-
Notifications
You must be signed in to change notification settings - Fork 761
CONSOLE-5524: Add CSP violation detection to Playwright #17188
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
Changes from all commits
af90c67
3b4dd4b
58774f9
9d4d5f8
e42fec5
ac6aa61
1f7e950
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,191 @@ | ||
| import type { Page } from '@playwright/test'; | ||
|
|
||
| export interface CSPViolationReport { | ||
| 'csp-report': Pick< | ||
| SecurityPolicyViolationEvent, | ||
| | 'documentURI' | ||
| | 'violatedDirective' | ||
| | 'effectiveDirective' | ||
| | 'blockedURI' | ||
| | 'sourceFile' | ||
| | 'lineNumber' | ||
| | 'disposition' | ||
| >; | ||
| } | ||
|
|
||
| // Shape of the POST body the browser sends to a `report-uri` endpoint. It uses | ||
| // kebab-case keys (per the legacy CSP violation report spec), not the camelCase | ||
| // names on SecurityPolicyViolationEvent used by CSPViolationReport above. | ||
| interface RawCSPReportBody { | ||
| 'document-uri'?: string; | ||
| 'violated-directive'?: string; | ||
| 'effective-directive'?: string; | ||
| 'blocked-uri'?: string; | ||
| 'source-file'?: string; | ||
| 'line-number'?: number; | ||
| disposition?: SecurityPolicyViolationEvent['disposition']; | ||
| } | ||
|
|
||
| // Fake reporting endpoint. Requests to it never hit the network: they're | ||
| // intercepted and fulfilled locally via CDP below. | ||
| const CSP_REPORT_URL = 'https://csp-violation-report.test/report'; | ||
|
|
||
| // Normalize the browser's kebab-case report body into the camelCase shape | ||
| // callers expect. The body can also be missing entirely: CDP omits | ||
| // `request.postData` when the body isn't available inline (too large, or not | ||
| // text), so an unparseable body must produce a clearly-labelled report rather | ||
| // than one whose every field is `undefined`. | ||
| const parseCSPReport = (postData: string | undefined, fallbackURI: string): CSPViolationReport => { | ||
| let raw: RawCSPReportBody | undefined; | ||
| try { | ||
| raw = JSON.parse(postData)?.['csp-report']; | ||
| } catch (e) { | ||
| raw = undefined; | ||
| console.warn('[CSP] Failed to parse CSP report POST data', e); | ||
| } | ||
|
|
||
| if (!raw || typeof raw !== 'object') { | ||
| return { | ||
| 'csp-report': { | ||
| documentURI: fallbackURI, | ||
| violatedDirective: 'unknown', | ||
| effectiveDirective: 'unknown', | ||
| blockedURI: 'unknown', | ||
| sourceFile: undefined, | ||
| lineNumber: undefined, | ||
| disposition: undefined, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| 'csp-report': { | ||
| documentURI: raw['document-uri'], | ||
| violatedDirective: raw['violated-directive'], | ||
| effectiveDirective: raw['effective-directive'], | ||
| blockedURI: raw['blocked-uri'], | ||
| sourceFile: raw['source-file'], | ||
| lineNumber: raw['line-number'], | ||
| disposition: raw.disposition, | ||
| }, | ||
| }; | ||
| }; | ||
|
|
||
| // Import from Git e2e tests make direct browser requests to api.github.com | ||
| // which violates connect-src CSP. This is expected since git hosting can be | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If this behavior is expected, we should address this within Console CSP itself as a long-term solution. Otherwise, we'd be suppressing
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yup but this is copied straight from the cypress version.. we will want to adjust the CSP to accommodate this use case in the very near future |
||
| // on any arbitrary hostname (e.g. Gitea) and cannot be allowlisted in CSP. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why it cannot be allowed in Console CSP? (I guess this is the part I'm missing)
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Gitea is self-hosted so it can come from any domain or origin, so the list of all gitea instances is unknownable at build time, so it cannot be allow listed. We would have to allow every origin
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the clarification. Maybe it's known (or resolvable) at Bridge runtime when we're generating CSP header for Console main page? Anyway, not an issue for now. |
||
| const isExpectedGitConnectViolation = (report: CSPViolationReport['csp-report']) => | ||
| report.effectiveDirective === 'connect-src' && | ||
| report.blockedURI?.startsWith('https://api.github.com/'); | ||
|
|
||
| // Resuming an intercepted request routinely fails with a "target closed" error | ||
| // when the request is still paused as the page is torn down at the end of a | ||
| // test; that is expected and must not be reported. Any other failure means the | ||
| // request stays paused forever, which surfaces as an unexplained navigation | ||
| // timeout, so make that visible instead of swallowing it. | ||
| const ignoreClosedTarget = (send: Promise<unknown>) => | ||
| send.catch((error) => { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| if (!/closed|detached/i.test(message)) { | ||
| console.warn(`[CSP] Failed to resume intercepted request: ${message}`); | ||
| } | ||
| }); | ||
|
|
||
| // Console only emits a CSP `report-uri` directive when the request serving the | ||
| // page carries a `Test-CSP-Reporting-Endpoint` header (see | ||
| // pkg/utils/utils.go BuildCSPDirectives / pkg/server/server.go indexHandler). | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, and the reason for this is to facilitate CSP testing as a request-level opt-in mechanism. We could also consider updating Console Bridge code to ignore |
||
| // | ||
| // Use `page` to test for Content Security Policy (CSP) violations, ported | ||
| // from the CDP-based approach in the former test-puppeteer-csp.ts, now | ||
| // applied to every navigation in every test rather than a single hardcoded | ||
| // page. | ||
| // | ||
| // `baseURL` scopes the header to Console's own origin. Only Console's backend | ||
| // understands it, and document navigations regularly leave that origin (the | ||
| // OAuth login flow redirects to the cluster's OAuth server), so without the | ||
| // scope this internal test-only header would be sent to third parties. | ||
| export interface CSPViolationTracker { | ||
| violations: CSPViolationReport[]; | ||
| // CDP delivers events and command responses over the same ordered channel, | ||
| // so round-tripping a command guarantees any 'Fetch.requestPaused' events | ||
| // already in flight (e.g. from a violation triggered by the test's last | ||
| // action) have been received and pushed to `violations` before this | ||
| // resolves. Callers must await this before reading `violations`. | ||
| waitForPendingReports: () => Promise<void>; | ||
| } | ||
|
|
||
| export const trackCSPViolations = async ( | ||
| page: Page, | ||
| baseURL: string, | ||
| ): Promise<CSPViolationTracker> => { | ||
| const violations: CSPViolationReport[] = []; | ||
|
|
||
| // Create a Chrome DevTools Protocol (CDP) session for the page. | ||
| const cdpSession = await page.context().newCDPSession(page); | ||
|
|
||
| const consoleOrigin = new URL(baseURL).origin; | ||
| const isConsoleURL = (url: string) => { | ||
| try { | ||
| return new URL(url).origin === consoleOrigin; | ||
| } catch { | ||
| return false; | ||
| } | ||
| }; | ||
|
|
||
| // Subscribe before enabling the domain: a 'Fetch.requestPaused' event that | ||
| // arrives with no listener attached leaves that request paused forever. | ||
| cdpSession.on('Fetch.requestPaused', ({ resourceType, request, requestId }) => { | ||
| // When requesting the web page, add custom 'Test-CSP-Reporting-Endpoint' HTTP header | ||
| // in order to instruct Console Bridge server to use the given CSP reporting endpoint. | ||
| if (resourceType === 'Document' && isConsoleURL(request.url)) { | ||
| const headers = Object.entries(request.headers).map(([name, value]) => ({ name, value })); | ||
|
|
||
| headers.push({ name: 'Test-CSP-Reporting-Endpoint', value: CSP_REPORT_URL }); | ||
| ignoreClosedTarget(cdpSession.send('Fetch.continueRequest', { requestId, headers })); | ||
| } | ||
|
|
||
| // The browser will attempt to send any CSP violations to the CSP reporting endpoint. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note that certain browser plugins like uBlock Origin will block CSP report requests by default. https://github.com/gorhill/uBlock/wiki/Dashboard:-Settings#block-csp-reports I guess this is not an issue when using Chrome (for testing) + CDP with Playwright.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Playwright shouldn't be loading any extensions anyway so for this specific scenario it's fine |
||
| // When such request occurs, we manually fulfill that request before it is sent over | ||
| // the network and therefore avoiding the need to implement that reporting endpoint. | ||
| else if (resourceType === 'CSPViolationReport' && request.url === CSP_REPORT_URL) { | ||
| const report = parseCSPReport(request.postData, request.url); | ||
| if (!isExpectedGitConnectViolation(report['csp-report'])) { | ||
| violations.push(report); | ||
|
Comment on lines
+152
to
+153
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '70,165p' frontend/e2e/fixtures/csp-violation-tracker.ts
rg -n 'api\.github\.com|import.from.Git|Import from Git|connect-src|CSP' frontend/e2e frontend/packages/console-app test-prow-e2e.sh test-prow-e2e-cypress.sh frontend/test-puppeteer-csp.ts 2>/dev/nullRepository: openshift/console Length of output: 36359 🏁 Script executed: set -o pipefail
printf '%s\n' '--- fixture callers and integration ---'
sed -n '1,150p' frontend/e2e/fixtures/index.ts
printf '%s\n' '--- all trackCSPViolations/assertion references ---'
rg -n -C 4 'trackCSPViolations|assertNoCSPViolations|isExpectedGitConnectViolation|api\.github\.com' frontend/e2e --glob '*.ts' --glob '*.tsx'
printf '%s\n' '--- focused diff summary ---'
git diff --stat -- frontend/e2e/fixtures/csp-violation-tracker.ts frontend/e2e/fixtures/index.ts frontend/e2e/tests
printf '%s\n' '--- focused diff ---'
git diff -- frontend/e2e/fixtures/csp-violation-tracker.ts frontend/e2e/fixtures/index.ts frontend/e2e/tests
printf '%s\n' '--- legacy CSP references and docs ---'
rg -n -C 4 'test-puppeteer-csp|csp.*violation|CSP.*violation|connect-src|Import from Git' --glob '*.md' --glob '*.go' --glob '*.ts' --glob '*.tsx' --glob '*.sh' --glob '*package.json' . 2>/dev/null | head -n 500Repository: openshift/console Length of output: 50375 🤖 get_repo_knowledge executed:
Length of output: 6342 🏁 Script executed: set -o pipefail
printf '%s\n' '--- topology test ---'
sed -n '1,180p' frontend/e2e/tests/topology/topology-ci.spec.ts
printf '%s\n' '--- import-from-Git Playwright tests ---'
for f in frontend/e2e/tests/dev-console/import-from-git.spec.ts frontend/e2e/tests/dev-console/import-from-devfile.spec.ts frontend/e2e/tests/dev-console/import-from-dockerfile.spec.ts frontend/e2e/tests/knative/serverless/knative-ci.spec.ts; do
if [ -f "$f" ]; then
echo "--- $f"
sed -n '1,150p' "$f"
fi
done
printf '%s\n' '--- annotations and fixture configuration ---'
rg -n -C 3 'test\.describe|test\.use|annotations|no-auto-reauth|fixtures|import-from-git|topology-ci' frontend/e2e/tests frontend/e2e/playwright.config.* frontend/e2e/*.config.* 2>/dev/null | head -n 500
printf '%s\n' '--- legacy files and documentation ---'
printf '%s\n' 'tracked legacy CSP files:'
git ls-files '*test-puppeteer-csp*' '*cypress*' | grep -E '(^|/)(test-puppeteer-csp|cypress)' | head -n 100 || true
printf '%s\n' 'tracked e2e documentation:'
git ls-files 'frontend/e2e/**' | grep -Ei '(^|/)(readme|doc|documentation)|\\.md$' | head -n 100 || true
printf '%s\n' 'legacy/reference text:'
rg -n -C 3 'test-puppeteer-csp|CSP.*(Cypress|Puppeteer)|Cypress.*CSP|Puppeteer.*CSP|GitHub.*CSP|api\\.github\\.com' frontend/e2e README.md .github 2>/dev/null | head -n 300Repository: openshift/console Length of output: 50373 Scope the GitHub suppression to GitHub-backed import tests.
Pass test-specific suppression state to 🤖 Prompt for AI Agents |
||
| } | ||
| ignoreClosedTarget(cdpSession.send('Fetch.fulfillRequest', { requestId, responseCode: 200 })); | ||
| } | ||
|
|
||
| // Resume other requests that were not explicitly handled above. | ||
| else { | ||
| ignoreClosedTarget(cdpSession.send('Fetch.continueRequest', { requestId })); | ||
| } | ||
| }); | ||
|
|
||
| // This will trigger 'Fetch.requestPaused' events for the matching requests. | ||
| await cdpSession.send('Fetch.enable', { | ||
| patterns: [{ resourceType: 'Document' }, { resourceType: 'CSPViolationReport' }], | ||
| }); | ||
|
|
||
| const waitForPendingReports = () => | ||
| ignoreClosedTarget(cdpSession.send('Runtime.evaluate', { expression: 'void 0' })).then( | ||
| () => {}, | ||
| ); | ||
|
|
||
| return { violations, waitForPendingReports }; | ||
| }; | ||
|
|
||
| export const assertNoCSPViolations = (violations: CSPViolationReport[]) => { | ||
| if (violations.length === 0) { | ||
| return; | ||
| } | ||
| const details = violations | ||
| .map((v) => { | ||
| const report = v['csp-report']; | ||
| return ( | ||
| ` - ${report.violatedDirective} blocked ${report.blockedURI} on ${report.documentURI}` + | ||
| (report.sourceFile ? ` (${report.sourceFile}:${report.lineNumber})` : '') | ||
| ); | ||
| }) | ||
| .join('\n'); | ||
| throw new Error(`Content Security Policy violation(s) detected:\n${details}`); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import type { Page } from '@playwright/test'; | ||
|
|
||
| /** | ||
| * Asserts no errors from {@link window.windowError} were reported during the test, | ||
| * which gets sourced from `window.onerror`, `window.onunhandledrejection`, and other | ||
| * unhandled page errors | ||
| * | ||
| * If any errors were reported, throws an error with the list of errors. | ||
| */ | ||
| export const assertNoWindowErrors = async (page: Page) => { | ||
| const windowError = await page.evaluate(() => window.windowError); | ||
| if (windowError) { | ||
| throw new Error(`Unhandled error(s) detected on the page:\n${windowError}`); | ||
| } | ||
| }; |
Uh oh!
There was an error while loading. Please reload this page.