From 6a8dcc1de56532184e2f00a950e9f25da2ad672d Mon Sep 17 00:00:00 2001 From: NX1X <124435875+NX1X@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:47:21 +0300 Subject: [PATCH] security: fix lang-guard prototype pollution, harden api/version - src/main.js: the lang guard used !STRINGS[state.lang], an object-key lookup rather than an allowlist check, so ?lang=__proto__ (or any other Object.prototype key) passed it. state.lang would then reach STRINGS[state.lang].aboutDataItems as undefined and throw, blanking the page for whoever clicked a crafted link. Switch to a strict he/en allowlist. - scripts/prerender.mjs: assert loc.slug matches [a-z0-9-] before using it in a path join. Currently safe only because build_data.py already collapses slugs to that charset; this makes the guarantee local instead of relying on the data pipeline never changing. - api/version.mjs: add a 5s timeout to the outbound GitHub fetch. - .devcontainer/devcontainer.json: pin the base image by digest. --- .devcontainer/devcontainer.json | 2 +- api/version.mjs | 1 + scripts/prerender.mjs | 6 ++++++ src/main.js | 5 ++++- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b68c181..ca1ff34 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ { "name": "OpenTaxMap", - "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04@sha256:d94c97dd9cacf183d0a6fd12a8e87b526e9e928307674ae9c94139139c0c6eae", "features": { "ghcr.io/devcontainers/features/node:1": { "version": "22" }, diff --git a/api/version.mjs b/api/version.mjs index 326b1af..f85dc89 100644 --- a/api/version.mjs +++ b/api/version.mjs @@ -15,6 +15,7 @@ export default { try { const gh = await fetch(RELEASES_API, { headers: { 'user-agent': 'opentaxmap', accept: 'application/vnd.github+json' }, + signal: AbortSignal.timeout(5000), }) if (gh.ok) { const data = await gh.json() diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index 1c6c395..9f5bb2a 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -48,6 +48,12 @@ const latestYear = String(Math.max(...data.years)) let count = 0 for (const loc of data.localities) { + // Safe today only because build_data.py collapses slugs to [a-z0-9-]; make + // that guarantee local so a future data-pipeline change can't turn + // loc.slug into a path-traversal segment for the join() below. + if (!/^[a-z0-9][a-z0-9-]*$/.test(loc.slug)) { + throw new Error(`unsafe locality slug: ${JSON.stringify(loc.slug)}`) + } const b = loc.benefits[latestYear] const years = Object.keys(loc.benefits).sort() const rateText = b diff --git a/src/main.js b/src/main.js index 57a9f39..b8ff880 100644 --- a/src/main.js +++ b/src/main.js @@ -32,7 +32,10 @@ const state = { changes: params.get('changes') === '1', selected: null, } -if (!STRINGS[state.lang]) state.lang = 'he' +// Strict allowlist, not a STRINGS[key] lookup: a ?lang=__proto__ (or any +// other Object.prototype key) would otherwise pass this guard and later +// blow up at STRINGS[state.lang].aboutDataItems, reflected client-side DoS. +if (state.lang !== 'he' && state.lang !== 'en') state.lang = 'he' let t = makeT(state.lang) let data = null