Problem
The session replay player renders a recording into an about:blank iframe. Local-scheme frames inherit the embedder's Content Security Policy, so the replayer runs under the PostHog app's policy and its asset loads are judged against it. Replayed images, fonts, and stylesheets come from whatever origins the recorded site used, so they violate that policy on essentially every replay.
The app currently sends Content-Security-Policy-Report-Only, so nothing is blocked and the player works. These violations are the advance warning of what breaks the day the policy is enforced: images, most fonts, and some stylesheets stop loading in every replay.
Two defects sit behind it.
1. The replay asset proxy is not in the policy.
CorsPlugin rewrites replayed font and script URLs through https://replay.ph-proxy.com (common/replay-shared/src/rrweb-plugins/index.ts), so those loads come from one origin we control. That origin appears nowhere in CSPMiddleware (posthog/middleware.py). Every proxied font load therefore violates font-src. The proxy exists to make this tractable and the policy does not know about it.
2. CorsPlugin does not rewrite images.
It handles STYLE, LINK, and SCRIPT nodes. Image elements are left alone, so replayed images load straight from the recorded site's origin and violate img-src. Images are the largest share of the violations by some way.
Why this needs fixing rather than filtering
The policy has already started absorbing recorded-site origins one at a time. 2a54418efe ("chore: add more csp sources, from testing", #39824) added https://assets.faircado.com and https://use.typekit.net to font-src. Neither string appears anywhere else in the codebase — no frontend code, no config, no docs — so they are not app dependencies.
That PR's review raised both halves of this and neither was answered. An automated reviewer asked whether those two origins were "legitimate requirements and not temporary testing artifacts". A human reviewer asked how the change affected replay, and the answer was a hope that frame-src https: would cover it. It does not: frame-src governs which frames a document may embed, and the replayer's frame is about:blank, which inherits the policy instead. So the question was the right one and the mechanism just isn't the one anybody expected.
Left alone, the policy accretes one recorded-site origin per investigation and still never covers the next recording.
Proposed fix
- Add
https://replay.ph-proxy.com to img-src, font-src, style-src-elem, and media-src in CSPMiddleware.
- Extend
CorsPlugin to rewrite image URLs through the proxy, including srcset and CSS url() references in stylesheets.
- Remove
https://assets.faircado.com and https://use.typekit.net from font-src once proxied fonts are covered.
The tradeoff is proxy bandwidth: every replayed image would go through replay.ph-proxy.com rather than loading direct. Worth measuring before committing to it, and worth deciding what a proxy failure should degrade to.
Alternatives considered
Give the replayer iframe a real document. Only about:blank, srcdoc, blob: and data: inherit the embedder's policy. An iframe pointed at a real same-origin URL gets its own response headers instead. That would give the replay surface a self-identifying document URL and a policy scoped to it, without loosening the app's. It needs a change on the rrweb side, because Replayer constructs its own iframe.
Serve the player from a separate origin. Everything above, plus real origin isolation between replayed content and the app. The largest change, and it needs infrastructure work.
Tag the violations at ingestion and filter them out. Cheap and backend-only, but it hides the signal rather than fixing the policy, and does nothing about the enforcement cliff.
How this surfaced
Found while validating the origin filter in #91384, which drops CSP reports that legacy self-hosted installs send to Cloud. Checking what that filter would classify turned up this population instead: reports with about as the document URL, carrying our own middleware policy and Cloud static asset source files.
Related
Problem
The session replay player renders a recording into an
about:blankiframe. Local-scheme frames inherit the embedder's Content Security Policy, so the replayer runs under the PostHog app's policy and its asset loads are judged against it. Replayed images, fonts, and stylesheets come from whatever origins the recorded site used, so they violate that policy on essentially every replay.The app currently sends
Content-Security-Policy-Report-Only, so nothing is blocked and the player works. These violations are the advance warning of what breaks the day the policy is enforced: images, most fonts, and some stylesheets stop loading in every replay.Two defects sit behind it.
1. The replay asset proxy is not in the policy.
CorsPluginrewrites replayed font and script URLs throughhttps://replay.ph-proxy.com(common/replay-shared/src/rrweb-plugins/index.ts), so those loads come from one origin we control. That origin appears nowhere inCSPMiddleware(posthog/middleware.py). Every proxied font load therefore violatesfont-src. The proxy exists to make this tractable and the policy does not know about it.2.
CorsPlugindoes not rewrite images.It handles
STYLE,LINK, andSCRIPTnodes. Image elements are left alone, so replayed images load straight from the recorded site's origin and violateimg-src. Images are the largest share of the violations by some way.Why this needs fixing rather than filtering
The policy has already started absorbing recorded-site origins one at a time.
2a54418efe("chore: add more csp sources, from testing", #39824) addedhttps://assets.faircado.comandhttps://use.typekit.nettofont-src. Neither string appears anywhere else in the codebase — no frontend code, no config, no docs — so they are not app dependencies.That PR's review raised both halves of this and neither was answered. An automated reviewer asked whether those two origins were "legitimate requirements and not temporary testing artifacts". A human reviewer asked how the change affected replay, and the answer was a hope that
frame-src https:would cover it. It does not:frame-srcgoverns which frames a document may embed, and the replayer's frame isabout:blank, which inherits the policy instead. So the question was the right one and the mechanism just isn't the one anybody expected.Left alone, the policy accretes one recorded-site origin per investigation and still never covers the next recording.
Proposed fix
https://replay.ph-proxy.comtoimg-src,font-src,style-src-elem, andmedia-srcinCSPMiddleware.CorsPluginto rewrite image URLs through the proxy, includingsrcsetand CSSurl()references in stylesheets.https://assets.faircado.comandhttps://use.typekit.netfromfont-srconce proxied fonts are covered.The tradeoff is proxy bandwidth: every replayed image would go through
replay.ph-proxy.comrather than loading direct. Worth measuring before committing to it, and worth deciding what a proxy failure should degrade to.Alternatives considered
Give the replayer iframe a real document. Only
about:blank,srcdoc,blob:anddata:inherit the embedder's policy. An iframe pointed at a real same-origin URL gets its own response headers instead. That would give the replay surface a self-identifying document URL and a policy scoped to it, without loosening the app's. It needs a change on the rrweb side, becauseReplayerconstructs its own iframe.Serve the player from a separate origin. Everything above, plus real origin isolation between replayed content and the app. The largest change, and it needs infrastructure work.
Tag the violations at ingestion and filter them out. Cheap and backend-only, but it hides the signal rather than fixing the policy, and does nothing about the enforcement cliff.
How this surfaced
Found while validating the origin filter in #91384, which drops CSP reports that legacy self-hosted installs send to Cloud. Checking what that filter would classify turned up this population instead: reports with
aboutas the document URL, carrying our own middleware policy and Cloud static asset source files.Related