diff --git a/CHANGELOG.md b/CHANGELOG.md index a250b6c..f13d6ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ project adheres to [Semantic Versioning](https://semver.org/). - Playwright setup note in the Quick start section of `README.md`. ### Fixed +- Saved-case GUI paths with malformed percent-encoding now return HTTP 400. + `decodeURIComponent` used to throw, and the request became HTTP 500. - The workbench comparison panel now says the comparison is unavailable when the pair is not comparable. It previously said no compared field differs, which disagreed with the Markdown download for the same result. diff --git a/bin/aas-gui.mjs b/bin/aas-gui.mjs index a5b15cf..c6b7872 100644 --- a/bin/aas-gui.mjs +++ b/bin/aas-gui.mjs @@ -577,6 +577,14 @@ compareButton.addEventListener('click',async()=>{ `; } +function savedPathId(pathname, prefix) { + try { + return decodeURIComponent(pathname.slice(prefix.length)); + } catch { + return null; + } +} + export function hasOnlySingleOptions(params, allowed) { return [...params.keys()].every((key) => allowed.has(key) && params.getAll(key).length === 1); } @@ -717,7 +725,7 @@ export function createGuiServer({ return; } if (request.method === "POST" && url.pathname.startsWith("/api/replay-saved/")) { - const runId = decodeURIComponent(url.pathname.slice("/api/replay-saved/".length)); + const runId = savedPathId(url.pathname, "/api/replay-saved/"); if (!isReviewRunId(runId) || url.search) { sendJson(response, 400, { error: "Invalid saved verification request." }); return; } try { assertFullStackNodeVersion( @@ -821,7 +829,7 @@ export function createGuiServer({ return; } if (request.method === "GET" && url.pathname.startsWith("/api/review/")) { - const runId = decodeURIComponent(url.pathname.slice("/api/review/".length)); + const runId = savedPathId(url.pathname, "/api/review/"); if (!isReviewRunId(runId) || url.search) { sendJson(response, 400, { error: "Invalid case review request." }); return; } let content; try { content = renderCaseMarkdown(inspectCase(runId, { outputRoot })); } @@ -830,7 +838,7 @@ export function createGuiServer({ response.end(content); return; } if (request.method === "GET" && url.pathname.startsWith("/api/bundle/")) { - const runId = decodeURIComponent(url.pathname.slice("/api/bundle/".length)); + const runId = savedPathId(url.pathname, "/api/bundle/"); if (!isReviewRunId(runId)) { sendJson(response, 400, { error: "Invalid saved run ID." }); return; } let bundle; try { bundle = exportRunBundle(runId, { outputRoot }); } diff --git a/test/gui.test.mjs b/test/gui.test.mjs index 3a589d2..07c3397 100644 --- a/test/gui.test.mjs +++ b/test/gui.test.mjs @@ -686,6 +686,25 @@ test("GUI compare refuses a symlinked runs directory", (t) => { }); }); +test("malformed percent-encoding in a saved-case path is a client error", async () => { + const server = createGuiServer({ outputRoot: mkdtempSync(join(tmpdir(), "aas-gui-pct-")) }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const origin = `http://127.0.0.1:${server.address().port}`; + try { + for (const path of ["/api/bundle/%ZZ", "/api/review/%E0%A4%A", "/api/replay-saved/%"]) { + const response = await requestServer(server, path, { + method: path.startsWith("/api/replay-saved/") ? "POST" : "GET", + headers: { origin }, + }); + assert.equal(response.status, 400, path); + assert.match(response.body, /Invalid/); + assert.doesNotMatch(response.body, /Request failed/); + } + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } +}); + test("GUI exposes a domain selector defaulting to refund", () => { const page = renderPage(); assert.match(page, /