Repository navigation
fix(csp): make the policy correct enough to enforce, and make its violations visible (#10) #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
d40778a
fix(security): nonce the Umami tracker and allow its beacon (#10)
arpitdalal d570ff4
fix(csp): capture parse-time violations before monitoring loads
arpitdalal 99f2123
docs(csp): correct the record — reportOnly never blocked anything
arpitdalal dccbdd6
fix(csp): stop CSP reporting from recursing through Sentry
arpitdalal File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| /** | ||
| * CSP violation capture. | ||
| * | ||
| * The policy in `server/index.ts` is `reportOnly`, so violations are never | ||
| * enforced — but the directives object also has no `report-uri` or `report-to`, | ||
| * which means the browser had nowhere to send them. They existed only as | ||
| * devtools console lines, which is how a policy that was wrong in two places | ||
| * stayed invisible. | ||
| * | ||
| * Registering the listener from `monitoring.client.tsx` is too late. That | ||
| * module is dynamically imported from `entry.client.tsx`, so it runs after the | ||
| * document has been parsed — and the violations most worth seeing (the Umami | ||
| * tracker, SSR images) are dispatched *during* parsing. Those events fire | ||
| * before any listener exists and are lost permanently. | ||
| * | ||
| * So the listener is installed by an inline nonced script at the top of | ||
| * `<head>` instead, which pushes into a bounded buffer. `drainCspViolations` | ||
| * is called once Sentry exists, forwarding both the buffered parse-time | ||
| * violations and anything that fires afterwards. | ||
| */ | ||
|
|
||
| /** Cap the buffer so a page in a violation loop cannot grow it without bound. */ | ||
| export const CSP_VIOLATION_LIMIT = 20 | ||
|
|
||
| export type CspViolation = { | ||
| blockedURI: string | ||
| violatedDirective: string | ||
| effectiveDirective: string | ||
| disposition: string | ||
| sourceFile: string | ||
| lineNumber: number | ||
| columnNumber: number | ||
| statusCode: number | ||
| } | ||
|
|
||
| /** | ||
| * Inline script source, rendered as the first child of `<head>`. | ||
| * | ||
| * Deliberately dependency-free and defensive: this runs before hydration, and | ||
| * a throw here would take down the whole document rather than just reporting. | ||
| */ | ||
| export const cspCaptureScript = `(function(){ | ||
| if (window.__cspViolations) return; | ||
| var seen = []; | ||
| window.__cspViolations = seen; | ||
| window.addEventListener('securitypolicyviolation', function (event) { | ||
| if (seen.length >= ${CSP_VIOLATION_LIMIT}) return; | ||
| seen.push({ | ||
| blockedURI: String(event.blockedURI || ''), | ||
| violatedDirective: String(event.violatedDirective || ''), | ||
| effectiveDirective: String(event.effectiveDirective || ''), | ||
| disposition: String(event.disposition || ''), | ||
| sourceFile: String(event.sourceFile || ''), | ||
| lineNumber: Number(event.lineNumber || 0), | ||
| columnNumber: Number(event.columnNumber || 0), | ||
| statusCode: Number(event.statusCode || 0) | ||
| }); | ||
| }); | ||
| })();` | ||
|
|
||
| /** | ||
| * Whether a violation was caused by Sentry's own transport being refused. | ||
| * | ||
| * Reporting a violation means sending an envelope to Sentry. If that envelope | ||
| * is itself blocked, forwarding the violation produces another blocked | ||
| * envelope, which produces another violation — an unbounded loop against the | ||
| * Sentry quota, triggered by nothing but a page load. | ||
| * | ||
| * `connect-src` is built from the DSN origin precisely so this cannot happen | ||
| * (`sentryOrigin` in `server/utils/csp.ts`). This is the second lock on the | ||
| * same door, for the case where the policy is tightened later and someone | ||
| * removes the Sentry host without realising what the violation handler does. | ||
| */ | ||
| export function isSentryViolation( | ||
| blockedURI: string, | ||
| sentryDsn: string | undefined, | ||
| ): boolean { | ||
| if (!sentryDsn || !blockedURI) return false | ||
| try { | ||
| return new URL(blockedURI).origin === new URL(sentryDsn).origin | ||
| } catch { | ||
| return false | ||
| } | ||
| } | ||
|
|
||
| declare global { | ||
| interface Window { | ||
| __cspViolations?: CspViolation[] | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Hand the buffered violations to the caller and install a live listener for | ||
| * everything after this point, so violations are not double-reported. | ||
| */ | ||
| export function drainCspViolations( | ||
| onViolation: (violation: CspViolation) => void, | ||
| ): number { | ||
| const buffered = window.__cspViolations ?? [] | ||
| window.__cspViolations = undefined | ||
|
|
||
| for (const violation of buffered.slice(0, CSP_VIOLATION_LIMIT)) { | ||
| onViolation(violation) | ||
| } | ||
|
|
||
| window.addEventListener('securitypolicyviolation', (event) => { | ||
| onViolation({ | ||
| blockedURI: String(event.blockedURI || ''), | ||
| violatedDirective: String(event.violatedDirective || ''), | ||
| effectiveDirective: String(event.effectiveDirective || ''), | ||
| disposition: String(event.disposition || ''), | ||
| sourceFile: String(event.sourceFile || ''), | ||
| lineNumber: Number(event.lineNumber || 0), | ||
| columnNumber: Number(event.columnNumber || 0), | ||
| statusCode: Number(event.statusCode || 0), | ||
| }) | ||
| }) | ||
|
|
||
| return buffered.length | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| /** | ||
| * Build the CSP `connect-src` allowlist. | ||
| * | ||
| * Umami's tracker POSTs to `https://<UMAMI_DOMAIN>/api/send`, and Sentry ships | ||
| * every envelope to the host in its DSN. Both are cross-origin, so both have to | ||
| * be named here. Umami is a full `https://` origin rather than a wildcard so a | ||
| * compromised Umami host cannot widen the policy. | ||
| */ | ||
|
|
||
| /** | ||
| * The exact origin Sentry's envelopes go to, from `SENTRY_DSN`. | ||
| * | ||
| * `*.sentry.io` was the previous answer, and it is wrong for anyone pointing | ||
| * `SENTRY_DSN` at a self-hosted or custom Sentry domain: their envelopes get | ||
| * blocked by `connect-src`, and since `app/utils/monitoring.client.tsx` now | ||
| * forwards CSP violations *through Sentry*, each blocked envelope triggers | ||
| * another one. That is an unbounded loop against the Sentry quota. | ||
| * | ||
| * `new URL(dsn).origin` is exact — it allows the configured host and nothing | ||
| * else, rather than every subdomain of sentry.io. | ||
| */ | ||
| export function sentryOrigin(sentryDsn: string | undefined): string | null { | ||
| if (!sentryDsn) return null | ||
| try { | ||
| return new URL(sentryDsn).origin | ||
| } catch { | ||
| // A malformed DSN cannot be reached anyway; Sentry itself will refuse | ||
| // it. Omitting the host keeps the policy tight instead of guessing. | ||
| return null | ||
| } | ||
| } | ||
|
|
||
| export function cspConnectSrc({ | ||
| mode, | ||
| sentryDsn, | ||
| umamiDomain, | ||
| }: { | ||
| mode: string | ||
| sentryDsn: string | undefined | ||
| umamiDomain: string | undefined | ||
| }): string[] { | ||
| return [ | ||
| mode === 'development' ? 'ws:' : null, | ||
| sentryOrigin(sentryDsn), | ||
| umamiDomain ? `https://${umamiDomain}` : null, | ||
| "'self'", | ||
| ].filter((value): value is string => value != null) | ||
| } | ||
|
|
||
| /** | ||
| * Build the CSP `img-src` allowlist. | ||
| * | ||
| * `*.cloudinary.com` backs the logo in `app/components/logo.tsx` and the | ||
| * project screenshots in `app/routes/_marketing+/__data.ts`. | ||
| * `*.hashnode.com` backs the blog/notes `coverImage.url` values served by the | ||
| * Hashnode GraphQL API (`app/graphql/gql.ts`) and rendered by `CardImage`. | ||
| */ | ||
| export function cspImgSrc(): string[] { | ||
| return ["'self'", 'data:', '*.cloudinary.com', '*.hashnode.com'] | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.