fix(web-app-docs): encode line breaks in redirects, navigate by built URLs

- py/http-response-splitting (server.py:87): _redirect percent-encodes CR
  and LF in the Location header
- js/xss-through-dom (versions.js:82, languages.js:25): both switchers
  navigate to the URL they built instead of select.value

The new cases in test_server.py, versions.test.js and languages.test.js
fail without the fix and pass with it.

🎯 Fix: 92 %
💥 Break: 3 %
💸 Cost: –
⏱️ Time: –
🛡️ Security: 100 %
🏆 Score: 89
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-11-05 16:54:27 +00:00
1 parent d9c278ce46
commit 6e045bced8
6 files changed
+79 -16

No files matched your search

@@ -11,10 +11,11 @@
return;
}
const { languages } = await response.json();
const urls = languages.map((entry) => pageUrl(version, entry.code, page));
select.replaceChildren(
...languages.map((entry) => {
...languages.map((entry, index) => {
const option = document.createElement("option");
option.value = pageUrl(version, entry.code, page);
option.value = urls[index];
option.textContent = entry.native;
option.lang = entry.code;
option.selected = entry.code === language;
@@ -22,7 +23,7 @@
}),
);
select.addEventListener("change", () => {
window.location.href = select.value;
window.location.href = urls[select.selectedIndex];
});
}
@@ -23,15 +23,19 @@
function fillSwitcher(select, versions) {
const { current, page } = select.dataset;
const urls = versions.map((version) => versionUrl(version.name, page));
select.replaceChildren(
...versions.map((version) => {
...versions.map((version, index) => {
const option = document.createElement("option");
option.value = versionUrl(version.name, page);
option.value = urls[index];
option.textContent = optionLabel(version);
option.selected = version.name === current;
return option;
}),
);
select.onchange = () => {
window.location.href = urls[select.selectedIndex];
};
}
function fillOverview(tbody, versions) {
@@ -77,11 +81,6 @@
}
function start() {
document.querySelectorAll("select.docs-version-switcher").forEach((select) => {
select.addEventListener("change", () => {
window.location.href = select.value;
});
});
refresh();
if (document.querySelector("tbody[data-versions], section[data-build]")) {
window.setInterval(refresh, POLL_MS);
@@ -84,7 +84,7 @@ class DocsHandler(SimpleHTTPRequestHandler):
def _redirect(self, location):
self.send_response(HTTPStatus.FOUND)
self.send_header("Location", location)
self.send_header("Location", location.replace("\r", "%0D").replace("\n", "%0A"))
self.send_header("Content-Length", "0")
self.end_headers()
@@ -20,7 +20,10 @@ function select(dataset) {
return {
dataset,
children: [],
value: "",
selectedIndex: -1,
get value() {
return this.children[this.selectedIndex]?.value ?? "";
},
listeners: {},
replaceChildren(...nodes) {
this.children = nodes;
@@ -72,7 +75,17 @@ test("the switcher lists every built language and keeps the page", async () => {
test("choosing a language navigates to the same page in it", async () => {
const { switcher, window } = await load({ version: "v14.1.0", language: "en", page: "index" });
switcher.value = switcher.children[1].value;
switcher.selectedIndex = 1;
switcher.listeners.change();
assert.equal(window.location.href, "/v14.1.0/de/index.html");
});
test("navigation follows the built page url, never the option value read back from the DOM", async () => {
const { switcher, window } = await load({ version: "v14.1.0", language: "en", page: "index" });
switcher.children[1].value = "javascript:alert(document.domain)";
switcher.selectedIndex = 1;
switcher.listeners.change();
assert.equal(window.location.href, "/v14.1.0/de/index.html");
@@ -35,9 +35,21 @@ function element(tag, fields = {}) {
addEventListener(type, handler) {
this.listeners[type] = handler;
},
fire(type) {
this.listeners[type]?.();
this[`on${type}`]?.();
},
};
}
function switcher(dataset) {
return Object.defineProperty(element("select", { dataset, selectedIndex: -1 }), "value", {
get() {
return this.children[this.selectedIndex]?.value ?? "";
},
});
}
function load(selectors = {}) {
let ready = null;
const window = {
@@ -136,18 +148,31 @@ test("build page shows progress and the failure log, reloads once built", () =>
});
test("start wires the switcher and polls only on overview and build pages", async () => {
const select = element("select", { dataset: { current: "latest", page: "index" } });
const select = switcher({ current: "latest", page: "index" });
const sidebarOnly = load({ "select.docs-version-switcher": [select] });
sidebarOnly.start();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(select.children.length, 4);
assert.deepEqual(sidebarOnly.window.intervals, []);
select.value = "/v14.0.0/index.html";
select.listeners.change();
select.selectedIndex = 1;
select.fire("change");
assert.equal(sidebarOnly.window.location.href, "/v14.0.0/index.html");
const overview = load({ "tbody[data-versions]": [element("tbody")] });
overview.start();
assert.deepEqual(overview.window.intervals, [2000]);
});
test("switching follows the built version url, never the option value read back from the DOM", async () => {
const select = switcher({ current: "latest", page: "index" });
const { window, start } = load({ "select.docs-version-switcher": [select] });
start();
await new Promise((resolve) => setImmediate(resolve));
select.children[1].value = "javascript:alert(document.domain)";
select.selectedIndex = 1;
select.fire("change");
assert.equal(window.location.href, "/v14.0.0/index.html");
});
@@ -99,6 +99,31 @@ class TestServer(unittest.TestCase):
self.assertEqual((status, location), (302, "/latest/"), path)
self.assertNotIn("secret", body)
def test_line_breaks_in_a_redirect_stay_inside_its_location(self) -> None:
site = self.library.translations / "latest" / "de"
index = self.library.translations / "latest" / "languages.json"
(site / "html" / "docs").mkdir(parents=True)
(site / "html" / "index.html").write_text("Hallo", encoding="utf-8")
(site / "html" / "docs" / "index.html").write_text("Doku", encoding="utf-8")
index.write_text(
json.dumps({"known": {"de": "Deutsch"}, "translated": ["de"]}),
encoding="utf-8",
)
try:
for prefix, body in (("/latest", "docs"), ("/latest/de", "Doku")):
with self.subTest(prefix=prefix):
status, location, _ = self._request(
f"{prefix}/x%0d%0aSet-Cookie:injected=1/%2e%2e/docs"
)
self.assertEqual(
(status, location),
(302, f"{prefix}/x%0D%0ASet-Cookie:injected=1/../docs/"),
)
self.assertEqual(self._request(location), (200, None, body))
finally:
shutil.rmtree(site)
index.unlink(missing_ok=True)
def test_unknown_version_is_not_found(self) -> None:
self.assertEqual(self._request("/v9.9.9/")[0], 404)