mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-10 12:47:04 +00:00
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:
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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user