Skip to content
bflo.sh

ADR-0011: Assets are served by a backend endpoint, as-is, behind a servable media-type allowlist

ADR-0011 — Accepted 2026-07-06. Part of the platform's decision record.

Adapted from the repo's docs/adr/0011-asset-serving-endpoint-and-media-allowlist.md — Part of Internals: the platform's design set, published as content on the platform it describes.

  • Status: Accepted
  • Date: 2026-07-06
  • Deciders: contract-owner (drafts/numbers/ratifies); lead + user (decision — asset origin and additive-minor call locked by the user)
  • Tiers affected: contract, backend, frontend
  • Related: ADR-0005 (filesystem is the content source of truth), ADR-0006 (read-only, unauthenticated; unpublished items 404, never leak existence), ADR-0007 (server-side sanitised HTML; in-body <img> rewriting rides this pipeline), ADR-0008 (publication gating), ADR-0009 (CONTENT_ROOT, no deploy target yet), ADR-0010 (api. host, /v1 base), contract v0.4.0 (getAsset), ../master-blueprint.md §7 open-questions #1 (content sync) and #3 (assets)

Context

Articles reference images two ways: a cover in front-matter / metadata.json, and in-body <img src> in the Markdown. Until now coverImageUrl was hardcoded null and in-body relative image sources pointed nowhere the reader could fetch. Track 4c must give those bytes a public URL. The forces that make this decidable now, not deferrable:

  • The content lives on disk under CONTENT_ROOT, and there is still no deploy target (ADR-0009, ADR-0004). A CDN/object-storage origin would be speculative infrastructure with nothing to point it at, and would need the very cross-environment content-sync mechanism ADR-0009 explicitly deferred. Whatever serves assets must work from the same bind-mounted content root the reindexer already reads.
  • Assets sit next to unpublished source. The content root interleaves servable images with the Markdown/JSON that backs DRAFT/SCHEDULED articles. A naive "serve any file under the root" endpoint would become a hole straight through the ADR-0006/0008 publication gate — anyone could read some-draft/index.md or metadata.json by path.
  • Path traversal. Both cover resolution and in-body rewriting take an author-supplied relative reference; without a guard, ../../.env is reachable.
  • The contract must fix the wire shape (coverImageUrl, the asset URL, the error behaviour) before backend and frontend build against it (contract-first, ADR-0002).

The Phase-2 spine already supplies the traversal primitive: ContentSource::resolveAsset(ContentPath owner, string reference) resolves an in-article relative reference to a canonical content-root-relative string or null when it escapes. This ADR builds on it rather than inventing a second traversal gate.

Decision

We will serve assets from a backend endpointGET /v1/assets/{path} (getAsset, contract v0.4.0) — that streams files from the content root, not from a static or CDN origin. Four sub-decisions:

  1. Backend endpoint over static/CDN origin. The endpoint streams the file identified by a content-root-relative {path} (catch-all, reserved-slash) from the same content root the backend already reads (Storage::disk('content') / config('content.root')). This keeps the system self-contained with no deploy target (ADR-0009's rationale): no second origin, no sync pipeline, no bucket to provision. The cross-environment content sync question stays deferred to ADR-0009's follow-up infra ADR — assets ride whatever mechanism ultimately syncs the content root.

  2. Originals served as-is. The endpoint returns the unmodified bytes on disk with the file's own media type, a strong ETag, a long-lived immutable Cache-Control (public, max-age=…, immutable), and honours If-None-Match304. There is no resizing, transcoding, srcset, or variant generation. Responsiveness/optimisation, if ever wanted, is a later additive decision; nothing here forecloses it.

  3. Servable media-type allowlist is the gating-bypass SECURITY boundary. The endpoint serves a file only if its extension is on a fixed allowlist — png, jpg, jpeg, gif, webp, avif — and refuses everything else (md, json, yaml, yml, and all others). This allowlist is not cosmetic: it is the control that stops /v1/assets from being turned into a reader for raw draft/scheduled .md source or metadata.json, which would bypass the ADR-0006/0008 publication gate. It is enforced at the domain edge by a new value object App\Domain\Content\ValueObject\AssetReference, whose fromString() factory throws InvalidAssetReferenceException on traversal (.., absolute, backslash, escape — same semantics as ContentPath) and on any non-allowlisted extension. SVG is excluded by default: an .svg served inline can carry script (<script>/on*= handlers) and execute in the reader's origin — a stored XSS vector precisely because we serve originals unsanitised (ADR-0007 sanitises HTML bodies, not standalone files). SVG may be added later only if it is served non-inline with Content-Disposition: attachment and X-Content-Type-Options: nosniff; until a use case justifies that handling, it stays off the allowlist.

  4. No per-asset publication gating. Assets are not individually gated to their owning article's publication state. They are non-sensitive by nature (images meant for public reading) and path-obscure (a caller must already know the exact content-relative path). The real boundary is the traversal guard + the allowlist: together they ensure only servable images inside the root are reachable and no source file ever is. Gating each asset on its article's live-at would add per-request index lookups and coupling for no security gain the allowlist doesn't already provide, and would still leak nothing an attacker could enumerate. Missing file, disallowed type, and traversal-escape therefore all return an identical 404 application/problem+json through the existing RFC 9457 renderer — the response never distinguishes exists-but-refused from absent (ADR-0006's no-existence-leak rule, applied to bytes).

Traversal guard reuses resolveAsset / ContentPath semantics. Cover resolution (reindex time: resolveAsset(ownerPath, rawCover) → stored cover_path, null when unresolvable) and in-body <img src> rewriting both go through the same resolveAsset gate; AssetReference applies the identical escape rules plus the allowlist. Every absolute asset URL is {config('content.asset_base_url')}/{content-relative-path}, one config key (ASSET_BASE_URL, default rtrim(APP_URL,'/').'/v1/assets') feeding both the cover URL builder and the in-body rewriter.

