Skip to content

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 — safeFetch resolves 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's Host header and exempted URLs matching it — Host: 169.254.169.254 reached the metadata service — and the map generator POSTed the caller's token and the operator's key to the Host header; all now use PLATFORM_BASE_URL or 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 own PORT, the hosts of FRONTEND_URL/APP_BASE_URL/PLATFORM_BASE_URL/the storage public URL bases, and OUTBOUND_ALLOW_PRIVATE_HOSTS (.env.example). The SAM2 service has the same rule in url_guard.py (SAM2_URL_ALLOW_PRIVATE_HOSTS; pins each hop to the address it checked, with the name as Host and TLS SNI). utils/outboundUrl.guard.test.ts fails on any raw fetch in server-core without an // outbound: reason; map export's stored image URLs go through fetchUserImage as the .cat export's do.
  • [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), parseMultipart parses nothing but a Buffer with a string boundary, and the legacy JSON import's zipData / campaignId / mapName must be single strings. The two routes/images.ts sites are fixed with the SSRF item, which rewrites that code.
  • [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, so delete("") or delete(".") could have removed the whole store. The inpaint provider, a grant's holder type (constructor used to resolve to Object) and the die shape are looked up only as own properties that are functions, or as Map entries; anything else is refused.
  • [x] DOM XSS: the four sinks take text, or HTML through the app's sanitizer.
    • Note: TraitDataGrid's three SVG previews go through sanitizeSvg (DOMPurify, SVG profile, beside sanitizeRichHtml); SystemContext's iframe and open-in-tab link take only an http(s) URL (safeHttpUrl, new in @purple-cat/shared's textEscape.ts); CroppedTokenArtwork, imported nowhere, is deleted, and with it the face-api.js dependency it alone used. Tests: domXssSinks.test.ts, textEscape.test.ts. No module changed.
  • [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, in characterToToken.ts and the modules' projectToken.ts and buffSpells.ts; scripts/vendorConditionIcons.mjs escapes the backslash with the quote. Tag stripping and entity decoding go through stripHtmlTags / stripHtmlComments / decodeHtmlEntities (linear, each entity decoded once) in actionDoc.ts (the double-escaping, multi-character and ReDoS alerts on its line 481), docxReader.ts and wiki/abstract.ts. Storage hosts are matched by parsed hostname (hostnameIsOrUnder) in artworkIngest.ts and useTokenArtworkEditor.ts. The test-file alerts: the transport matrix's calls.includes is array membership (now some(u => u === …)), and conditionBadge.test.ts checks /<script/i. Modules changed, bumped as patches: dnd5e 0.22.1, system5 0.10.1, system8 0.21.1.
  • [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; chatPreview also 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).
  • [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.ts no longer exists), and logFormatStrings.guard.test.ts fails on a new one. CORS is an explicit list in development too: localhost, *.localhost, private and Tailscale IPv4 ranges, *.local and single-label hosts on any port, plus FRONTEND_URL and the new CORS_ALLOWED_ORIGINS (where a program host names its *.ts.net origin). The Terraform workflow gets contents: read; the sendMessage completion loses a replace that swapped a backtick for a backtick.
  • [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 16 js/missing-rate-limiting alerts, left to the API rate limiting roadmap entry, and #110 and #89, which v0.124.1 fixes. None was dismissed.