[1.21.0] — 2026-05-14
Security + concurrency + a11y polish from two independent code-review passes. Seven findings from docs/specs/V1.20.1-BACKLOG.md shipped in one release: one blocker (DNS-rebind TOCTOU), six high-severity bugs (path-traversal sanitization spread, rate-limit gap on LAN deploy, concurrent-write race, i18n coverage hole, dangling aria-describedby, missing label associations). 34 new tests; baseline rose from 427 → 461 unit + 32/32 Playwright. Every fix lands behind a named regression test.
🛡️ Security
-
fix(security): B-1 — close DNS-rebind TOCTOU via safe-fetch.mjs(server/lib/safe-fetch.mjs) — the previous pattern did one explicitdnsLookupfor validation, then letfetch()do its own independent lookup. A DNS rebind attacker with TTL=0 could return a public IP on lookup 1 and127.0.0.1/169.254.169.254/ a LAN address on lookup 2, bypassingisPrivateOrLoopbackHost. The newsafeGetresolves ONCE, pins the TCP connection to that exact IP via node:http(s), and sets SNI/Host so cert validation still targets the original hostname. Used by/api/pipeline/previewand/api/auto-pipeline. Fail-CLOSED on lookup error (reverses the priortry { … } catch { /* fall through */ }). Validated by 8 new tests intests/ssrf-redirect-rebind.test.mjs. -
fix(security): H-4 — consolidate sanitizePathName across 10 routes(server/lib/security.mjs) — the barereplace(/[^\w\-.]/g, '')regex was duplicated acrossjds.mjs,content.mjs,reports.mjs,llm.mjs,runners.mjsand kept.characters, so..pdf,....md, leading-dot names survived. Onlyreports.mjs::sanitizeSlugdid it right. v1.21.0 hoists the correct version (sanitizePathName) intosecurity.mjs, deletes 10 broken copies, and rejects empty results with 400. Validated by 12 tests intests/path-traversal.test.mjs. -
fix(security): H-5 — rate-limit LLM endpoints on public bind(server/lib/rate-limit.mjs) —/api/evaluate,/api/deep,/api/mode/:slug,/api/auto-pipelinepreviously had no per-IP throttle. Loopback users are unaffected; LAN-exposed deploys (HOST=0.0.0.0) get 10 req/min/IP withRetry-AfterandX-RateLimit-*headers on overflow. Configurable viaLLM_RATE_LIMIT="N/Ws". Cheap interim defense ahead of the v2.0 P-12 auth gate. Validated by 6 tests intests/rate-limit.test.mjs.
🔒 Concurrency
fix(data): H-6 — per-file mutex on applications.md / pipeline.md(server/lib/file-lock.mjs) — concurrentPOST /api/tracker(or auto-pipeline racing a manual add) used to both readnum=42, both writenum=43, and silently drop the earlier row.withFileLock(path, fn)serializes read-modify-write per path; independent paths still run in parallel. Wired intotracker.mjs,pipeline.mjs(POST + DELETE), andauto-pipeline.mjstracker step. Validated by 5 tests intests/concurrent-tracker-write.test.mjsincluding a 20-concurrent-POST integration check that asserts rows 001..020 land sequentially.
♿ Accessibility
-
fix(a11y): H-1 — id="batch-tsv-hint" on the batch.js hint paragraph(public/js/views/batch.js) — v1.20.0 addedaria-describedby="batch-tsv-hint"to the TSV textarea but never gave the hint<p>a matchingid. Screen readers had nothing to voice. Fixed. -
fix(a11y): H-2 — htmlFor on batch-parallel / batch-min-score labels(public/js/views/batch.js) — four v1.20.0 inputs got new ids but their labels weren't programmatically associated. WCAG 3.3.2 now satisfied. -
New static-analysis canary in
tests/a11y-form-wires.test.mjs— walks every view file and asserts everyaria-describedby/htmlForIDREF points at a siblingid:declaration. Catches typo-class regressions at CI time.
🌐 i18n
fix(i18n): H-3 — 13 keys from v1.20.0 silently fell through to EN for 7 locales(public/js/lib/i18n.js) —pipe.filter,pipe.count,pipe.preview*,pipe.openTab,pipe.evaluateAll*,eval.jdHint,batch.parallelAria,batch.minScoreAria, pluscommon.delete,config.group{Core,Runtime,Regional},config.profileEmpty,config.viewProfile,scan.atsBadge,scan.regionalBadgewere referenced viat('key', 'EN fallback')but never added to DICT. Russian, Japanese, Chinese screen-reader users heard Englisharia-labels — directly defeating the WCAG 3.3.2 win v1.20.0 claimed. v1.21.0 adds all 19 keys × 8 locales (≈ 150 new translations) and extendstests/i18n-coverage.test.mjswith a static-analysis pass that scans everyt('key', …)call inpublic/js/**/*.jsand asserts each key exists in DICT. Future drift caught at CI time.
🧪 Tests
- 427 → 461 unit (+34) + 32/32 Playwright unchanged.
- New test files:
ssrf-redirect-rebind,path-traversal,concurrent-tracker-write,rate-limit,a11y-form-wires. - Existing
pipeline-preview.test.mjsrewired fromglobalThis.fetchmock to the new_setTransportinjection point insafe-fetch.mjs— the SSRF path no longer goes through fetch, so the old mock was bypassed silently.
Verification
npm test # 461 / 461
npm run test:e2e:browser # 32 / 32
node --test tests/ssrf-redirect-rebind.test.mjs tests/path-traversal.test.mjs \
tests/concurrent-tracker-write.test.mjs tests/rate-limit.test.mjs \
tests/a11y-form-wires.test.mjs # 34 new tests, all green
# Path-traversal: every traversal-style :name returns 400 / 404
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4317/api/jds/..pdf
# → 400
# Rate-limit on public bind:
HOST=0.0.0.0 LLM_RATE_LIMIT=3/60s npm start &
for i in 1 2 3 4; do
curl -sS -o /dev/null -w '%{http_code} ' -X POST -H 'Content-Type: application/json' \
-d '{"jd":"…"}' http://0.0.0.0:4317/api/evaluate
done
# → 200 200 200 429
# Concurrent tracker writes: 20 parallel POSTs, 20 rows land:
node tests/concurrent-tracker-write.test.mjs
# 20 sequential rows 001..020
# Aria wires sanity:
grep -r 'aria-describedby' public/js/views/ | wc -l
# matching `id:` lookups all resolve (a11y-form-wires.test.mjs canary)Out of scope (v1.22+)
| Item | Notes |
|---|---|
pipeline-preview body-size streaming cap (M-2)
| await upstream.text() reads full body before the 8 KB slice; malicious 1 GB stream could exhaust memory. Stream-read with byte counter + abort.
|
WCAG 1.4.1 — color-only state on .connection-banner + score pills (M-3)
| Hue alone signals state; add icon prefix (✓ / ◐ / ○) or text suffix. |
stripDangerousMarkdown bypasses via HTML entities (M-4)
| <script>, javascript:, <img src="data:image/svg+xml,<svg onload=…>"> survive the regex. Defense-in-depth via UI.md still holds; doc + lock bypasses in a test sweep.
|
Safari private-mode localStorage access without try/catch (M-5)
| i18n.js:544/571 throws → SPA renders raw keys. Wrap in try/catch with 'en' default.
|
setInterval(checkHealth, 3000) polls forever with no backoff (M-6)
| Exponential 3s → 6s → 12s → cap 60s. |
htmlFor alias missing null-guard (M-7)
| One-line if (v != null && v !== false) defense.
|
404 canary for retired /api/scan-ru/config (M-8)
| Three-line test mirroring v1.18 precedent. |
| Locale CHANGELOG body translations (M-9) | Bulk translation candidate after release cadence slows. |
| Inline-hint paragraphs for every mode-page field (M-1) | ~168 i18n keys × 8 locales; held back as polish item. |
| L-1 through L-5 nits | parseInt radix, bash --noprofile, Windows-safe fileURLToPath, multi-line SSE, scan.js timer cleanup. |