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.