Skip to content

fix(import): validate JSON-import media bytes + destination like the archive path#154

Draft
DavidBabinec wants to merge 1 commit into
mainfrom
fix/import-media-content-validation
Draft

fix(import): validate JSON-import media bytes + destination like the archive path#154
DavidBabinec wants to merge 1 commit into
mainfrom
fix/import-media-content-validation

Conversation

@DavidBabinec

Copy link
Copy Markdown
Contributor

What & why

The JSON SiteBundle import endpoint (POST /admin/api/cms/import) wrote each media[].bytesBase64 to join(uploadsDir, storagePath) guarded only by a traversal check — no magic-byte MIME detection, no SVG sanitisation, no extension check (the archive import path already had all three).

Because published/ lives under uploadsDir and the public router serves it verbatim as text/html with no CSP, on the same origin as /admin, a caller holding only data.import (merge-add needs no step-up and no content.manage) could POST storagePath: "published/current/index.html" with <script> bytes and land arbitrary same-origin HTML/JS → stored XSS → admin account takeover (mint an owner role / reset the owner password / exfiltrate).

Fix (at the source, shared)

Made the existing gate shared and applied it to both import paths:

  • Renamed importArchiveMediaValidation.tsimportMediaValidation.ts (now used by both routes), split into two focused gates:
    • validateAndSanitizeMediaBytes(bytes, {storagePath, mimeType}) — content: detected MIME must match the declared type and the storagePath extension; SVG is sanitised. No accepted media MIME maps to .html/.js, so script writes are rejected outright.
    • resolveMediaWriteTarget(uploadsDir, storagePath) — destination: no traversal (via assertPathWithin), and never a reserved served subtree (published/, plugins/, fonts/).
  • The JSON import media write now runs through both gates (was unguarded).
  • The archive import now calls the same two gates — behaviour-preserving (it already buffered bytes for MIME detection and still moves non-SVG files zero-copy).

Impact

  • Developer: one shared media-import gate instead of a duplicated/absent one.
  • User: closes a data.import → same-origin account-takeover primitive. No API/behaviour change for legitimate imports.

Verification

  • bun test server/handlers/cms/__tests__/importMediaValidation.test.ts server/handlers/cms/__tests__/importArchiveMedia.test.ts — 14 pass (HTML/JS, MIME-mismatch, extension-laundering, reserved-subtree writes all rejected; SVG scripts stripped).
  • bun test src/__tests__/architecture/cms-handlers-capability-gated.test.ts — pass (allowlist updated for the rename).
  • bunx tsc -b — clean. eslint on touched files — clean.
  • Pre-existing unrelated failures (icon-catalog-integrity, bundle-size-budgets) confirmed failing on the untouched base; not part of this change.

🤖 Generated with Claude Code

…archive path

The JSON SiteBundle import (`POST /admin/api/cms/import`) wrote each
`media[].bytesBase64` to `join(uploadsDir, storagePath)` with only a
traversal check — no magic-byte MIME detection, no SVG sanitisation, no
extension check. Because `published/` lives under `uploadsDir` and is served
verbatim as `text/html` with no CSP by the public router (same origin as
`/admin`), a caller holding only `data.import` (no step-up, no
`content.manage`) could write `storagePath: "published/current/index.html"`
with `<script>` bytes and achieve stored XSS → same-origin admin account
takeover. The archive import path already gated its writes; the JSON path did
not.

Fix at the source by making that gate shared and applying it to both paths:

- Rename `importArchiveMediaValidation.ts` → `importMediaValidation.ts`
  (now used by both import routes) and split it into two focused gates:
  - `validateAndSanitizeMediaBytes(bytes, {storagePath, mimeType})` — content:
    real MIME must match the declared type + extension; SVG is sanitised.
    No accepted media MIME maps to `.html`/`.js`, so script writes are rejected.
  - `resolveMediaWriteTarget(uploadsDir, storagePath)` — destination: no
    traversal, and never a reserved served subtree (`published/`, `plugins/`,
    `fonts/`).
- Route the JSON import media write through both gates (previously unguarded).
- Route the archive path through the same two gates (behaviour-preserving:
  it already buffered bytes for MIME detection and still moves non-SVG files).

Tests: new `importMediaValidation.test.ts` proves HTML/JS, MIME-mismatch,
extension-laundering, and reserved-subtree writes are rejected and SVG scripts
stripped; existing archive media test still passes through the shared gate;
architecture gate allowlist updated for the rename.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant