Skip to content

Commit

Permalink
Bug 1659604 [wpt PR 25061] - Enable text fragments across redirects, …
Browse files Browse the repository at this point in the history
…a=testonly

Automatic update from web-platform-tests
Enable text fragments across redirects

A text fragment occurs in a URL fragment and begins with ":~:text=...".
It is used to highlight and scroll the provided text into view when the
page is loaded. For user privacy reasons, we restrict scrolling the text
into view unless the navigation occurred via a user gesture. See:
https://github.com/WICG/scroll-to-text-fragment#security-considerations
for more details.

However, it is common (particularly on social and messaging services
where content is user-generated) for links to be served via a redirect.
A typical example (from chat.google.com) works like this:

 1. User receives and clicks a link to https://example.com#:~:text=foo"
 2. chat.google.com opens a new tab using window.open("", "_blank")
 3. chat.google.com calls document.write on the newly opened window to
   write a <meta> tag-based client redirect to
   google.com/url?url=https://example.com... which is the URL
   redirection service with the destination URL as a query param.
 4. google.com/url then calls window.location and writes
   "https://example.com#:~:text=foo" into it
 5. the new tab finally navigates to example.com

The only navigation that had a user gesture attached to it is the
initial empty document navigation in step 2. This means the example.com
page is navigated to without a user gesture and the text fragment is
blocked. A similar pattern is seen on many popular services: Twitter,
Instagram, Facebook Messenger, etc.

This CL solves the above scenario by introducing a "text fragment
token". This token grants its holder permission to invoke a text
fragment. The token can be used during load to invoke the text fragment,
or it can be passed into a navigation to grant permission to the next
page without requiring a user gesture. However, in either case, the
token is consumed so a page cannot both invoke a text fragment and pass
the token.

The token is created in only in DocumentLoader's constructor and while
processing a same-document navigation. For regular navigations, it is
only created if the current navigation was user initiated. For
same-document navigations, it's created only if browser-initiated and
the navigation has a text fragment. This mechanism can be thought of as
a user gesture that applies only to text fragment and whose lifetime
extends across navigations but cannot be copied and is always consumed
on use.

Bug: 1055455
Change-Id: Icddd849937d24b579bbeb5a4b9f87539d8339905
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2159324
Reviewed-by: Mike West <mkwst@chromium.org>
Reviewed-by: Avi Drissman <avi@chromium.org>
Commit-Queue: David Bokan <bokan@chromium.org>
Cr-Commit-Position: refs/heads/master@{#799173}

--

wpt-commits: 392793004f49b07eb813b2c600ef438d4b176524
wpt-pr: 25061
  • Loading branch information
bokand authored and moz-wptsync-bot committed Aug 25, 2020
1 parent d469d28 commit b3cbcee
Show file tree
Hide file tree
Showing 4 changed files with 208 additions and 0 deletions.
@@ -0,0 +1,12 @@
<!doctype html>
<script>
const query = window.location.search.substr(1);
const type = query.split('&')[0];
const url = decodeURIComponent(query.split('&')[1]);

if (type == 'meta') {
document.write(`<meta http-equiv="Refresh" content="0; URL=${url}">`);
} else if (type == 'location') {
window.location = url;
}
</script>
@@ -0,0 +1,42 @@
<!doctype html>
<title>Destination of a Redirect</title>
<script src="stash.js"></script>
<script>
function checkScroll() {
// Two rAFs since the exact timing of when we cause scrolling is up to the
// UA.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
let twice = (new URL(document.location)).searchParams.get("twice");
let key = (new URL(document.location)).searchParams.get("key");
let results = {
scrolled: (window.pageYOffset != 0),
};

if (twice != null) {
// If this param is specified, we'll try to redirect to another
// text-fragment after this one has been invoked.
if (!results.scrolled) {
results.scrolled = null;
stashResultsThenClose(key, results);
throw "Intermediate page failed to scroll to fragment";
}

window.location = `redirects-target2.html?key=${key}#:~:text=target`;
} else {
stashResultsThenClose(key, results);
}
});
});
}
window.addEventListener('load', checkScroll);
</script>
<style>
p#target {
margin: 2000px 0px 2000px 0px;
}
</style>
<body>
<p>Top of page</p>
<p id="target">target</p>
</body>
@@ -0,0 +1,28 @@
<!doctype html>
<title>Destination of a Redirect</title>
<script src="stash.js"></script>
<script>
function checkScroll() {
// Two rAFs since the exact timing of when we cause scrolling is up to the
// UA.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
let results = {
scrolled: (window.pageYOffset != 0),
};
let key = (new URL(document.location)).searchParams.get("key");
stashResultsThenClose(key, results);
});
});
}
window.addEventListener('load', checkScroll);
</script>
<style>
p#target {
margin: 2000px 0px 2000px 0px;
}
</style>
<body>
<p>Top of page</p>
<p id="target">target</p>
</body>
126 changes: 126 additions & 0 deletions testing/web-platform/tests/scroll-to-text-fragment/redirects.html
@@ -0,0 +1,126 @@
<!doctype html>
<title>TextFragment invoked on redirects</title>
<meta charset=utf-8>
<link rel="help" href="https://wicg.github.io/ScrollToTextFragment/">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<script src="/resources/testdriver.js"></script>
<script src="/resources/testdriver-vendor.js"></script>
<script src="/common/get-host-info.sub.js"></script>
<script src="/common/utils.js"></script>
<script src="stash.js"></script>

