Skip to main content

Portal Embed Security — This guide exists to avoid iframe regressions when adding new items to the Portal. ⬆ índice

Portal Embed Security

This guide exists to avoid iframe regressions when adding new items to the Portal.

Problem model

When a Portal item is rendered inside an iframe, all of these layers must allow embedding:

  1. Portal CSP must allow the target origin in frame-src.
  2. The embedded app must allow being framed with Content-Security-Policy: frame-ancestors ... and/or a compatible X-Frame-Options.
  3. The public reverse proxy must not overwrite the embedded app headers with X-Frame-Options: DENY.
  4. Any SSO or redirect hop must also be allowlisted.

If one layer blocks, the browser shows a generic frame error and Portal-side postMessage will fail with origin null.

Golden rules

  • Prefer same-origin embedding when a Portal rewrite already exists.
  • If the app is cross-origin, allow the public origin, not localhost.
  • Treat auth providers and redirect domains as first-class iframe origins.
  • Verify the final public URL with curl -I; do not trust container config alone.
  • Fix the lowest blocking layer first.

Required changes for a new Portal item

1. Register the public endpoint

The remote endpoint must resolve to its real public hostname.

Examples:

REMOTE_ENDPOINT_DOMAIN_<KEY>=<public-hostname>
REMOTE_ENDPOINT_CERT_<KEY>=true

If the endpoint metadata is wrong, Portal will usually build an allowlist with localhost, which is invalid for remote iframe usage.

2. Allow the origin in Portal UI CSP

Portal CSP is built in:

  • platform/portal/ui/src/middleware.ts

Add the new public URL env to the frame source builder if it is a direct iframe target.

Also include any auth/redirect origins through:

  • ZITADEL_ISSUER
  • ZITADEL_ACCOUNT_URL
  • PORTAL_IFRAME_EXTRA_ORIGINS

Important:

  • the effective allowlist is assembled in middleware from env vars such as DOCS_URL, CUSTOMER_SERVICE_URL, ZITADEL_ISSUER, ZITADEL_ACCOUNT_URL, and PORTAL_IFRAME_EXTRA_ORIGINS
  • those envs are injected into the Portal image at build/release time
  • a restart is not enough
  • portal-ui must be rebuilt/released

3. Sync the remote URL into Portal deploy env

Remote Portal deploy env is assembled in:

  • platform/portal/ansible.remote.yml

Add the endpoint lookup and sync the resulting URL into the Portal env if missing.

Typical keys:

- { key: <APP>_URL, value: "{{ ('https://' if (...) else 'http://') ~ (...) }}" }

If the app may redirect to a different public host, include that host in PORTAL_IFRAME_EXTRA_ORIGINS.

4. Allow embedding in Traefik

Public reverse-proxy framing behavior is generated in:

  • deployment/scripts/python/render_traefik_config.py

By default, proxied apps get deny-frame.

If the app must be embeddable inside Portal, it must use portal-embed:

if endpoint["key"] in {"THINGSBOARD", "PORTAINER", "N8N", "EVOLUTION", "INFLUXDB", "DOCS", "CHATWOOT"}:
frame_header_mode = "portal-embed"

If this step is skipped, the public site may still return:

X-Frame-Options: DENY

even when the app container itself is configured correctly.

5. Allow embedding in the app itself

The embedded app must not deny framing.

Typical fixes:

  • Remove or override X-Frame-Options: DENY
  • Set a compatible Content-Security-Policy: frame-ancestors ...
  • If a local nginx sits in front of the app, make sure it does not reintroduce a deny policy

Important:

  • validate the final public URL, not only container-local headers
  • Traefik may intentionally narrow a permissive app header to the Portal-safe public policy
  • for example, an app may emit frame-ancestors * internally while the public host correctly returns frame-ancestors 'self' https://portal...

Examples in this repo:

  • visualization/docusaurus/nginx.conf
  • infrastructure/portainer/nginx/proxy.conf
  • platform/chatwoot/custom_build/patches/iframe_fix.rb

6. Validate dynamic iframe URLs

If Portal loads a URL returned by an API or SSO flow, validate the final origin before rendering the iframe.

Current example:

  • platform/portal/ui/src/components/apps/CustomerServiceClient/CustomerServiceClient.tsx

Pattern:

  • Parse the returned URL
  • Compare url.origin against the allowlist
  • Render only if allowed
  • Otherwise fail with a controlled Portal error

Verification checklist

Portal headers

Check the Portal CSP:

curl -skI https://<portal-host> | grep -i content-security-policy

Confirm frame-src contains:

  • the embedded app public origin
  • auth/redirect origins
  • any extra iframe origins

Embedded app headers

Check the embedded app response:

curl -skI https://<embedded-app-host> | grep -i -E 'x-frame-options|content-security-policy'

Correct result:

  • no X-Frame-Options: DENY
  • frame-ancestors compatible with Portal embedding
  • if the app is intended only for Portal embedding, a narrowed public policy such as frame-ancestors 'self' https://<portal-host> is acceptable

Traefik generated config

Render and inspect:

tmpdir=$(mktemp -d)
python3 deployment/scripts/python/render_traefik_config.py --output-dir "$tmpdir"
rg -n "<app>|portal-embed-frame|deny-frame" "$tmpdir/dynamic/routes.yml"

For embeddable apps, the router must use:

- security-headers-base
- portal-embed-frame

Browser symptoms

If you still see any of these, embedding is still blocked somewhere:

  • Refused to display ... because it set 'X-Frame-Options' to 'deny'
  • Framing ... violates the following Content Security Policy directive: frame-src ...
  • Failed to execute 'postMessage' ... recipient window's origin ('null')

The postMessage error is usually secondary. Fix the frame block first.

Portal runtime/cache symptoms

The Portal can keep iframe instances alive across window moves/reopens.

If headers were fixed after an earlier failed load, the browser may still show the old failure state until the iframe is recreated.

Typical signs:

  • public curl -I checks already look correct
  • Portal still shows chrome-error://chromewebdata/
  • a full Portal reload or a fresh browser session makes the error disappear

Before assuming the server is still broken:

  • reload the full Portal session
  • test in a fresh incognito window
  • verify the iframe target directly outside Portal

Release checklist

For Portal iframe changes:

./infra release <server> portal

For app-side header changes:

./infra release <server> <project>

For Traefik/embed policy changes:

./infra remote deploy <server> core

Use all required steps if the fix spans multiple layers.

Important:

  • image-changing app fixes require release; remote deploy alone does not rebuild the app image
  • after release, verify the exact public behavior you changed instead of assuming the new image is active

Examples:

curl -skI https://docs.<domain>/
curl -sk https://cs.<domain>/enterprise/api/v1/accounts/1/limits

If a route patch was supposed to return 200 and the public host still returns 401 or 404, the running image is not the one you expected.

Current embeddable surfaces

At the time of writing, these require Portal-safe frame handling:

  • THINGSBOARD
  • PORTAINER
  • N8N
  • EVOLUTION
  • INFLUXDB
  • DOCS
  • CHATWOOT / customer service flows

Any new Portal app should be reviewed against this checklist before release.