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,/v1base), 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/SCHEDULEDarticles. A naive "serve any file under the root" endpoint would become a hole straight through the ADR-0006/0008 publication gate — anyone could readsome-draft/index.mdormetadata.jsonby path. - Path traversal. Both cover resolution and in-body rewriting take an author-supplied
relative reference; without a guard,
../../.envis 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 endpoint — GET /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:
-
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. -
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 immutableCache-Control(public, max-age=…, immutable), and honoursIf-None-Match→304. There is no resizing, transcoding,srcset, or variant generation. Responsiveness/optimisation, if ever wanted, is a later additive decision; nothing here forecloses it. -
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/assetsfrom being turned into a reader for raw draft/scheduled.mdsource ormetadata.json, which would bypass the ADR-0006/0008 publication gate. It is enforced at the domain edge by a new value objectApp\Domain\Content\ValueObject\AssetReference, whosefromString()factory throwsInvalidAssetReferenceExceptionon traversal (.., absolute, backslash, escape — same semantics asContentPath) and on any non-allowlisted extension. SVG is excluded by default: an.svgserved 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 withContent-Disposition: attachmentandX-Content-Type-Options: nosniff; until a use case justifies that handling, it stays off the allowlist. -
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+jsonthrough 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
- 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/.jsonsource 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. - On-the-fly resizing / responsive variants. Generate
srcsetsizes 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. - 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.
- 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. - 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/avifallowlist is ever served — adding a type (notably SVG) requires editingAssetReference'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
getAsset404 must stay indistinguishable across missing/disallowed/traversal — any richer error (e.g.403for a refused type) would leak existence and violate ADR-0006. - The in-body rewrite must run outside the hash-keyed
MarkdownRenderer(which is a purecontent-hash → htmlfunction and must stay owner-independent); rewriting is an owner-aware pass at read/serialization time keyed by the owner path.
- No file outside the
- Follow-up owners:
domain-engineer—AssetReference(traversal + allowlist,fromStringthrowingInvalidAssetReferenceException),AssetContent(stream/bytes +mimeType+etagsizeBytes), theContentSource::readAsset(AssetReference): ?AssetContentport addition, and confirmingTrustedHtmlSanitizerretains<img src>after rewrite.
data-engineer—readAsseton the filesystemContentSource; resolve + storecover_pathat reindex time; addreadAssetto every persistence-tier fake.backend-api— theGET /v1/assets/{path}route (->where('path', '.*')) outside the 60/min limiter; stream 200 withContent-Type/Cache-Control/ETag/X-Content-Type-Options: nosniff;If-None-Match→ 304; RFC 9457 404 for missing/disallowed/traversal; buildcoverImageUrland the in-body<img>rewrite fromconfig('content.asset_base_url').frontend-engineer— regenerate the v0.4.0 client; render the now-populatedcoverImageUrland in-body images.platform-engineer— wireASSET_BASE_URL(defaultrtrim(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 incontract/openapi.yamlat v0.4.0 with the image media types, the304, and the sharedProblem404;ddev contract-lintpasses andoasdiff breaking --flatten-allofreports the 0.3.1 → 0.4.0 diff as non-breaking. Thepublic-api-is-read-onlySpectral rule still passes —getAssetis GET-only. - Allowlist test (security boundary): a backend test requests a
.md,.json,.yaml, and.svgpath 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/resolveAssetreject 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+ immutableCache-Control+X-Content-Type-Options: nosniff, and a matchingIf-None-Matchyields304. - Rate-limit check: many rapid
getAssetrequests from one IP are not rejected by the 60/minapilimiter (the endpoint is registered outside it / under its own looser limiter). - Rewrite-layering scan: the in-body
<img src>rewrite is not performed insideMarkdownRenderer(which stays a pure hash-keyedstring → htmlfunction); it is an owner-keyed pass at read/serialization time.