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 17, 2023
1 parent 3d0a2b4 commit ff189fc
Show file tree
Hide file tree
Showing 20 changed files with 1,274 additions and 16 deletions.
2 changes: 2 additions & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ _Released 04/17/2023_

**Features:**

- 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). Addressed in [#26483](https://github.com/cypress-io/cypress/pull/26483).

- 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).

**Bugfixes:**
Expand Down
12 changes: 10 additions & 2 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 Expand Up @@ -3247,14 +3255,14 @@ declare namespace Cypress {
}

interface SuiteConfigOverrides extends Partial<
Pick<ConfigOptions, 'animationDistanceThreshold' | 'blockHosts' | 'defaultCommandTimeout' | 'env' | 'execTimeout' | 'includeShadowDom' | 'numTestsKeptInMemory' | 'pageLoadTimeout' | 'redirectionLimit' | 'requestTimeout' | 'responseTimeout' | 'retries' | 'screenshotOnRunFailure' | 'slowTestThreshold' | 'scrollBehavior' | 'taskTimeout' | 'viewportHeight' | 'viewportWidth' | 'waitForAnimations'>
Pick<ConfigOptions, 'animationDistanceThreshold' | 'blockHosts' | 'defaultCommandTimeout' | 'env' | 'execTimeout' | 'includeShadowDom' | 'numTestsKeptInMemory' | 'pageLoadTimeout' | 'redirectionLimit' | 'requestTimeout' | 'responseTimeout' | 'retries' | 'screenshotOnRunFailure' | 'slowTestThreshold' | 'scrollBehavior' | 'stripCspDirectives' | 'taskTimeout' | 'viewportHeight' | 'viewportWidth' | 'waitForAnimations'>
>, Partial<Pick<ResolvedConfigOptions, 'baseUrl' | 'testIsolation'>> {
browser?: IsBrowserMatcher | IsBrowserMatcher[]
keystrokeDelay?: number
}

