Skip to content

fix(security): audit follow-ups — response headers, import SVG sanitization, MCP token expiry#115

Merged
DavidBabinec merged 9 commits into
mainfrom
fix/security-hardening-audit
Jun 30, 2026
Merged

fix(security): audit follow-ups — response headers, import SVG sanitization, MCP token expiry#115
DavidBabinec merged 9 commits into
mainfrom
fix/security-hardening-audit

Conversation

@DavidBabinec

Copy link
Copy Markdown
Contributor

What changed

Security response headers

New server/securityHeaders.ts applied centrally in server/index.ts:

  • Global X-Content-Type-Options: nosniff and Referrer-Policy on all responses.
  • Conditional Strict-Transport-Security — emitted only when the public origin is HTTPS (no HSTS over plain HTTP).
  • X-Frame-Options: DENY + CSP frame-ancestors 'none' on /admin/* — clickjacking protection for one-click publish/delete actions.
  • default-src 'none' CSP on /uploads/* — prevents uploaded static files from being weaponised as script execution contexts.

A full admin Content-Security-Policy was intentionally deferred (requires careful scoping for the blob: canvas iframe + plugin bundles) — noted as a follow-up in code.

Bundle-import hardening

importStagedArchiveMediaEntries now re-validates media via magic-byte MIME detection, rejects MIME/extension mismatches, and sanitizes SVG bytes before writing to disk — closing a gap where a data.import holder could smuggle an unsanitized/script-bearing SVG or a MIME-mismatched file past the upload pipeline's protections.

  • Validation runs only on entries actually imported (skipped entries are untouched).
  • Validation helper extracted to server/handlers/cms/importArchiveMediaValidation.ts.

MCP connector token expiry

Additive migration 019_mcp_connector_token_expiry adds a nullable expires_at column to ai_mcp_connectors (both PG + SQLite dialects) and backfills existing rows.

  • New tokens default to a 90-day TTL (overridable via ttlDays, range 1–3650).
  • findConnectorByTokenHash now rejects expired tokens.
  • expiresAt is surfaced in the connector view (the token hash is still never leaked).

Docs

CLAUDE.md corrected: the database is not disposable — live, self-hosted installations exist with real user data. Schema changes ship as additive, non-destructive migrations (next sequential ID in both dialect files); committed migrations must never be rewritten or require a DB drop. The code-level no-backward-compat stance is retained with an explicit DB exception.


Why

Follow-up remediation from a deep security audit of the CMS. These are the three HIGH-priority items from that audit.


Migration / upgrade impact

Migration 019 is additive (ADD COLUMN expires_at + backfill UPDATE) and runs automatically on pull without dropping data.

Important behavioural note: Existing MCP connector tokens are backfilled with a 90-day grace period from when the migration runs — they are not invalidated immediately, but will require rotation within 90 days. Newly created tokens expire 90 days from creation by default.


Verification

bun run build   ✅  (tsc -b + vite build)
bun run lint    ✅
bun test        ✅  (5833 pass, 0 fail)

Out of scope / follow-ups

The remaining MEDIUM audit findings are intentionally deferred:

  • Full admin Content-Security-Policy (needs blob: canvas + plugin-bundle scoping)
  • SSRF guard for outbound plugin/AI fetches
  • Upload and zip size caps
  • Password-change current-password gate

These are tracked as follow-up issues.

DavidBabinec and others added 9 commits June 29, 2026 17:32
…ay TTL)

Adds token expiry to MCP connectors so bearer tokens no longer live forever.
Existing live tokens receive a 90-day grace via a non-destructive backfill
migration; new tokens always carry a computed expires_at from creation time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…disposable-DB guidance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…alidation module

Root causes of the 3 failing tests:

1. `skips unselected media entries` and `full-site round-trip` (import-export-
   roundtrip.test.ts): both fixtures used fake ASCII bytes as PNG content
   (`strToU8('keep')`, `Buffer.from('fake-png-bytes')`). The new
   `validateAndSanitizeMediaBytesForImport` gate introduced on this branch
   calls `detectAcceptedMime()` on every staged entry before writing to disk;
   plain ASCII returns null → ArchiveMediaValidationError → 400.
   Fix: update both fixtures to use real PNG magic bytes (8-byte signature ±
   padding to keep `sizeBytes` consistent with DB records).

2. `Module size budgets`: `importArchive.ts` grew to 771 lines (ceiling 700)
   after the new validation class and function were added inline.
   Fix: extract `ArchiveMediaValidationError` and
   `validateAndSanitizeMediaBytesForImport` into the focused new module
   `server/handlers/cms/importArchiveMediaValidation.ts` (97 lines);
   `importArchive.ts` drops to 688 lines. Add the new helper to the
   cms-handlers-capability-gated ALLOWLIST (it's not a route handler,
   same pattern as mediaUpload.ts / svgSanitize.ts).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Migration 019 now only adds the expires_at column (no backfill UPDATE).
Pre-existing / grandfathered rows have NULL expires_at, which the SQL
predicate `(expires_at is null or expires_at > now)` already accepts as
valid — no extra code needed. rowToRecord passes NULL through as-is
(string | null) instead of coercing it to epoch-0. McpConnectorViewSchema
and McpConnectorRecord updated to string | null. New tests assert that a
connector with NULL expires_at is accepted by both findConnectorByTokenHash
and resolveMcpAuth.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add an "Expires after" Select field (30 days / 90 days / 1 year, default 90)
to the Add MCP Connector dialog. The selected value is converted to an integer
and sent as `ttlDays` in the create request. The connector list now also renders
`expiresAt` — formatted as a local date when set, or "No expiry" when null.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…s:null

Add `null` as an explicit opt-in value for `ttlDays` when creating an MCP
connector, meaning the token never expires. A number still produces a
concrete expiry; omitting the field defaults to 90 days. The `expires_at`
column is already nullable and `findConnectorByTokenHash` already treats
NULL as non-expiring, so this is a pure schema/logic change with no
migration required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a 'No expiry' choice to the TTL selector in the Add Connector
dialog. Selecting it sends `ttlDays: null` to the backend, which the
server now accepts as an explicit opt-in for a non-expiring token.
Default selection remains 90 days.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@DavidBabinec
DavidBabinec marked this pull request as ready for review June 30, 2026 23:24
@DavidBabinec
DavidBabinec merged commit 4d8fbaf into main Jun 30, 2026
6 checks passed
@DavidBabinec
DavidBabinec deleted the fix/security-hardening-audit branch June 30, 2026 23:24
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