Skip to content

Commit

Permalink
feat: Selective CSP header directive stripping from HTTPResponse
Browse files Browse the repository at this point in the history
  • Loading branch information
pgoforth committed Apr 14, 2023
1 parent c7da9f4 commit 4a5f223
Show file tree
Hide file tree
Showing 18 changed files with 1,167 additions and 14 deletions.
2 changes: 2 additions & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ _Released 04/11/2023 (PENDING)_

- The Component Testing setup wizard will now show a warning message if an issue is encountered with an installed [third party framework definition](https://on.cypress.io/component-integrations). Addresses [#25838](https://github.com/cypress-io/cypress/issues/25838).

- Cypress now allows targeted Content-Security-Policy and Content-Security-Policy-Report-Only header directive stripping from requests via the [`stripCspDirectives`](https://docs.cypress.io/guides/references/configuration#stripCspDirectives) config option. Addresses [#1030](https://github.com/cypress-io/cypress/issues/1030).

**Bugfixes:**

- Capture the [Azure](https://azure.microsoft.com/) CI provider's environment variable [`SYSTEM_PULLREQUEST_PULLREQUESTNUMBER`](https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml#system-variables-devops-services) to display the linked PR number in the Cloud. Addressed in [#26215](https://github.com/cypress-io/cypress/pull/26215).
Expand Down
8 changes: 8 additions & 0 deletions cli/types/cypress.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3048,6 +3048,14 @@ declare namespace Cypress {
* @default 'top'
*/
scrollBehavior: scrollBehaviorOptions
/**
* Indicates whether Cypress should strip CSP header directives from the application under test.
* NOTE: When this option is set to `false`, Cypress **will** continue to strip the `frame-ancestors` directive
* because it prevents loading the target document into an iframe. Please see the documentation for more information.
* @see https://on.cypress.io/configuration#stripCspDirectives
* @default true
*/
stripCspDirectives: boolean | string | string[],
/**
* Allows listening to the `before:run`, `after:run`, `before:spec`, and `after:spec` events in the plugins file during interactive mode.
* @default false
Expand Down
1 change: 1 addition & 0 deletions packages/app/cypress.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export default defineConfig({
reporterOptions: {
configFile: '../../mocha-reporter-config.json',
},
stripCspDirectives: true,
experimentalInteractiveRunEvents: true,
component: {
experimentalSingleTabRunMode: true,
Expand Down
1 change: 1 addition & 0 deletions packages/config/__snapshots__/index.spec.ts.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ exports['config/src/index .getPublicConfigKeys returns list of public config key
'screenshotsFolder',
'slowTestThreshold',
'scrollBehavior',
'stripCspDirectives',
'supportFile',
'supportFolder',
'taskTimeout',
Expand Down
5 changes: 5 additions & 0 deletions packages/config/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,11 @@ const driverConfigOptions: Array<DriverConfigOption> = [
defaultValue: 'top',
validation: validate.isOneOf('center', 'top', 'bottom', 'nearest', false),
overrideLevel: 'any',
}, {
name: 'stripCspDirectives',
defaultValue: true,
validation: validate.validateAny(validate.isBoolean, validate.isStringOrArrayOfStrings),
overrideLevel: 'any',
}, {
name: 'supportFile',
defaultValue: (options: Record<string, any> = {}) => options.testingType === 'component' ? 'cypress/support/component.{js,jsx,ts,tsx}' : 'cypress/support/e2e.{js,jsx,ts,tsx}',
Expand Down
27 changes: 27 additions & 0 deletions packages/config/src/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,33 @@ const isFalse = (value: any): boolean => {
return value === false
}

type ValidationResult = ErrResult | boolean | string;
type ValidationFn = (key: string, value: any) => ValidationResult

export const validateAny = (...validations: ValidationFn[]): ValidationFn => {
return (key: string, value: any): ValidationResult => {
return validations.reduce((result: ValidationResult, validation: ValidationFn) => {
if (result === true) {
return result
}

return validation(key, value)
}, false)
}
}

export const validateAll = (...validations: ValidationFn[]): ValidationFn => {
return (key: string, value: any): ValidationResult => {
return validations.reduce((result: ValidationResult, validation: ValidationFn) => {
if (result !== true) {
return result
}

return validation(key, value)
}, true)
}
}

/**
* Validates a single browser object.
* @returns {string|true} Returns `true` if the object is matching browser object schema. Returns an error message if it does not.
Expand Down
1 change: 1 addition & 0 deletions packages/config/test/project/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1088,6 +1088,7 @@ describe('config/src/project/utils', () => {
screenshotsFolder: { value: 'cypress/screenshots', from: 'default' },
specPattern: { value: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}', from: 'default' },
slowTestThreshold: { value: 10000, from: 'default' },
stripCspDirectives: { value: true, from: 'default' },
supportFile: { value: false, from: 'config' },
supportFolder: { value: false, from: 'default' },
taskTimeout: { value: 60000, from: 'default' },
Expand Down
17 changes: 17 additions & 0 deletions packages/driver/cypress/e2e/e2e/security.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,21 @@ describe('security', () => {
cy.visit('/fixtures/security.html')
cy.get('div').should('not.exist')
})

it('works even with content-security-policy script-src', () => {
// create report URL
cy.intercept('/csp-report', (req) => {
throw new Error(`/csp-report should not be reached:${ req.body}`)
})

// inject script-src on visit
cy.intercept('/fixtures/empty.html', (req) => {
req.continue((res) => {
res.headers['content-security-policy'] = `script-src http://not-here.net; report-uri /csp-report`
})
})

cy.visit('/fixtures/empty.html')
.wait(1000)
})
})
5 changes: 5 additions & 0 deletions packages/frontend-shared/cypress/fixtures/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@
"from": "default",
"field": "scrollBehavior"
},
{
"value": true,
"from": "default",
"field": "stripCspDirectives"
},
{
"value": "cypress/support/e2e.ts",
"from": "config",
Expand Down
70 changes: 67 additions & 3 deletions packages/proxy/lib/http/response-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { URL } from 'url'
import { CookiesHelper } from './util/cookies'
import { doesTopNeedToBeSimulated } from './util/top-simulation'
import { toughCookieToAutomationCookie } from '@packages/server/lib/util/cookies'
import { generateCspDirectives, hasCspHeader, nonceDirectives, parseCspHeaders, unsupportedCSPDirectives } from './util/csp-header'
import crypto from 'crypto'

interface ResponseMiddlewareProps {
/**
Expand Down Expand Up @@ -302,6 +304,24 @@ const SetInjectionLevel: ResponseMiddleware = function () {
this.res.wantsInjection = getInjectionLevel()
}

// If the user has specified CSP directives to strip, we must remove them from the CSP header
const stripDirectives = this.config.stripCspDirectives || unsupportedCSPDirectives

// Only patch the headers that are being supplied by the response
const incomingCspHeaders = ['content-security-policy', 'content-security-policy-report-only']
.filter((headerName) => {
const hasHeader = hasCspHeader(this.incomingRes.headers, headerName)

// Remove the header entirely if it exists and stripCspDirectives is === true
if (hasHeader && stripDirectives === true) {
this.res.removeHeader(headerName)

return false
}

return hasHeader
})

if (this.res.wantsInjection) {
// Chrome plans to make document.domain immutable in Chrome 109, with the default value
// of the Origin-Agent-Cluster header becoming 'true'. We explicitly disable this header
Expand All @@ -311,6 +331,51 @@ const SetInjectionLevel: ResponseMiddleware = function () {
// We set the header here only for proxied requests that have scripts injected that set the domain.
// Other proxied requests are ignored.
this.res.setHeader('Origin-Agent-Cluster', '?0')

if (incomingCspHeaders.length && stripDirectives !== true) {
// In order to allow the injected script to run on sites with a CSP header
// we must add a generated `nonce` into the response headers
const nonce = crypto.randomBytes(16).toString('base64')

// Iterate through each CSP header
incomingCspHeaders.forEach((headerName) => {
const excludeDirectives = _.castArray(stripDirectives)
const validCspHeaders = parseCspHeaders(this.incomingRes.headers, headerName, excludeDirectives)

const usedNonceDirectives = nonceDirectives
// If the user has specified a list of CSP directives to strip, we cannot use them for the nonce
.filter((directive) => !excludeDirectives.includes(directive))
// If there are no used CSP directives that restrict script src execution, our script will run
// without the nonce, so we will not add it to the response
.filter((directive) => validCspHeaders.some((policy) => policy.has(directive)))

if (usedNonceDirectives.length) {
// If there is a CSP directive that that restrict script src execution, we must add the
// nonce policy to each supported directive of each CSP header. This is due to the effect
// of [multiple policies](https://w3c.github.io/webappsec-csp/#multiple-policies) in CSP.
this.res.injectionNonce = nonce
validCspHeaders.forEach((policies) => {
nonceDirectives.forEach((availableNonceDirective) => {
if (policies.has(availableNonceDirective)) {
const cspScriptSrc = policies.get(availableNonceDirective) || []

policies.set(availableNonceDirective, [...cspScriptSrc, `'nonce-${nonce}'`])
}
})
})
}

const modifiedCspHeaders = validCspHeaders.map(generateCspDirectives).filter(Boolean)

if (modifiedCspHeaders.length === 0) {
// If there are no CSP headers after stripping directives, we will remove it from the response
this.res.removeHeader(headerName)
} else {
// To replicate original response CSP headers, we must apply all header values as an array
this.res.setHeader(headerName, modifiedCspHeaders)
}
})
}
}

this.res.wantsSecurityRemoved = (this.config.modifyObstructiveCode || this.config.experimentalModifyObstructiveThirdPartyCode) &&
Expand Down Expand Up @@ -356,8 +421,6 @@ const OmitProblematicHeaders: ResponseMiddleware = function () {
'x-frame-options',
'content-length',
'transfer-encoding',
'content-security-policy',
'content-security-policy-report-only',
'connection',
])

Expand Down Expand Up @@ -540,6 +603,7 @@ const MaybeInjectHtml: ResponseMiddleware = function () {

const decodedBody = iconv.decode(body, nodeCharset)
const injectedBody = await rewriter.html(decodedBody, {
cspNonce: this.res.injectionNonce,
domainName: cors.getDomainNameFromUrl(this.req.proxiedUrl),
wantsInjection: this.res.wantsInjection,
wantsSecurityRemoved: this.res.wantsSecurityRemoved,
Expand Down Expand Up @@ -613,8 +677,8 @@ export default {
AttachPlainTextStreamFn,
InterceptResponse,
PatchExpressSetHeader,
OmitProblematicHeaders, // Since we might modify CSP headers, this middleware needs to come BEFORE SetInjectionLevel
SetInjectionLevel,
OmitProblematicHeaders,
MaybePreventCaching,
MaybeStripDocumentDomainFeaturePolicy,
MaybeCopyCookiesFromIncomingRes,
Expand Down
74 changes: 74 additions & 0 deletions packages/proxy/lib/http/util/csp-header.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { OutgoingHttpHeaders } from 'http'

const cspRegExp = /[; ]*([^\n\r; ]+) ?([^\n\r;]+)*/g

export const nonceDirectives = ['script-src-elem', 'script-src', 'default-src']

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',
]

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())
}

export function hasCspHeader (headers: OutgoingHttpHeaders, headerName: string = 'content-security-policy') {
return getCspHeaders(headers, headerName).length > 0
}

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]
}, [])
}

export function generateCspDirectives (policies: Map<string, string[]>): string {
return Array.from(policies.entries()).map(([directive, values]) => `${directive} ${values.join(' ')}`).join('; ')
}
19 changes: 15 additions & 4 deletions packages/proxy/lib/http/util/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getRunnerInjectionContents, getRunnerCrossOriginInjectionContents } fro
import type { SerializableAutomationCookie } from '@packages/server/lib/util/cookies'

interface InjectionOpts {
cspNonce?: string
shouldInjectDocumentDomain: boolean
}
interface FullCrossOriginOpts {
Expand All @@ -12,6 +13,7 @@ interface FullCrossOriginOpts {
}

export function partial (domain, options: InjectionOpts) {
const { cspNonce } = options
let documentDomainInjection = `document.domain = '${domain}';`

if (!options.shouldInjectDocumentDomain) {
Expand All @@ -21,13 +23,17 @@ export function partial (domain, options: InjectionOpts) {
// With useDefaultDocumentDomain=true we continue to inject an empty script tag in order to be consistent with our other forms of injection.
// This is also diagnostic in nature is it will allow us to debug easily to make sure injection is still occurring.
return oneLine`
<script type='text/javascript'>
<script type='text/javascript'${
cspNonce ? ` nonce="${cspNonce}"` : ''
}>
${documentDomainInjection}
</script>
`
}

export function full (domain, options: InjectionOpts) {
const { cspNonce } = options

return getRunnerInjectionContents().then((contents) => {
let documentDomainInjection = `document.domain = '${domain}';`

Expand All @@ -36,7 +42,9 @@ export function full (domain, options: InjectionOpts) {
}

return oneLine`
<script type='text/javascript'>
<script type='text/javascript'${
cspNonce ? ` nonce="${cspNonce}"` : ''
}>
${documentDomainInjection}
${contents}
Expand All @@ -47,6 +55,7 @@ export function full (domain, options: InjectionOpts) {

export async function fullCrossOrigin (domain, options: InjectionOpts & FullCrossOriginOpts) {
const contents = await getRunnerCrossOriginInjectionContents()
const { cspNonce, ...crossOriginOptions } = options

let documentDomainInjection = `document.domain = '${domain}';`

Expand All @@ -55,12 +64,14 @@ export async function fullCrossOrigin (domain, options: InjectionOpts & FullCros
}

return oneLine`
<script type='text/javascript'>
<script type='text/javascript'${
cspNonce ? ` nonce="${cspNonce}"` : ''
}>
${documentDomainInjection}
(function (cypressConfig) {
${contents}
}(${JSON.stringify(options)}));
}(${JSON.stringify(crossOriginOptions)}));
</script>
`
}
Loading

0 comments on commit 4a5f223

Please sign in to comment.