interface TestConfigOverrides extends Partial<
Pick<ConfigOptions, 'animationDistanceThreshold' | 'blockHosts' | 'defaultCommandTimeout' | 'env' | 'execTimeout' | 'includeShadowDom' | 'numTestsKeptInMemory' | 'pageLoadTimeout' | 'redirectionLimit' | 'requestTimeout' | 'responseTimeout' | 'retries' | 'screenshotOnRunFailure' | 'slowTestThreshold' | 'scrollBehavior' | 'taskTimeout' | 'viewportHeight' | 'viewportWidth' | 'waitForAnimations'>
Pick<ConfigOptions, 'animationDistanceThreshold' | 'blockHosts' | 'defaultCommandTimeout' | 'env' | 'execTimeout' | 'includeShadowDom' | 'numTestsKeptInMemory' | 'pageLoadTimeout' | 'redirectionLimit' | 'requestTimeout' | 'responseTimeout' | 'retries' | 'screenshotOnRunFailure' | 'slowTestThreshold' | 'scrollBehavior' | 'stripCspDirectives' | 'taskTimeout' | 'viewportHeight' | 'viewportWidth' | 'waitForAnimations'>
>, Partial<Pick<ResolvedConfigOptions, 'baseUrl'>> {
browser?: IsBrowserMatcher | IsBrowserMatcher[]
keystrokeDelay?: number
Expand Down
1 change: 1 addition & 0 deletions cli/types/tests/cypress-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,7 @@ namespace CypressTestConfigOverridesTests {
requestTimeout: 6000,
responseTimeout: 6000,
scrollBehavior: 'center',
stripCspDirectives: false,
taskTimeout: 6000,
viewportHeight: 200,
viewportWidth: 200,
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
3 changes: 3 additions & 0 deletions packages/config/__snapshots__/index.spec.ts.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ exports['config/src/index .getDefaultValues returns list of public config keys 1
'screenshotsFolder': 'cypress/screenshots',
'slowTestThreshold': 10000,
'scrollBehavior': 'top',
'stripCspDirectives': true,
'supportFile': 'cypress/support/e2e.{js,jsx,ts,tsx}',
'supportFolder': false,
'taskTimeout': 60000,
Expand Down Expand Up @@ -157,6 +158,7 @@ exports['config/src/index .getDefaultValues returns list of public config keys f
'screenshotsFolder': 'cypress/screenshots',
'slowTestThreshold': 10000,
'scrollBehavior': 'top',
'stripCspDirectives': true,
'supportFile': 'cypress/support/e2e.{js,jsx,ts,tsx}',
'supportFolder': false,
'taskTimeout': 60000,
Expand Down Expand Up @@ -239,6 +241,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
24 changes: 24 additions & 0 deletions packages/config/test/project/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,28 @@ describe('config/src/project/utils', () => {
})
})

it('stripCspDirectives=true', function () {
return this.defaults('stripCspDirectives', true)
})

it('stripCspDirectives=false', function () {
return this.defaults('stripCspDirectives', false, {
stripCspDirectives: false,
})
})

it('stripCspDirectives=fake-directive', function () {
return this.defaults('stripCspDirectives', 'fake-directive', {
stripCspDirectives: 'fake-directive',
})
})

it('stripCspDirectives=["fake-directive"]', function () {
return this.defaults('stripCspDirectives', ['fake-directive'], {
stripCspDirectives: ['fake-directive'],
})
})

it('resets numTestsKeptInMemory to 0 when runMode', function () {
return mergeDefaults({ projectRoot: '/foo/bar/', supportFile: false }, { isTextTerminal: true }, {}, this.getFilesByGlob)
.then((cfg) => {
Expand Down Expand Up @@ -1088,6 +1110,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 Expand Up @@ -1207,6 +1230,7 @@ describe('config/src/project/utils', () => {
screenshotsFolder: { value: 'cypress/screenshots', from: 'default' },
slowTestThreshold: { value: 10000, from: 'default' },
specPattern: { value: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}', from: 'default' },
stripCspDirectives: { value: true, from: 'default' },
supportFile: { value: false, from: 'config' },
supportFolder: { value: false, from: 'default' },
taskTimeout: { value: 60000, from: 'default' },
Expand Down
58 changes: 58 additions & 0 deletions packages/config/test/validation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,64 @@ import * as validation from '../src/validation'
describe('config/src/validation', () => {
const mockKey = 'mockConfigKey'

describe('.validateAll', () => {
it('returns new validation function that accepts 2 arguments', () => {
const validate = validation.validateAll(() => true, () => false)

expect(validate).to.be.a.instanceof(Function)
expect(validate.length).to.eq(2)
})

it('returned validation function will return true when all validations pass', () => {
const value = Date.now()
const key = `key_${value}`
const validate = validation.validateAll((k, v) => v === value, (k, v) => k === key)

expect(validate(key, value)).to.equal(true)
})

it('returned validation function will return first failure result when any validation fails', () => {
const value = Date.now()
const key = `key_${value}`
const validateFail1 = validation.validateAll((k, v) => `${value}`, (k, v) => true)

expect(validateFail1(key, value)).to.equal(`${value}`)

const validateFail2 = validation.validateAll((k, v) => true, (k, v) => false)

expect(validateFail2(key, value)).to.equal(false)
})
})

describe('.validateAny', () => {
it('returns new validation function that accepts 2 arguments', () => {
const validate = validation.validateAny(() => true, () => false)

expect(validate).to.be.a.instanceof(Function)
expect(validate.length).to.eq(2)
})

it('returned validation function will return true when any validations pass', () => {
const value = Date.now()
const key = `key_${value}`
const validate = validation.validateAny((k, v) => 'fake-error', (k, v) => k === key)

expect(validate(key, value)).to.equal(true)
})

it('returned validation function will return last failure result when all validations fail', () => {
const value = Date.now()
const key = `key_${value}`
const validateFail1 = validation.validateAny((k, v) => `${value}`, (k, v) => false)

expect(validateFail1(key, value)).to.equal(false)

const validateFail2 = validation.validateAny((k, v) => false, (k, v) => `${value}`)

expect(validateFail2(key, value)).to.equal(`${value}`)
})
})

describe('.isValidClientCertificatesSet', () => {
it('returns error message for certs not passed as an array array', () => {
const result = validation.isValidRetriesConfig(mockKey, '1')
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
Loading

0 comments on commit ff189fc

Please sign in to comment.