Skip to content

feat(security): allow specific origins to frame the UI - #209

Open
Ian-Costa18 wants to merge 1 commit into
jordanlambrecht:developmentfrom
Ian-Costa18:feat/configurable-clickjacking-protection
Open

feat(security): allow specific origins to frame the UI#209
Ian-Costa18 wants to merge 1 commit into
jordanlambrecht:developmentfrom
Ian-Costa18:feat/configurable-clickjacking-protection

Conversation

@Ian-Costa18

Copy link
Copy Markdown

Summary

X-Frame-Options is hardcoded to DENY in next.config.ts, so the UI cannot be embedded
by 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, is
unsupported in every current browser,
leaving only DENY and SAMEORIGIN. Neither can express "this one dashboard may frame me".
CSP frame-ancestors
can, and supersedes X-Frame-Options
wherever 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:

const frameAncestorsPolicy = (process.env.APPSMITH_ALLOWED_FRAME_ANCESTORS || "'self'")
  .replace(/;.*$/, "")
...
Content-Security-Policy "frame-ancestors ${frameAncestorsPolicy}"

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_ANCESTORS X-Frame-Options Content-Security-Policy
unset DENY frame-ancestors 'none'
https://organizr.example.com omitted frame-ancestors 'self' https://organizr.example.com
junk (*, javascript:…) DENY frame-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-Options is omitted, not downgraded, once an allow-list exists. Downgrading it
to SAMEORIGIN would not help — a dashboard on another subdomain is a different origin —
and a surviving DENY would be honoured by any client that ignores CSP, contradicting the
policy 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 bare
hostname, 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.ts and not next.config.ts

The obvious implementation — read the env var inside headers() — does not work for anyone
running 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 running
container before this change:

{ "key": "X-Frame-Options", "value": "DENY" }

An env var read there is baked in at docker build and ignored at runtime. So the framing
headers move to src/proxy.ts, which already reads env per-request for
shouldSecureCookies(). The remaining static headers stay in next.config.ts untouched.

Every branch of proxy() applies them, including the unauthenticated 401 and the login
redirect — 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/image and the logo
assets, so those no longer carry X-Frame-Options. They are static images with no UI to
hijack.

The security audit needed a real check

scripts/security-audit.ts asserted the headers by substring-matching next.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 the
module still emits DENY and frame-ancestors 'none' by default and that proxy.ts
actually calls the helper, with comments stripped and the import line excluded so neither a
comment nor a bare import can 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: nosniff header stayed present throughout, confirming the change
touches only the framing pair.

The emitted CSP contains frame-ancestors and nothing else.
Directives absent from a policy are unrestricted,
so this constrains framing only and does not introduce a
default-src/script-src policy that would need maintaining alongside the UI.

Changes

File Action
src/lib/frame-security.ts Create (parsing, validation, headers)
src/lib/frame-security.test.ts Create (29 tests)
src/proxy.ts Edit (apply on every branch)
src/proxy.test.ts Edit (10 tests, per return path)
next.config.ts Edit (remove the baked X-Frame-Options)
scripts/security-audit.ts Edit (real check, +1 total)
README.md, .env.example, docker-compose.yml Edit (document the variable)

2 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-By trailer 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.ts bakes headers at build time, and
that 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.

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
Ian-Costa18 force-pushed the feat/configurable-clickjacking-protection branch from 9e01d81 to 4c5d74d Compare August 24, 2026 20:39
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