Skip to content

fix(security): close M-2 — image-URL host allowlist for Chronicle media#56

Merged
keyxmakerx merged 3 commits into
mainfrom
claude/fm-sec-chunk-2
May 23, 2026
Merged

fix(security): close M-2 — image-URL host allowlist for Chronicle media#56
keyxmakerx merged 3 commits into
mainfrom
claude/fm-sec-chunk-2

Conversation

@keyxmakerx

Copy link
Copy Markdown
Owner

Cites: 2026-05-21-core-tenets §T-B1; reports/foundry/2026-05-22-fm-security-audit.md §2 M-2, §4 Chunk 2, §0.5 D1=(c); dispatches/foundry/FM-SEC-CHUNK-2.md
Security implication: closes M-2 (latent exfiltration vector). Without a host allowlist, a tampered Chronicle response could return image_url: "https://attacker.com/track.png"; the Foundry client would fetch from the attacker host, leaking the player's IP, referer, and any cookies/credentials applicable to that host. With this change, full-URL image sources from Chronicle must match the configured apiUrl's scheme + hostname; mismatches are dropped with a console warning. Relative paths flow through unchanged.
Consumer-verified: Chronicle serves map images from its own /media/... path under apiUrl; the inventory in §1.4 of the audit confirmed every observed image_url shape resolves to the same host as apiUrl. No CDN-host setting exists anywhere in the module config (D1.1 sub-question default applies: strict hostname equality).
Foundry compatibility: v12 / v13 / v14 — uses standard new URL() (available across the Electron Chromium range) and existing game.settings.get(MODULE_ID, 'apiUrl') accessor. No new globals.
Mockup: n/a — visual surface unchanged for any legitimate image (same-host). A tampered cross-host URL surfaces as a blank <img> ({{#if image}} guard in the Handlebars template) plus a console warning; the rest of the map renders.

What this changes

File Change
scripts/_url-validation.mjs (NEW, 63 lines) Pure helper exporting _isAllowedImageHost(url, apiUrl) (scheme+hostname allowlist; fail-closed on any parse failure or non-string input) and _describeRejection(kind, url, apiUrl) (consistent warning-message format across callsites).
scripts/map-sync.mjs Wires the check into three callsites: _mapImageSrc (full-URL fields on the map row), _resolveMediaUrl (full-URL response from /media/:id), and _buildMapMeta (drops the raw-field fallback that previously bypassed _mapImageSrc).
scripts/map-viewer.mjs Wires the check into two callsites: _mapImageSrc (player-side reader of the chronicleMapMeta.image_url flag) and _prepareToken (token-image overlay rendered as &lt;img src=...&gt;).
tools/test-url-validation.mjs (NEW, 23 tests) Behavioral tests for _isAllowedImageHost (matching host, port-different match, attacker host, subdomain rejection, protocol mismatch, userinfo-confusion phishing pattern, malformed URL, empty / null / non-string inputs, javascript: / data: / file: rejection) + static-source integration pins that both consumer files import the helper AND that _mapImageSrc / _prepareToken contain the host-check call.

Why

The Foundry-side security audit (§2 M-2, §4 Chunk 2, §0.5 D1=(c)) flagged that the module's map rendering accepted full-URL image_url values from Chronicle without verifying the host. The risk is silent: a single tampered response could redirect any client to an attacker host. The audit's §0.5 resolved this as D1=(c) — targeted host-allowlist on the image fields specifically, leaving the rest of the URL-handling surface for later passes.

The shared-helper pattern (scripts/_url-validation.mjs) follows the same single-source-of-truth shape used in FM-SEC-CHUNK-7's _descriptor-validator.mjs: every callsite goes through one function, and the static-source test pins both the import AND the in-function reference so a future refactor that splits or skips a callsite fails CI.

Test plan

  • node --test tools/test-*.mjs passes locally (279 baseline + 23 new = 302/302)
  • All five callsites covered by the static-source pins (2 import-statement pins + 2 in-function-body pins + behavioral pins for _isAllowedImageHost)
  • Behavioral tests cover: matching host (✓), port-different match (✓), query-string preserved (✓), attacker host (✗), subdomain (✗ — D1.1 default), protocol mismatch (✗), userinfo phishing pattern (✗), malformed URL (✗), empty / null / non-string (✗), javascript: / data: / file: (✗), trailing-slash on apiUrl tolerated (✓), empty / null / malformed apiUrl fail-closed (✗)
  • Manual verification in Foundry: launch a world with a configured Chronicle apiUrl; open a Chronicle-linked map; verify the image still renders and no warning fires. Then temporarily edit a page flag to set chronicleMapMeta.image_url to https://attacker.com/x.png; reload; verify the image goes blank, the page still renders, and the browser console shows Chronicle [map_image]: rejected cross-host image URL ....

Tenet self-check

  • T-B1 security: closes M-2; fail-closed on every malformed-input path
  • T-B2 plugin isolation: changes stay within chronicle-foundry-module
  • T-B3 production UI: silent on the happy path; cross-host rejection surfaces as a console warning (the operator's intended diagnostic surface for "Chronicle returned something unexpected") plus a benign blank slot in the overlay. The map itself continues to render
  • T-B4 dual-audience docs: helper file + per-callsite comments + test preamble explain the design and rationale for both audiences
  • T-O4 single source of truth: all five callsites share one validator, pinned by static-source integration tests

Stop-and-flag (per dispatch)

Neither stop-and-flag condition triggered:

  • No CDN-host setting exists in scripts/settings.mjsgrep -i 'cdn\|mediaHost\|media_host\|imageHost' returns only comment references in update-info.mjs. D1.1 sub-question default applies (strict hostname equality).
  • No subdomain-of-apiUrl shape observed in the audit inventory or in chronicle-package.json. If one surfaces post-merge, the helper can be relaxed by walking up the host's eTLD+1 without touching any of the five callsites.

Drift from dispatch

The dispatch listed three callsites; inventory surfaced five. The two additions:

  • map-sync.mjs::_resolveMediaUrl — the /media/:id endpoint response was also taking the unsafe full-URL branch (line 285 pre-change). Closing that here avoids leaving a parallel exfiltration path open through the signed-URL flow.
  • map-sync.mjs::_buildMapMeta — the persisted chronicleMapMeta.image_url field had a raw-field fallback (mapData.image_url || mapData.image_path) that bypassed _mapImageSrc. With M-2 active, a rejected URL would still get persisted onto the flag and re-surface to players. Changed to imageSrc || '' so the flag only ever holds what the validator approved.

Both extensions stay within the dispatch's scope ("image-URL host allowlist") and the audit's §0.5 D1=(c) wording ("targeted host-allowlist on the image fields").

Note on commit history

Three commits via MCP push_files (signing infra failure on chronicle-foundry-module — 9th confirmed reproduction). The split is driven by file-size budget on each push, not logical commits — squash-on-merge will collapse cleanly:

  1. bc8c13e — Add _url-validation.mjs + tests (CI green standalone since callsites would still match the static-source pin once they land).
  2. e439ac5 — Wire into map-sync.mjs.
  3. 5ebee7d — Wire into map-viewer.mjs.

🤖 Cites dispatches/foundry/FM-SEC-CHUNK-2.md. Generated by Claude Code.


Generated by Claude Code

…2 part 1)

Pure host-allowlist helper for full-URL image sources from Chronicle.
Closes M-2 latent exfiltration vector (FM-SECURITY-AUDIT §2, §4 Chunk 2,
§0.5 D1=(c)). Wired into map-sync.mjs + map-viewer.mjs in part 2.

23 new tests covering allowed/rejected host, port-different match,
subdomain rejection (D1.1 default: strict hostname equality), protocol-
confusion via userinfo, javascript:/data:/file: rejection, malformed
input fail-closed, and static-source integration pins both callsites.

Cites 2026-05-21-core-tenets §T-B1.
…NK-2 part 2a)

Three callsites in map-sync.mjs now gate full-URL image sources from
Chronicle against the apiUrl host:

- _mapImageSrc: cross-host full URLs in image_url/image_path/image
  are dropped (warn + try next field). Relative paths unchanged.
- _resolveMediaUrl: when /media/:id returns a full URL, host-validate
  before caching. Mismatch → return '' (treats as unresolvable).
- _buildMapMeta: drop the raw-field fallback that bypassed _mapImageSrc.
  The persisted chronicleMapMeta.image_url now reflects only what the
  validator approved.

Per FM-SECURITY-AUDIT §2 M-2, §4 Chunk 2, §0.5 D1=(c).
Cites 2026-05-21-core-tenets §T-B1.
…HUNK-2 part 2b)

Two callsites in map-viewer.mjs now gate full-URL image sources from
Chronicle against the apiUrl host:

- _mapImageSrc (player-side reader of chronicleMapMeta): cross-host
  full URLs are dropped (warn + try next field). The GM should have
  already dropped these at materialization, but a flag set by an older
  module version or hand-edited could still surface one — fail-closed.
- _prepareToken: token-image overlay surfaces as <img src=...> in the
  Handlebars template. Cross-host full URLs are blanked (no <img> renders
  via `{{#if image}}` guard); relative paths pass through unchanged.

Per FM-SECURITY-AUDIT §2 M-2, §4 Chunk 2, §0.5 D1=(c).
Cites 2026-05-21-core-tenets §T-B1.
@keyxmakerx keyxmakerx merged commit c6ea5ba into main May 23, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant