Skip to content

Resolve ALLOWED_FRAME_URLS from the runtime environment in Docker#4356

Open
Niki2k1 wants to merge 1 commit into
umami-software:devfrom
Niki2k1:runtime-allowed-frame-urls
Open

Resolve ALLOWED_FRAME_URLS from the runtime environment in Docker#4356
Niki2k1 wants to merge 1 commit into
umami-software:devfrom
Niki2k1:runtime-allowed-frame-urls

Conversation

@Niki2k1

@Niki2k1 Niki2k1 commented Jun 26, 2026

Copy link
Copy Markdown

Problem

ALLOWED_FRAME_URLS is documented as the way to allow iframe embedding, but it is effectively a build-time variable: the CSP is built in next.config.ts headers(), which Next serializes into routes-manifest.json during next build. The published Docker images are built without it, so frame-ancestors is fixed at 'self' and setting the env on the running container has no effect — embedding currently requires building a custom image.

Reported in #3793 and #2407.

Change

  • Extract the CSP into a shared getContentSecurityPolicy() (src/lib/csp.ts) that reads the environment when called.
  • Set the header from the Docker proxy (docker/proxy.ts) on every response, alongside the other runtime env it already handles (CORS_MAX_AGE, DISABLE_LOGIN, …). This makes ALLOWED_FRAME_URLS (and API_URL in connect-src) resolve at runtime.
  • next.config.ts skips the build-time CSP when the proxy is present (src/proxy.ts, which the Dockerfile copies in), so browsers receive exactly one CSP header — two would be intersected, dropping the runtime frame-ancestors.

Non-Docker builds are unchanged: next.config.ts still emits the build-time CSP, and ALLOWED_FRAME_URLS keeps working as a build-time variable there.

Verification

Built the image once, then started the same image with different runtime values:

ALLOWED_FRAME_URLS (runtime env) frame-ancestors in response
https://example.com 'self' https://example.com
https://example.com https://foo.example 'self' https://example.com https://foo.example
(unset) 'self'

Exactly one Content-Security-Policy header is returned in each case.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

@vercel

vercel Bot commented Jun 26, 2026

Copy link
Copy Markdown

@Niki2k1 is attempting to deploy a commit to the Umami Software Team on Vercel.

A member of the Team first needs to authorize it.

@Niki2k1 Niki2k1 force-pushed the runtime-allowed-frame-urls branch from af6a268 to 2d6ad04 Compare June 26, 2026 10:46
@greptile-apps

greptile-apps Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a longstanding issue where ALLOWED_FRAME_URLS was baked into the routes-manifest.json at next build time and therefore had no effect on the published Docker image at runtime. It extracts the CSP construction into a shared src/lib/csp.ts helper and moves CSP header injection into the Next.js 16 proxy middleware (docker/proxy.ts) for Docker deployments, while leaving the existing build-time path intact for non-Docker users.

  • src/lib/csp.ts: New shared helper that reads ALLOWED_FRAME_URLS and API_URL from process.env at call time, making the CSP dynamically composable in both build-time and per-request contexts.
  • docker/proxy.ts: Refactors the middleware loop to retain the matched response, then unconditionally stamps the runtime-generated CSP onto every response before returning.
  • next.config.ts: Uses existsSync('./src/proxy.ts') at build time to detect the Docker context and suppress the static CSP header, preventing browsers from receiving two Content-Security-Policy headers whose intersection would silently drop frame-ancestors.

Confidence Score: 4/5

Safe to merge; the runtime CSP path works correctly in Docker and the non-Docker build path is unchanged.

The core logic is sound: CSP is correctly constructed from runtime env vars, the middleware stamps it on every response, and the dual-header problem is properly avoided by suppressing the build-time header. The two findings are minor — a per-request recomputation that could be lifted to module scope, and a filesystem sentinel that could break silently if the Dockerfile is restructured.

The existsSync('./src/proxy.ts') check in next.config.ts warrants a second look: it is the load-bearing mechanism that prevents two CSP headers from being emitted, but it relies on an implicit Dockerfile convention rather than an explicit signal.

Important Files Changed

Filename Overview
src/lib/csp.ts New shared helper that reads ALLOWED_FRAME_URLS and API_URL from process.env at call time; logic faithfully mirrors the old inline CSP construction in next.config.ts and handles empty/missing env vars correctly via filter(Boolean).
docker/proxy.ts Refactored middleware loop to accumulate the matched response, then stamps the runtime CSP onto every response (including rewrites and 403s). The CSP is recomputed on every request even though env vars are immutable after process start.
next.config.ts Delegates CSP construction to the shared helper and gates the build-time header behind existsSync('./src/proxy.ts'), which is a pragmatic but implicit coupling to the Dockerfile's COPY step.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    subgraph Docker Build
        A[next build runs] --> B{existsSync src/proxy.ts?}
        B -- YES Docker context --> C[Skip CSP in routes-manifest.json]
        B -- NO Local dev context --> D[Bake CSP into routes-manifest.json]
    end
    subgraph Docker Runtime - Per Request
        E[Incoming request] --> F[proxy.ts middleware matcher /:path*]
        F --> G{Matches custom collect/script/login?}
        G -- YES --> H[Return rewrite/redirect]
        G -- NO --> I[NextResponse.next]
        H --> J[res.headers.set Content-Security-Policy reads ALLOWED_FRAME_URLS at runtime]
        I --> J
        J --> K[Response to browser Single CSP header]
    end
    subgraph Non-Docker Runtime
        L[Incoming request] --> M[Next.js serves baked CSP header from routes-manifest.json ALLOWED_FRAME_URLS frozen at build time]
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    subgraph Docker Build
        A[next build runs] --> B{existsSync src/proxy.ts?}
        B -- YES Docker context --> C[Skip CSP in routes-manifest.json]
        B -- NO Local dev context --> D[Bake CSP into routes-manifest.json]
    end
    subgraph Docker Runtime - Per Request
        E[Incoming request] --> F[proxy.ts middleware matcher /:path*]
        F --> G{Matches custom collect/script/login?}
        G -- YES --> H[Return rewrite/redirect]
        G -- NO --> I[NextResponse.next]
        H --> J[res.headers.set Content-Security-Policy reads ALLOWED_FRAME_URLS at runtime]
        I --> J
        J --> K[Response to browser Single CSP header]
    end
    subgraph Non-Docker Runtime
        L[Incoming request] --> M[Next.js serves baked CSP header from routes-manifest.json ALLOWED_FRAME_URLS frozen at build time]
    end
Loading

Reviews (1): Last reviewed commit: "Resolve ALLOWED_FRAME_URLS from the runt..." | Re-trigger Greptile

Comment thread docker/proxy.ts Outdated
Comment on lines +84 to +86
// Set the CSP here, not only at build time in next.config.ts, so
// ALLOWED_FRAME_URLS is resolved from the runtime environment.
res.headers.set('Content-Security-Policy', getContentSecurityPolicy());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 getContentSecurityPolicy() reads process.env.ALLOWED_FRAME_URLS and process.env.API_URL and rebuilds the CSP string on every single request. Because environment variables are immutable once the Node process has started, the return value never changes between calls. Computing it once at module initialization (or lazily on the first call) avoids redundant string allocations on every request through this middleware.

Suggested change
// Set the CSP here, not only at build time in next.config.ts, so
// ALLOWED_FRAME_URLS is resolved from the runtime environment.
res.headers.set('Content-Security-Policy', getContentSecurityPolicy());
// Set the CSP here, not only at build time in next.config.ts, so
// ALLOWED_FRAME_URLS is resolved from the runtime environment.
// Computed once at module load — env vars don't change after process start.
res.headers.set('Content-Security-Policy', CSP);

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread next.config.ts Outdated
// The Docker proxy (src/proxy.ts) sets the CSP per request so ALLOWED_FRAME_URLS
// works at runtime; skip the build-time header there to avoid sending two CSPs
// (browsers intersect duplicates, which would drop the runtime frame-ancestors).
const runtimeCsp = existsSync('./src/proxy.ts');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Implicit Docker detection via filesystem sentinel

existsSync('./src/proxy.ts') works because the Dockerfile copies docker/proxy.ts into src/ before running next build. However, this implicitly couples next.config.ts to the Dockerfile's COPY step — if the Dockerfile is restructured, the copy step is reordered, or next build is run from a non-root cwd, the check silently returns false and the build-time CSP header is re-emitted alongside the runtime one (causing browsers to intersect both and silently drop frame-ancestors). An explicit build-arg-turned-env-var (e.g. ARG RUNTIME_CSPENV RUNTIME_CSP=1 in the Dockerfile, checked here as process.env.RUNTIME_CSP) would make the intent explicit and survive Dockerfile refactors.

@Niki2k1 Niki2k1 force-pushed the runtime-allowed-frame-urls branch from 2d6ad04 to 29c5427 Compare June 26, 2026 11:08
The Content-Security-Policy is defined in next.config.ts headers(), which Next
serializes at build time, so ALLOWED_FRAME_URLS only takes effect when it is set
before `next build`. The published Docker images are built without it, so
frame-ancestors cannot be configured at runtime and iframe embedding requires a
custom image (umami-software#3793, umami-software#2407).

Move the policy into a shared getContentSecurityPolicy() and set the header from
the Docker proxy (docker/proxy.ts) on every response, where runtime env such as
CORS_MAX_AGE and DISABLE_LOGIN is already handled. next.config.ts skips the
build-time CSP when the proxy is present, so exactly one header is sent.

Non-Docker builds are unchanged: the build-time CSP still applies and
ALLOWED_FRAME_URLS keeps working as a build-time variable there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Niki2k1 Niki2k1 force-pushed the runtime-allowed-frame-urls branch from 29c5427 to ecf9b3b Compare June 26, 2026 11:31
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