<!--See comment in scroll-to-text-fragment.html for why these tests have the
structure they do. -->
<script>
// This test ensure correct operation of text-fragments through both HTTP and
// client side redirects in various scenarios.

// Constructs a URL to either redirect.py or the local client-redirect.html;
// which will cause an HTTP or client based redirect, respectively, to
// |to_url|. |type| provides a numeric 30x code to specify an HTTP redirect,
// "location" for a write to window.location, or "meta" for a <meta> refresh.
function buildRedirectUrl(to_url, type) {
let dest = "";
to_url = encodeURIComponent(to_url);

if (typeof type == "number") {
// If the type is a number, it's an HTTP response code, use redirect.py to
// respond with an HTTP redirect.
const code = type;
dest = `${get_host_info().ORIGIN}/common/redirect.py?status=${code}&location=${to_url}`;
} else if (type == 'meta' || type == 'location') {
// Otherwise we're requesting a client-side redirect, either a <meta> tag
// or window.location. Use the client-redirect file to bounce to the
// destination.
dest = `client-redirect.html?${type}&${to_url}`;
}
return dest;
}

// Turns |path| from a relative-to-this-file path into a full URL.
function relativePathToFull(path) {
const pathname = window.location.toString();
const base_path = pathname.substring(0, pathname.lastIndexOf('/') + 1);
return base_path + path;
}

const status_codes = [301, 302, 303, 307, 308];

// Test that an HTTP redirect to a URL with a text fragment invokes the
// fragment.
for (let code of status_codes) {
promise_test(t => new Promise((resolve, reject) => {
let key = token();

const abs_url = relativePathToFull(`redirects-target.html?key=${key}#:~:text=target`);
const url = buildRedirectUrl(abs_url, code);

test_driver.bless('Open a URL with a text fragment directive', () => {
window.open(url, '_blank', 'noopener');
});

fetchResults(key, resolve, reject);
}).then(data => {
assert_equals(data.scrolled, true);
}), `Text fragment works from HTTP ${code} redirect.`);
}

// Test that a URL with a text fragment that causes an HTTP redirect preserves
// the fragment and invokes it on the destination page.
for (let code of status_codes) {
promise_test(t => new Promise((resolve, reject) => {
let key = token();

const abs_url = relativePathToFull(`redirects-target.html?key=${key}`);
const url = buildRedirectUrl(abs_url, code) + "#:~:text=target";

test_driver.bless('Open a URL with a text fragment directive', () => {
window.open(url, '_blank', 'noopener');
});

fetchResults(key, resolve, reject);
}).then(data => {
assert_equals(data.scrolled, true);
}), `Text fragment propagated through HTTP ${code} redirect.`);
}

// Test that client-side redirects (using script) to a URL with a text fragment
// cause the text fragment to be invoked.
for (let type of ['location', 'meta']) {
promise_test(t => new Promise((resolve, reject) => {
let key = token();

const to_url = `redirects-target.html?key=${key}#:~:text=target`
const url = buildRedirectUrl(to_url, type);

test_driver.bless('Open a URL with a text fragment directive', () => {
window.open(url, '_blank', 'noopener');
});

fetchResults(key, resolve, reject);
}).then(data => {
assert_equals(data.scrolled, true);
}), `Text fragment works on client-side ${type} redirect.`);
}

// Test that client-side redirects (using script) to a URL with a text fragment
// cause the text fragment to be invoked only the first time. A further
// redirect without a user gesture is blocked.
for (let type of ['location', 'meta']) {
promise_test(t => new Promise((resolve, reject) => {
let key = token();

const to_url = `redirects-target.html?twice&key=${key}#:~:text=target`
const url = buildRedirectUrl(to_url, type);

test_driver.bless('Open a URL with a text fragment directive', () => {
window.open(url, '_blank', 'noopener');
});

fetchResults(key, resolve, reject);
}).then(data => {
assert_equals(data.scrolled, false);
}), `One text fragment per user gesture allowed in client-side ${type} redirect.`);
}
</script>

0 comments on commit b3cbcee

Please sign in to comment.