feat(security): allow specific origins to frame the UI - #209
Open
Ian-Costa18 wants to merge 1 commit into
Open
Conversation
X-Frame-Options was hardcoded to DENY in next.config.ts, so the UI could not be embedded by a self-hosted dashboard (Organizr, Homarr, Heimdall) and there was no setting that changed it. X-Frame-Options cannot express an allow-list — ALLOW-FROM is unsupported in every current browser — so the fix is CSP frame-ancestors, which can. Modelled on Appsmith's APPSMITH_ALLOWED_FRAME_ANCESTORS (deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs). - ALLOWED_FRAME_ANCESTORS takes a space- or comma-separated origin list and emits Content-Security-Policy: frame-ancestors 'self' <origins> - unset is unchanged from today: X-Frame-Options: DENY, now paired with frame-ancestors 'none' so the CSP says the same thing to current browsers - there is deliberately no "disable protection" switch. Naming an origin narrows the exception; it never opens framing to all - only well-formed origins are accepted. A bare *, a data:/blob:/javascript: URI, a CSP keyword, a path, or a second directive smuggled in after a ; are dropped, and a value with no valid origin left falls back to deny rather than to open - X-Frame-Options is omitted once an allow-list exists rather than downgraded to SAMEORIGIN: it cannot express the list, and a surviving DENY would be honoured by any client that ignores CSP, contradicting the policy - applied in src/proxy.ts, not next.config.ts: headers() is evaluated at build time and frozen into .next/routes-manifest.json, so an env var read there would be baked into the image and unreadable at runtime - security-audit.ts now asserts the default stays closed, with comments stripped and imports excluded so prose mentioning a header cannot satisfy the check Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ian-Costa18
force-pushed
the
feat/configurable-clickjacking-protection
branch
from
August 24, 2026 20:39
9e01d81 to
4c5d74d
Compare
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.
Summary
X-Frame-Optionsis hardcoded toDENYinnext.config.ts, so the UI cannot be embeddedby a self-hosted dashboard — Organizr,
Homarr, Heimdall —
and no setting changes it.
The header itself is the reason this needs a CSP rather than a looser
X-Frame-Options:its allow-list form,
ALLOW-FROM, isunsupported in every current browser,
leaving only
DENYandSAMEORIGIN. Neither can express "this one dashboard may frame me".CSP
frame-ancestorscan, and supersedes
X-Frame-Optionswherever both are understood.
So this adds
ALLOWED_FRAME_ANCESTORS, which narrows the existing deny to named origins.It does not add a way to turn the protection off.
Modelled on Appsmith
Rather than invent a shape, this follows Appsmith's
APPSMITH_ALLOWED_FRAME_ANCESTORS,implemented in
deploy/docker/fs/opt/appsmith/caddy-reconfigure.mjs:Same variable shape and same space-separated value, so a value written for Appsmith —
including a leading
'self'— works here verbatim.One deliberate difference: Appsmith guards CSP injection by truncating at the first
;.That stops a smuggled second directive but still lets arbitrary text reach the header, so
this validates each token against an origin pattern instead and drops anything else.
Default is unchanged
ALLOWED_FRAME_ANCESTORSX-Frame-OptionsContent-Security-PolicyDENYframe-ancestors 'none'https://organizr.example.comframe-ancestors 'self' https://organizr.example.com*,javascript:…)DENYframe-ancestors 'none'Unset behaves exactly as the hardcoded header did. The added
frame-ancestors 'none'closes a real gap: the app previously sent only the deprecated header and no CSP at all.
Invalid input fails closed. A typo'd origin does not silently open framing — it is
dropped, and if nothing valid remains the policy falls back to deny.
X-Frame-Optionsis omitted, not downgraded, once an allow-list exists. Downgrading itto
SAMEORIGINwould not help — a dashboard on another subdomain is a different origin —and a surviving
DENYwould be honoured by any client that ignores CSP, contradicting thepolicy the CSP states.
What is rejected
The variable is interpolated into a response header, so anything that is not an origin is
dropped rather than sanitized into something plausible: a bare
*,data:,blob:,javascript:URIs, CSP keywords like'unsafe-inline', a scheme-relative//host, a barehostname, a path, an out-of-range port, and a second directive after a
;. Ports and*.subdomain wildcards are accepted.
A CRLF payload cannot inject a header — the token split consumes the newline, the origin is
validated alone, and only validated tokens are re-joined. There is a test asserting that
output property rather than assuming it.
Why
src/proxy.tsand notnext.config.tsThe obvious implementation — read the env var inside
headers()— does not work for anyonerunning the published image, which is worth recording because it is invisible from the
source.
headers()is evaluated at build time and frozen into
.next/routes-manifest.json. From a runningcontainer before this change:
{ "key": "X-Frame-Options", "value": "DENY" }An env var read there is baked in at
docker buildand ignored at runtime. So the framingheaders move to
src/proxy.ts, which already reads env per-request forshouldSecureCookies(). The remaining static headers stay innext.config.tsuntouched.Every branch of
proxy()applies them, including the unauthenticated 401 and the loginredirect — a login page is exactly what clickjacking targets. Each branch is asserted
separately, so a future return path added without them fails the suite.
One consequence:
proxy.ts's matcher excludes_next/static,_next/imageand the logoassets, so those no longer carry
X-Frame-Options. They are static images with no UI tohijack.
The security audit needed a real check
scripts/security-audit.tsasserted the headers by substring-matchingnext.config.ts.After the header moved, a comment mentioning it still satisfied that match and the audit
reported 38/38 — it was matching prose.
The check is rewritten rather than worked around: a new
checkFrameSecurity()asserts themodule still emits
DENYandframe-ancestors 'none'by default and thatproxy.tsactually calls the helper, with comments stripped and the import line excluded so neither a
comment nor a bare
importcan satisfy it.Confirmed by tampering: opening the default policy, or deleting the call site, each turn the
check red, and it returns green on restore. Total goes 38 → 39.
Verified at runtime
Against a running dev server, not only unit tests. All three rows of the table above were
checked by starting the app with that configuration and reading the response headers; the
X-Content-Type-Options: nosniffheader stayed present throughout, confirming the changetouches only the framing pair.
The emitted CSP contains
frame-ancestorsand nothing else.Directives absent from a policy are unrestricted,
so this constrains framing only and does not introduce a
default-src/script-srcpolicy that would need maintaining alongside the UI.Changes
src/lib/frame-security.tssrc/lib/frame-security.test.tssrc/proxy.tssrc/proxy.test.tsnext.config.tsX-Frame-Options)scripts/security-audit.tsREADME.md,.env.example,docker-compose.yml2 new files, 7 edited
Tests: 4278 passing (39 new) | TypeScript: clean | Biome: clean | Security audit: 39/39
Disclosure
Written with AI assistance — the commit carries a
Co-Authored-Bytrailer to that effect.What that did and didn't mean here: the shape came from reading Appsmith's implementation,
and the two findings worth having — that
next.config.tsbakes headers at build time, andthat the security audit was passing on a comment — both came from inspecting the running
container and from deliberately tampering with the audit to see whether it noticed. Neither
would have surfaced from reasoning about the source.
An earlier draft of this change was a boolean that disabled the protection outright. It was
rejected in review here as weakening the default, which is right, and the allow-list is what
replaced it.
I've read the diff and can walk through any part of it.