v0.122.1 — CodeQL security backlog
The security debt CodeQL reports on main (93 open alerts as of v0.122.0, 15 critical), worked by severity. Modules: dnd5e 0.22.1, system5 0.10.1, system8 0.21.1 (escaping and linear regexes only; no API change). Plan: planning roadmap/2026-Q4/codeql-security-backlog.md. Rate limiting (16 of the 93) is its own entry, API rate limiting, and is not part of this release.
- [x] Server-side request forgery: one outbound URL guard (http/https only; private, loopback, link-local and metadata addresses refused, re-checked on every redirect; size and time limits) for every server-side fetch of a user-influenced URL, at all eight JS sites and in the SAM2 service; a guard test.
- Note:
packages/server-core/src/utils/outboundUrl.ts—safeFetchresolves the host and refuses any private, loopback, link-local (metadata), unique-local, multicast or reserved address, IPv4-mapped/NAT64/6to4 forms included; connects to the address it checked (no DNS rebinding window); follows redirects by hand, re-checking each hop and dropping credentials across origins; one deadline and a body cap (decompressed). The eight sites, plus the unflagged ones of the same shape (/api/images/upload, OpenAI's result URL,uploadImageFromUrl, the .cat export's stored images), go through it. Two routes resolved a relative image path against the request'sHostheader and exempted URLs matching it —Host: 169.254.169.254reached the metadata service — and the map generator POSTed the caller's token and the operator's key to theHostheader; all now usePLATFORM_BASE_URLor this server over loopback. The system data proxy and token artwork keep their host allowlists, now checked on every hop; image URL import has no host allowlist (private-address refusal plus size and time limits, the plan's recommended answer). Allowed to resolve privately: this server on loopback at its ownPORT, the hosts ofFRONTEND_URL/APP_BASE_URL/PLATFORM_BASE_URL/the storage public URL bases, andOUTBOUND_ALLOW_PRIVATE_HOSTS(.env.example). The SAM2 service has the same rule inurl_guard.py(SAM2_URL_ALLOW_PRIVATE_HOSTS; pins each hop to the address it checked, with the name asHostand TLS SNI).utils/outboundUrl.guard.test.tsfails on any rawfetchin server-core without an// outbound:reason; map export's stored image URLs go throughfetchUserImageas the .cat export's do.
- Note:
- [x] Type confusion: request values read as the type they must be at the six flagged sites (images, map export, textures).
- Note: map export's import and the texture upload take a multipart body only as bytes (a body another parser made into an array or object is a 400, where it used to hang waiting on a consumed stream),
parseMultipartparses nothing but a Buffer with a string boundary, and the legacy JSON import'szipData/campaignId/mapNamemust be single strings. The tworoutes/images.tssites are fixed with the SSRF item, which rewrites that code.
- Note: map export's import and the texture upload take a multipart body only as bytes (a body another parser made into an array or object is a 400, where it used to hang waiting on a consumed stream),
- [x] Path injection in the local storage provider, and the three unvalidated dynamic method calls.
- Note: the local provider resolves every key strictly inside its root and refuses an empty key, NUL, an absolute (POSIX or Windows) path and any
..segment; it no longer accepts the root itself, sodelete("")ordelete(".")could have removed the whole store. The inpaint provider, a grant's holder type (constructorused to resolve toObject) and the die shape are looked up only as own properties that are functions, or as Map entries; anything else is refused.
- Note: the local provider resolves every key strictly inside its root and refuses an empty key, NUL, an absolute (POSIX or Windows) path and any
- [x] DOM XSS: the four sinks take text, or HTML through the app's sanitizer.
- Note:
TraitDataGrid's three SVG previews go throughsanitizeSvg(DOMPurify, SVG profile, besidesanitizeRichHtml);SystemContext's iframe and open-in-tab link take only an http(s) URL (safeHttpUrl, new in@purple-cat/shared'stextEscape.ts);CroppedTokenArtwork, imported nowhere, is deleted, and with it theface-api.jsdependency it alone used. Tests:domXssSinks.test.ts,textEscape.test.ts. No module changed.
- Note:
- [x] Sanitization and escaping: the incomplete (multi-character) sanitization, double escaping and URL substring checks, in the platform, the web app and the modules (with module version bumps).
- Note: generated action code escapes every interpolated string with
escapeJsString(@purple-cat/shared, one pass: backslash, both quotes, backtick,$, line breaks), including the wiki URL, incharacterToToken.tsand the modules'projectToken.tsandbuffSpells.ts;scripts/vendorConditionIcons.mjsescapes the backslash with the quote. Tag stripping and entity decoding go throughstripHtmlTags/stripHtmlComments/decodeHtmlEntities(linear, each entity decoded once) inactionDoc.ts(the double-escaping, multi-character and ReDoS alerts on its line 481),docxReader.tsandwiki/abstract.ts. Storage hosts are matched by parsed hostname (hostnameIsOrUnder) inartworkIngest.tsanduseTokenArtworkEditor.ts. The test-file alerts: the transport matrix'scalls.includesis array membership (nowsome(u => u === …)), andconditionBadge.test.tschecks/<script/i. Modules changed, bumped as patches: dnd5e 0.22.1, system5 0.10.1, system8 0.21.1.
- Note: generated action code escapes every interpolated string with
- [x] Polynomial ReDoS: the twelve regexes rewritten or their input bounded, each with a pathological-input test.
- Note: eleven sites rewritten as linear scans (or, for the slug trims,
/^-|-$/g, the same trim once runs are single hyphens), each tested against its old regex on real data and seeded random strings, with 50,000-character inputs under 100 ms;chatPreviewalso gets its<[^>]+>pass. The twelfth,actionDoc.ts's tag strip, is left to the sanitization item. Modules changed: dnd5e, system5, system8 (the bumps are the sanitization item's).
- Note: eleven sites rewritten as linear scans (or, for the slug trims,
- [x] Tainted format strings in log calls, and the three medium alerts (CORS origins, workflow permissions, identity replacement).
- Note: all 55 server-core console calls that put an interpolated template before further arguments now pass a fixed format with the values behind
%s(the 11 open alerts among them;services/ingestLibraryAsset.tsno longer exists), andlogFormatStrings.guard.test.tsfails on a new one. CORS is an explicit list in development too: localhost,*.localhost, private and Tailscale IPv4 ranges,*.localand single-label hosts on any port, plusFRONTEND_URLand the newCORS_ALLOWED_ORIGINS(where a program host names its*.ts.netorigin). The Terraform workflow getscontents: read; thesendMessagecompletion loses a replace that swapped a backtick for a backtick.
- Note: all 55 server-core console calls that put an interpolated template before further arguments now pass a fixed format with the values behind
- [x] Re-run CodeQL; what remains is dismissed only with a written reason.
- Note: main's first analysis after the merge (with v0.124.0,
1e29ca32) had 18 of the 92 open: the 16js/missing-rate-limitingalerts, left to the API rate limiting roadmap entry, and #110 and #89, which v0.124.1 fixes. None was dismissed.
- Note: main's first analysis after the merge (with v0.124.0,