Asset requests are exempt from the article rate limit. A single article page fans out to many image requests, so getAsset is registered outside the named api limiter (60/min per IP) — either with no throttle or a dedicated looser limiter (~300/min). Long-lived caching + 304 already shed most repeat load.

Alternatives considered

  1. Static / CDN origin serving the content root directly. Push assets to a bucket or let the web server serve the directory. Rejected: there is no deploy target to host a CDN (ADR-0009/0004), a directory-serving origin has no allowlist, so it would serve raw .md/.json source and blow the publication gate wide open, and it duplicates the content-sync mechanism ADR-0009 deferred. A backend endpoint keeps one origin, one guard, one gate.
  2. On-the-fly resizing / responsive variants. Generate srcset sizes per request or at reindex. Rejected as premature: it needs an image pipeline, a variant cache, and a cache-invalidation story none of the current readers demand; serving originals is correct today and a variant endpoint is a clean additive change later.
  3. Per-asset publication gating (resolve owner article, honour live-at). Rejected: it adds per-request coupling and index lookups for no boundary the allowlist + traversal guard don't already provide, since source files are refused outright and images are public content.
  4. Allow SVG in the default allowlist. Rejected on security grounds: inline SVG is a stored-XSS vector when served as an unsanitised original; the raster allowlist covers every current cover/in-body need. SVG is a later opt-in with attachment disposition + nosniff, not a default.
  5. Denylist source extensions instead of an allowlist. Rejected: a denylist fails open — any new/unforeseen sensitive extension is served until someone remembers to block it. An allowlist fails closed, which is the only acceptable default for a gating boundary.

Consequences

  • Easier / now true: covers and in-body images have stable absolute URLs; the system stays self-contained (no CDN/bucket, no new sync path); one traversal primitive (resolveAsset/ContentPath) and one allowlist (AssetReference) guard every asset path; responses are cache-friendly (immutable + ETag/304).
  • Harder / now forbidden:
    • No file outside the png/jpg/jpeg/gif/webp/avif allowlist is ever served — adding a type (notably SVG) requires editing AssetReference's allowlist and this ADR's security reasoning (for SVG, plus non-inline disposition + nosniff).
    • No variant/resize behaviour may be added silently — it is a new contract decision.
    • The getAsset 404 must stay indistinguishable across missing/disallowed/traversal — any richer error (e.g. 403 for a refused type) would leak existence and violate ADR-0006.
    • The in-body rewrite must run outside the hash-keyed MarkdownRenderer (which is a pure content-hash → html function and must stay owner-independent); rewriting is an owner-aware pass at read/serialization time keyed by the owner path.
  • Follow-up owners:
    • domain-engineerAssetReference (traversal + allowlist, fromString throwing InvalidAssetReferenceException), AssetContent (stream/bytes + mimeType + etag
      • sizeBytes), the ContentSource::readAsset(AssetReference): ?AssetContent port addition, and confirming TrustedHtmlSanitizer retains <img src> after rewrite.
    • data-engineerreadAsset on the filesystem ContentSource; resolve + store cover_path at reindex time; add readAsset to every persistence-tier fake.
    • backend-api — the GET /v1/assets/{path} route (->where('path', '.*')) outside the 60/min limiter; stream 200 with Content-Type/Cache-Control/ETag/ X-Content-Type-Options: nosniff; If-None-Match → 304; RFC 9457 404 for missing/disallowed/traversal; build coverImageUrl and the in-body <img> rewrite from config('content.asset_base_url').
    • frontend-engineer — regenerate the v0.4.0 client; render the now-populated coverImageUrl and in-body images.
    • platform-engineer — wire ASSET_BASE_URL (default rtrim(APP_URL,'/').'/v1/assets') and confirm the assets route is CORS-correct like the rest of /v1/*.
  • Blueprint open questions: resolves §7 #3 (assets) — backend endpoint, originals as-is, allowlist boundary. Resolves the asset facet of #1 (content sync) — assets are served from the same content root as everything else; the broader cross-environment sync mechanism stays deferred per ADR-0009.

Compliance

  • Contract: getAsset (GET /assets/{path}) is published in contract/openapi.yaml at v0.4.0 with the image media types, the 304, and the shared Problem 404; ddev contract-lint passes and oasdiff breaking --flatten-allof reports the 0.3.1 → 0.4.0 diff as non-breaking. The public-api-is-read-only Spectral rule still passes — getAsset is GET-only.
  • Allowlist test (security boundary): a backend test requests a .md, .json, .yaml, and .svg path under the content root and asserts each returns the same 404 problem+json as an absent path — source files and SVG are never served, and the response does not disclose which case occurred.
  • Traversal test: a request whose resolved {path} escapes the content root (e.g. ../../.env) returns the identical 404; AssetReference::fromString / resolveAsset reject it before any filesystem read.
  • As-is / caching test: a served image's bytes are byte-identical to the file on disk (no transform), the response carries ETag + immutable Cache-Control + X-Content-Type-Options: nosniff, and a matching If-None-Match yields 304.
  • Rate-limit check: many rapid getAsset requests from one IP are not rejected by the 60/min api limiter (the endpoint is registered outside it / under its own looser limiter).
  • Rewrite-layering scan: the in-body <img src> rewrite is not performed inside MarkdownRenderer (which stays a pure hash-keyed string → html function); it is an owner-keyed pass at read/serialization time.