fix(security): close M-2 — image-URL host allowlist for Chronicle media#56
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdSecurity 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 configuredapiUrl'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 underapiUrl; the inventory in §1.4 of the audit confirmed every observedimage_urlshape resolves to the same host asapiUrl. 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 existinggame.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
scripts/_url-validation.mjs(NEW, 63 lines)_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_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_mapImageSrc(player-side reader of thechronicleMapMeta.image_urlflag) and_prepareToken(token-image overlay rendered as<img src=...>).tools/test-url-validation.mjs(NEW, 23 tests)_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/_prepareTokencontain 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-URLimage_urlvalues 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-*.mjspasses locally (279 baseline + 23 new = 302/302)_isAllowedImageHost)javascript:/data:/file:(✗), trailing-slash on apiUrl tolerated (✓), empty / null / malformed apiUrl fail-closed (✗)chronicleMapMeta.image_urltohttps://attacker.com/x.png; reload; verify the image goes blank, the page still renders, and the browser console showsChronicle [map_image]: rejected cross-host image URL ....Tenet self-check
chronicle-foundry-moduleStop-and-flag (per dispatch)
Neither stop-and-flag condition triggered:
scripts/settings.mjs—grep -i 'cdn\|mediaHost\|media_host\|imageHost'returns only comment references inupdate-info.mjs. D1.1 sub-question default applies (strict hostname equality).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/:idendpoint 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 persistedchronicleMapMeta.image_urlfield 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 toimageSrc || ''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 onchronicle-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:bc8c13e— Add_url-validation.mjs+ tests (CI green standalone since callsites would still match the static-source pin once they land).e439ac5— Wire intomap-sync.mjs.5ebee7d— Wire intomap-viewer.mjs.🤖 Cites
dispatches/foundry/FM-SEC-CHUNK-2.md. Generated by Claude Code.Generated by Claude Code