Files
cypress/packages/proxy/lib/http/util/csp-header.ts
T
Preston Goforth 71c5b864ea feat: Selective CSP header stripping from HTTPResponse (#26483)
* feat: Selective CSP header directive stripping from HTTPResponse
- uses `stripCspDirectives` config option

* feat: Selective CSP header directive permission from HTTPResponse
- uses `experimentalCspAllowList` config option

* Address Review Comments:
- Add i18n for `experimentalCspAllowList`
- Remove PR link in changelog
- Fix docs link in changelog
- Remove extra typedef additions
- Update validation error message and snapshot
- Fix middleware negated conditional

* chore: refactor driver test into system tests to get better test
coverage on experimentalCspAllowList options

* Address Review Comments:
- Remove legacyOption for `experimentalCspAllowList`
- Update App desc for `experimentalCspAllowList` to include "Content-Security-Policy-Report-Only"
- Modify CHANGELOG wording
- Specify “never” overrideLevel
- Remove unused validator (+2 squashed commits)
- Add "Addresses" note in CHANGELOG to satisfy automation
- Set `canUpdateDuringTestTime` to `false` to prevent confusion

* chore: Add `frame-src` and `child-src` to conditional CSP directives

* chore: Rename `isSubsetOf` to `isArrayIncludingAny`

* chore: fix CLI linting types

* chore: fix server unit tests

* chore: fix system tests within firefox and webkit

* chore: add form-action test

* chore: update system test snapshots

* chore: skip tests in webkit due to form-action flakiness

* chore: Move 'sandbox' and 'navigate-to' into `unsupportedCSPDirectives`
- Add additional system tests
- Update snapshots and unit test

* chore: update system test snapshots

* chore: fix system tests

* chore: do not run csp tests within firefox or webkit due to flake issues in CI

* chore: attempt to increase intercept delay to avoid race condition

* chore: update new snapshots with video defaults work

* chore: update changelog

---------

Co-authored-by: Bill Glesias <bglesias@gmail.com>
Co-authored-by: Matt Schile <mschile@cypress.io>
2023-06-14 14:54:52 -05:00

128 lines
4.9 KiB
TypeScript

import type { OutgoingHttpHeaders } from 'http'
const cspRegExp = /[; ]*([^\n\r; ]+) ?([^\n\r;]+)*/g
export const cspHeaderNames = ['content-security-policy', 'content-security-policy-report-only'] as const
export const nonceDirectives = ['script-src-elem', 'script-src', 'default-src']
export const problematicCspDirectives = [
...nonceDirectives,
'child-src', 'frame-src', 'form-action',
] as Cypress.experimentalCspAllowedDirectives[]
export const unsupportedCSPDirectives = [
/**
* In order for Cypress to run content in an iframe, we must remove the `frame-ancestors` directive
* from the CSP header. This is because this directive behaves like the `X-Frame-Options='deny'` header
* and prevents the iframe content from being loaded if it detects that it is not being loaded in the
* top-level frame.
*/
'frame-ancestors',
/**
* The `navigate-to` directive is not yet fully supported, so we are erring on the side of caution
*/
'navigate-to',
/**
* The `sandbox` directive seems to affect all iframes on the page, even if the page is a direct child of Cypress
*/
'sandbox',
/**
* Since Cypress might modify the DOM of the application under test, `trusted-types` would prevent the
* DOM injection from occurring.
*/
'trusted-types',
'require-trusted-types-for',
]
const caseInsensitiveGetAllHeaders = (headers: OutgoingHttpHeaders, lowercaseProperty: string): string[] => {
return Object.entries(headers).reduce((acc: string[], [key, value]) => {
if (key.toLowerCase() === lowercaseProperty) {
// It's possible to set more than 1 CSP header, and in those instances CSP headers
// are NOT merged by the browser. Instead, the most **restrictive** CSP header
// that applies to the given resource will be used.
// https://www.w3.org/TR/CSP2/#content-security-policy-header-field
//
// Therefore, we need to return each header as it's own value so we can apply
// injection nonce values to each one, because we don't know which will be
// the most restrictive.
acc.push.apply(
acc,
`${value}`.split(',')
.filter(Boolean)
.map((policyString) => `${policyString}`.trim()),
)
}
return acc
}, [])
}
function getCspHeaders (headers: OutgoingHttpHeaders, headerName: string = 'content-security-policy'): string[] {
return caseInsensitiveGetAllHeaders(headers, headerName.toLowerCase())
}
/**
* Parses the provided headers object and returns an array of policy Map objects.
* This will parse all CSP headers that match the provided `headerName` parameter,
* even if they are not lower case.
* @param headers - The headers object to parse
* @param headerName - The name of the header to parse. Defaults to `content-security-policy`
* @param excludeDirectives - An array of directives to exclude from the returned policy maps
* @returns An array of policy Map objects
*
* @example
* const policyMaps = parseCspHeaders({
* 'Content-Security-Policy': 'default-src self; script-src self https://www.google-analytics.com',
* 'content-security-policy': 'default-src self; script-src https://www.mydomain.com',
* })
* // policyMaps = [
* // Map {
* // 'default-src' => [ 'self' ],
* // 'script-src' => [ 'self', 'https://www.google-analytics.com' ]
* // },
* // Map {
* // 'default-src' => [ 'self' ],
* // 'script-src' => [ 'https://www.mydomain.com' ]
* // }
* // ]
*/
export function parseCspHeaders (headers: OutgoingHttpHeaders, headerName: string = 'content-security-policy', excludeDirectives: string[] = []): Map<string, string[]>[] {
const cspHeaders = getCspHeaders(headers, headerName)
// We must make an policy map for each CSP header individually
return cspHeaders.reduce((acc: Map<string, string[]>[], cspHeader) => {
const policies = new Map<string, string[]>()
let policy = cspRegExp.exec(cspHeader)
while (policy) {
const [/* regExpMatch */, directive, values = ''] = policy
if (!excludeDirectives.includes(directive)) {
const currentDirective = policies.get(directive) || []
policies.set(directive, [...currentDirective, ...values.split(' ').filter(Boolean)])
}
policy = cspRegExp.exec(cspHeader)
}
return [...acc, policies]
}, [])
}
/**
* Generates a CSP header string from the provided policy map.
* @param policies - The policy map to generate the CSP header string from
* @returns A CSP header policy string
* @example
* const policyString = generateCspHeader(new Map([
* ['default-src', ['self']],
* ['script-src', ['self', 'https://www.google-analytics.com']],
* ]))
* // policyString = 'default-src self; script-src self https://www.google-analytics.com'
*/
export function generateCspDirectives (policies: Map<string, string[]>): string {
return Array.from(policies.entries()).map(([directive, values]) => `${directive} ${values.join(' ')}`).join('; ')
}