diff --git a/roles/web-app-docs/files/javascript/languages.js b/roles/web-app-docs/files/javascript/languages.js index 261c3043c6..551b5f5ed1 100644 --- a/roles/web-app-docs/files/javascript/languages.js +++ b/roles/web-app-docs/files/javascript/languages.js @@ -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]; }); } diff --git a/roles/web-app-docs/files/javascript/versions.js b/roles/web-app-docs/files/javascript/versions.js index 054ec8ca34..e5fe556352 100644 --- a/roles/web-app-docs/files/javascript/versions.js +++ b/roles/web-app-docs/files/javascript/versions.js @@ -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); diff --git a/roles/web-app-docs/files/python/infinito_docs/server.py b/roles/web-app-docs/files/python/infinito_docs/server.py index b5a9130f4c..dcd23d1584 100644 --- a/roles/web-app-docs/files/python/infinito_docs/server.py +++ b/roles/web-app-docs/files/python/infinito_docs/server.py @@ -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() diff --git a/tests/unit/javascript/roles/web-app-docs/files/languages.test.js b/tests/unit/javascript/roles/web-app-docs/files/languages.test.js index 102e0dd10e..c179bfd880 100644 --- a/tests/unit/javascript/roles/web-app-docs/files/languages.test.js +++ b/tests/unit/javascript/roles/web-app-docs/files/languages.test.js @@ -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"); diff --git a/tests/unit/javascript/roles/web-app-docs/files/versions.test.js b/tests/unit/javascript/roles/web-app-docs/files/versions.test.js index ec4b278a88..15eb56d64f 100644 --- a/tests/unit/javascript/roles/web-app-docs/files/versions.test.js +++ b/tests/unit/javascript/roles/web-app-docs/files/versions.test.js @@ -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"); +}); diff --git a/tests/unit/python/roles/web-app-docs/files/infinito_docs/test_server.py b/tests/unit/python/roles/web-app-docs/files/infinito_docs/test_server.py index 204822bf5e..05b9378fd3 100644 --- a/tests/unit/python/roles/web-app-docs/files/infinito_docs/test_server.py +++ b/tests/unit/python/roles/web-app-docs/files/infinito_docs/test_server.py @@ -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)