diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 6c5c3c116aa1..817edc2982e9 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -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:** diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index e2311554ab94..5fee5261025d 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -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 @@ -3247,14 +3255,14 @@ declare namespace Cypress { } interface SuiteConfigOverrides extends Partial< - Pick + Pick >, Partial> { browser?: IsBrowserMatcher | IsBrowserMatcher[] keystrokeDelay?: number } interface TestConfigOverrides extends Partial< - Pick + Pick >, Partial> { browser?: IsBrowserMatcher | IsBrowserMatcher[] keystrokeDelay?: number diff --git a/cli/types/tests/cypress-tests.ts b/cli/types/tests/cypress-tests.ts index 4b036baa10e5..1c2a5b4d2840 100644 --- a/cli/types/tests/cypress-tests.ts +++ b/cli/types/tests/cypress-tests.ts @@ -851,6 +851,7 @@ namespace CypressTestConfigOverridesTests { requestTimeout: 6000, responseTimeout: 6000, scrollBehavior: 'center', + stripCspDirectives: false, taskTimeout: 6000, viewportHeight: 200, viewportWidth: 200, diff --git a/packages/app/cypress.config.ts b/packages/app/cypress.config.ts index b505c98ee97e..7daddb0e0989 100644 --- a/packages/app/cypress.config.ts +++ b/packages/app/cypress.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ reporterOptions: { configFile: '../../mocha-reporter-config.json', }, + stripCspDirectives: true, experimentalInteractiveRunEvents: true, component: { experimentalSingleTabRunMode: true, diff --git a/packages/config/__snapshots__/index.spec.ts.js b/packages/config/__snapshots__/index.spec.ts.js index 51e0c8b4f839..6d859a64577c 100644 --- a/packages/config/__snapshots__/index.spec.ts.js +++ b/packages/config/__snapshots__/index.spec.ts.js @@ -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, @@ -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, @@ -239,6 +241,7 @@ exports['config/src/index .getPublicConfigKeys returns list of public config key 'screenshotsFolder', 'slowTestThreshold', 'scrollBehavior', + 'stripCspDirectives', 'supportFile', 'supportFolder', 'taskTimeout', diff --git a/packages/config/src/options.ts b/packages/config/src/options.ts index 2a48153160ba..552441c6f541 100644 --- a/packages/config/src/options.ts +++ b/packages/config/src/options.ts @@ -376,6 +376,11 @@ const driverConfigOptions: Array = [ 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 = {}) => options.testingType === 'component' ? 'cypress/support/component.{js,jsx,ts,tsx}' : 'cypress/support/e2e.{js,jsx,ts,tsx}', diff --git a/packages/config/src/validation.ts b/packages/config/src/validation.ts index 8f023db35515..e4a9ece82e81 100644 --- a/packages/config/src/validation.ts +++ b/packages/config/src/validation.ts @@ -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. diff --git a/packages/config/test/project/utils.spec.ts b/packages/config/test/project/utils.spec.ts index 87c713d06e4c..0aa7ff0e7aea 100644 --- a/packages/config/test/project/utils.spec.ts +++ b/packages/config/test/project/utils.spec.ts @@ -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) => { @@ -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' }, @@ -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' }, diff --git a/packages/config/test/validation.spec.ts b/packages/config/test/validation.spec.ts index fc7b22e5eb31..09b1b9a0cd13 100644 --- a/packages/config/test/validation.spec.ts +++ b/packages/config/test/validation.spec.ts @@ -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') diff --git a/packages/driver/cypress/e2e/e2e/security.cy.js b/packages/driver/cypress/e2e/e2e/security.cy.js index 879e71ba41cd..0f1816727b82 100644 --- a/packages/driver/cypress/e2e/e2e/security.cy.js +++ b/packages/driver/cypress/e2e/e2e/security.cy.js @@ -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) + }) }) diff --git a/packages/frontend-shared/cypress/fixtures/config.json b/packages/frontend-shared/cypress/fixtures/config.json index 22b42486130a..e9ce47851737 100644 --- a/packages/frontend-shared/cypress/fixtures/config.json +++ b/packages/frontend-shared/cypress/fixtures/config.json @@ -207,6 +207,11 @@ "from": "default", "field": "scrollBehavior" }, + { + "value": true, + "from": "default", + "field": "stripCspDirectives" + }, { "value": "cypress/support/e2e.ts", "from": "config", diff --git a/packages/proxy/lib/http/response-middleware.ts b/packages/proxy/lib/http/response-middleware.ts index 918764cbf59d..3252b3918e90 100644 --- a/packages/proxy/lib/http/response-middleware.ts +++ b/packages/proxy/lib/http/response-middleware.ts @@ -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 { /** @@ -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 @@ -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) && @@ -356,8 +421,6 @@ const OmitProblematicHeaders: ResponseMiddleware = function () { 'x-frame-options', 'content-length', 'transfer-encoding', - 'content-security-policy', - 'content-security-policy-report-only', 'connection', ]) @@ -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, @@ -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, diff --git a/packages/proxy/lib/http/util/csp-header.ts b/packages/proxy/lib/http/util/csp-header.ts new file mode 100644 index 000000000000..056a876d750f --- /dev/null +++ b/packages/proxy/lib/http/util/csp-header.ts @@ -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[] { + const cspHeaders = getCspHeaders(headers, headerName) + + // We must make an policy map for each CSP header individually + return cspHeaders.reduce((acc: Map[], cspHeader) => { + const policies = new Map() + 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 { + return Array.from(policies.entries()).map(([directive, values]) => `${directive} ${values.join(' ')}`).join('; ') +} diff --git a/packages/proxy/lib/http/util/inject.ts b/packages/proxy/lib/http/util/inject.ts index 936cf34eb379..9a840e2610cf 100644 --- a/packages/proxy/lib/http/util/inject.ts +++ b/packages/proxy/lib/http/util/inject.ts @@ -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 { @@ -12,6 +13,7 @@ interface FullCrossOriginOpts { } export function partial (domain, options: InjectionOpts) { + const { cspNonce } = options let documentDomainInjection = `document.domain = '${domain}';` if (!options.shouldInjectDocumentDomain) { @@ -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` - ` } export function full (domain, options: InjectionOpts) { + const { cspNonce } = options + return getRunnerInjectionContents().then((contents) => { let documentDomainInjection = `document.domain = '${domain}';` @@ -36,7 +42,9 @@ export function full (domain, options: InjectionOpts) { } return oneLine` - ` } diff --git a/packages/proxy/lib/http/util/rewriter.ts b/packages/proxy/lib/http/util/rewriter.ts index 26067bce9e10..de4d286a771f 100644 --- a/packages/proxy/lib/http/util/rewriter.ts +++ b/packages/proxy/lib/http/util/rewriter.ts @@ -14,6 +14,7 @@ export type SecurityOpts = { } export type InjectionOpts = { + cspNonce?: string domainName: string wantsInjection: CypressWantsInjection wantsSecurityRemoved: any @@ -32,6 +33,7 @@ function getRewriter (useAstSourceRewriting: boolean) { function getHtmlToInject (opts: InjectionOpts & SecurityOpts) { const { + cspNonce, domainName, wantsInjection, modifyObstructiveThirdPartyCode, @@ -44,9 +46,11 @@ function getHtmlToInject (opts: InjectionOpts & SecurityOpts) { case 'full': return inject.full(domainName, { shouldInjectDocumentDomain, + cspNonce, }) case 'fullCrossOrigin': return inject.fullCrossOrigin(domainName, { + cspNonce, modifyObstructiveThirdPartyCode, modifyObstructiveCode, simulatedCookies, @@ -55,6 +59,7 @@ function getHtmlToInject (opts: InjectionOpts & SecurityOpts) { case 'partial': return inject.partial(domainName, { shouldInjectDocumentDomain, + cspNonce, }) default: return diff --git a/packages/proxy/lib/types.ts b/packages/proxy/lib/types.ts index 8c0020ebb62f..8343b6640d70 100644 --- a/packages/proxy/lib/types.ts +++ b/packages/proxy/lib/types.ts @@ -37,6 +37,7 @@ export type CypressWantsInjection = 'full' | 'fullCrossOrigin' | 'partial' | fal * An outgoing response to an incoming request to the Cypress web server. */ export type CypressOutgoingResponse = Response & { + injectionNonce?: string isInitial: null | boolean wantsInjection: CypressWantsInjection wantsSecurityRemoved: null | boolean diff --git a/packages/proxy/test/integration/net-stubbing.spec.ts b/packages/proxy/test/integration/net-stubbing.spec.ts index 60685ec85f86..56f2f38fbedd 100644 --- a/packages/proxy/test/integration/net-stubbing.spec.ts +++ b/packages/proxy/test/integration/net-stubbing.spec.ts @@ -72,9 +72,35 @@ context('network stubbing', () => { destinationApp.get('/', (req, res) => res.send('it worked')) + destinationApp.get('/csp-header-none', (req, res) => { + const headerName = req.query.headerName + + res.setHeader('content-type', 'text/html') + res.setHeader(headerName, 'fake-directive fake-value') + res.send('bar') + }) + + destinationApp.get('/csp-header-single', (req, res) => { + const headerName = req.query.headerName + + res.setHeader('content-type', 'text/html') + res.setHeader(headerName, 'script-src \'self\' localhost') + res.send('bar') + }) + + destinationApp.get('/csp-header-multiple', (req, res) => { + const headerName = req.query.headerName + + res.setHeader('content-type', 'text/html') + res.setHeader(headerName, ['default-src \'self\'', 'script-src \'self\' localhost']) + res.send('bar') + }) + server = allowDestroy(destinationApp.listen(() => { destinationPort = server.address().port remoteStates.set(`http://localhost:${destinationPort}`) + remoteStates.set(`http://localhost:${destinationPort}/csp-header`) + remoteStates.set(`http://localhost:${destinationPort}/csp-header-multiple`) done() })) }) @@ -285,4 +311,103 @@ context('network stubbing', () => { expect(sendContentLength).to.eq(receivedContentLength) expect(sendContentLength).to.eq(realContentLength) }) + + describe('CSP Headers', () => { + // Loop through valid CSP header names can verify that we handle them + [ + 'content-security-policy', + 'Content-Security-Policy', + 'content-security-policy-report-only', + 'Content-Security-Policy-Report-Only', + ].forEach((headerName) => { + describe(`${headerName}`, () => { + it('does not add CSP header if injecting JS and original response had no CSP header', () => { + netStubbingState.routes.push({ + id: '1', + routeMatcher: { + url: '*', + }, + hasInterceptor: false, + staticResponse: { + body: 'bar', + }, + getFixture: async () => {}, + matches: 1, + }) + + return supertest(app) + .get(`/http://localhost:${destinationPort}`) + .set('Accept', 'text/html,application/xhtml+xml') + .then((res) => { + expect(res.headers[headerName]).to.be.undefined + expect(res.headers[headerName.toLowerCase()]).to.be.undefined + }) + }) + + it('does not modify CSP header if not injecting JS and original response had CSP header', () => { + return supertest(app) + .get(`/http://localhost:${destinationPort}/csp-header-none?headerName=${headerName}`) + .then((res) => { + expect(res.headers[headerName.toLowerCase()]).to.equal('fake-directive fake-value') + }) + }) + + it('does not modify a CSP header if injecting JS and original response had CSP header, but did not have a directive affecting script-src', () => { + return supertest(app) + .get(`/http://localhost:${destinationPort}/csp-header-none?headerName=${headerName}`) + .set('Accept', 'text/html,application/xhtml+xml') + .then((res) => { + expect(res.headers[headerName.toLowerCase()]).to.equal('fake-directive fake-value') + }) + }) + + it('modifies a CSP header if injecting JS and original response had CSP header affecting script-src', () => { + return supertest(app) + .get(`/http://localhost:${destinationPort}/csp-header-single?headerName=${headerName}`) + .set('Accept', 'text/html,application/xhtml+xml') + .then((res) => { + expect(res.headers[headerName.toLowerCase()]).to.match(/^script-src 'self' localhost 'nonce-[^-A-Za-z0-9+/=]|=[^=]|={3,}'$/) + }) + }) + + it('modifies CSP header if injecting JS and original response had multiple CSP headers and directives', () => { + return supertest(app) + .get(`/http://localhost:${destinationPort}/csp-header-multiple?headerName=${headerName}`) + .set('Accept', 'text/html,application/xhtml+xml') + .then((res) => { + expect(res.headers[headerName.toLowerCase()]).to.match(/^default-src 'self' 'nonce-[^-A-Za-z0-9+/=]|=[^=]|={3,}', script-src 'self' localhost 'nonce-[^-A-Za-z0-9+/=]|=[^=]|={3,}'$/) + }) + }) + + if (headerName !== headerName.toLowerCase()) { + // Do not add a non-lowercase version of a CSP header, because most-restrictive is used + it('removes non-lowercase CSP header to avoid conflicts on unmodified CSP headers', () => { + return supertest(app) + .get(`/http://localhost:${destinationPort}/csp-header?headerName=${headerName}`) + .then((res) => { + expect(res.headers[headerName]).to.be.undefined + }) + }) + + it('removes non-lowercase CSP header to avoid conflicts on modified CSP headers', () => { + return supertest(app) + .get(`/http://localhost:${destinationPort}/csp-header?headerName=${headerName}`) + .set('Accept', 'text/html,application/xhtml+xml') + .then((res) => { + expect(res.headers[headerName]).to.be.undefined + }) + }) + + it('removes non-lowercase CSP header to avoid conflicts on multiple CSP headers', () => { + return supertest(app) + .get(`/http://localhost:${destinationPort}/csp-header-multiple?headerName=${headerName}`) + .set('Accept', 'text/html,application/xhtml+xml') + .then((res) => { + expect(res.headers[headerName]).to.be.undefined + }) + }) + } + }) + }) + }) }) diff --git a/packages/proxy/test/unit/http/response-middleware.spec.ts b/packages/proxy/test/unit/http/response-middleware.spec.ts index efed881d6d5f..feda77ac4d4b 100644 --- a/packages/proxy/test/unit/http/response-middleware.spec.ts +++ b/packages/proxy/test/unit/http/response-middleware.spec.ts @@ -1,12 +1,14 @@ -import _ from 'lodash' +import * as rewriter from '../../../lib/http/util/rewriter' + +import { Readable } from 'stream' +import { RemoteStates } from '@packages/server/lib/remote_states' import ResponseMiddleware from '../../../lib/http/response-middleware' +import _ from 'lodash' import { debugVerbose } from '../../../lib/http' import { expect } from 'chai' +import { nonceDirectives } from '../../../lib/http/util/csp-header' import sinon from 'sinon' import { testMiddleware } from './helpers' -import { RemoteStates } from '@packages/server/lib/remote_states' -import { Readable } from 'stream' -import * as rewriter from '../../../lib/http/util/rewriter' describe('http/response-middleware', function () { it('exports the members in the correct order', function () { @@ -15,8 +17,8 @@ describe('http/response-middleware', function () { 'AttachPlainTextStreamFn', 'InterceptResponse', 'PatchExpressSetHeader', - 'SetInjectionLevel', 'OmitProblematicHeaders', + 'SetInjectionLevel', 'MaybePreventCaching', 'MaybeStripDocumentDomainFeaturePolicy', 'MaybeCopyCookiesFromIncomingRes', @@ -387,6 +389,321 @@ describe('http/response-middleware', function () { }) }) + describe('modify CSP headers', () => { + // Loop through valid CSP header names to verify that we handle them + [ + 'content-security-policy', + 'Content-Security-Policy', + 'content-security-policy-report-only', + 'Content-Security-Policy-Report-Only', + ].forEach((headerName) => { + describe(`${headerName}`, () => { + nonceDirectives.forEach((validNonceDirectiveName) => { + it(`modifies existing "${validNonceDirectiveName}" directive for "${headerName}" header if injection is requested, header exists, and "${validNonceDirectiveName}" directive exists`, () => { + prepareContext({ + res: { + wantsInjection: 'full', + }, + config: { + stripCspDirectives: false, + }, + incomingRes: { + headers: { + [`${headerName}`]: `fake-csp-directive fake-csp-value; ${validNonceDirectiveName} \'fake-src\'`, + }, + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.setHeader).to.be.calledWith(headerName.toLowerCase(), + [sinon.match(new RegExp(`^fake-csp-directive fake-csp-value; ${validNonceDirectiveName} 'fake-src' 'nonce-[^-A-Za-z0-9+/=]|=[^=]|={3,}'$`))]) + }) + }) + + it(`modifies all existing "${validNonceDirectiveName}" directives for "${headerName}" header if injection is requested, and multiple headers exist with "${validNonceDirectiveName}" directives`, () => { + prepareContext({ + res: { + wantsInjection: 'full', + }, + config: { + stripCspDirectives: false, + }, + incomingRes: { + headers: { + [`${headerName}`]: `fake-csp-directive-0 fake-csp-value-0; ${validNonceDirectiveName} \'fake-src-0\',${validNonceDirectiveName} \'fake-src-1\'`, + }, + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.setHeader).to.be.calledWith(headerName.toLowerCase(), + [ + sinon.match(new RegExp(`^fake-csp-directive-0 fake-csp-value-0; ${validNonceDirectiveName} 'fake-src-0' 'nonce-[^-A-Za-z0-9+/=]|=[^=]|={3,}'$`)), + sinon.match(new RegExp(`^${validNonceDirectiveName} 'fake-src-1' 'nonce-[^-A-Za-z0-9+/=]|=[^=]|={3,}'$`)), + ]) + }) + }) + + nonceDirectives.filter((directive) => directive !== validNonceDirectiveName).forEach((otherNonceDirective) => { + it(`modifies existing "${otherNonceDirective}" directive for "${headerName}" header if injection is requested, header exists, and "${validNonceDirectiveName}" directive exists`, () => { + prepareContext({ + res: { + wantsInjection: 'full', + }, + config: { + stripCspDirectives: false, + }, + incomingRes: { + headers: { + [`${headerName}`]: `${validNonceDirectiveName} \'self\'; fake-csp-directive fake-csp-value; ${otherNonceDirective} \'fake-src\'`, + }, + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.setHeader).to.be.calledWith(headerName.toLowerCase(), + [sinon.match(new RegExp(`^${validNonceDirectiveName} 'self' 'nonce-[^-A-Za-z0-9+/=]|=[^=]|={3,}'; fake-csp-directive fake-csp-value; ${otherNonceDirective} 'fake-src' 'nonce-[^-A-Za-z0-9+/=]|=[^=]|={3,}'$`))]) + }) + }) + + it(`modifies existing "${otherNonceDirective}" directive for "${headerName}" header if injection is requested, header exists, and "${validNonceDirectiveName}" directive exists in a different header`, () => { + prepareContext({ + res: { + wantsInjection: 'full', + }, + config: { + stripCspDirectives: false, + }, + incomingRes: { + headers: { + [`${headerName}`]: `${validNonceDirectiveName} \'self\',fake-csp-directive fake-csp-value; ${otherNonceDirective} \'fake-src\'`, + }, + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.setHeader).to.be.calledWith(headerName.toLowerCase(), + [ + sinon.match(new RegExp(`^${validNonceDirectiveName} 'self' 'nonce-[^-A-Za-z0-9+/=]|=[^=]|={3,}'`)), + sinon.match(new RegExp(`^fake-csp-directive fake-csp-value; ${otherNonceDirective} 'fake-src' 'nonce-[^-A-Za-z0-9+/=]|=[^=]|={3,}'$`)), + ]) + }) + }) + }) + }) + + it(`does not append script-src directive in "${headerName}" headers if injection is requested, header exists, but no valid directive exists`, () => { + prepareContext({ + res: { + wantsInjection: 'full', + }, + config: { + stripCspDirectives: false, + }, + incomingRes: { + headers: { + [`${headerName}`]: 'fake-csp-directive fake-csp-value;', + }, + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.setHeader).to.be.calledWith(headerName.toLowerCase(), + ['fake-csp-directive fake-csp-value']) + }) + }) + + it(`does not append script-src directive in "${headerName}" headers if injection is requested, and multiple headers exists, but no valid directive exists`, () => { + prepareContext({ + res: { + wantsInjection: 'full', + }, + config: { + stripCspDirectives: false, + }, + incomingRes: { + headers: { + [`${headerName}`]: 'fake-csp-directive-0 fake-csp-value-0,fake-csp-directive-1 fake-csp-value-1', + }, + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.setHeader).to.be.calledWith(headerName.toLowerCase(), + [ + 'fake-csp-directive-0 fake-csp-value-0', + 'fake-csp-directive-1 fake-csp-value-1', + ]) + }) + }) + + it(`does not modify "${headerName}" header if full injection is requested, and header does not exist`, () => { + prepareContext({ + res: { + wantsInjection: 'full', + }, + config: { + stripCspDirectives: false, + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.setHeader).not.to.be.calledWith(headerName, sinon.match.array) + expect(ctx.res.setHeader).not.to.be.calledWith(headerName.toLowerCase(), sinon.match.array) + }) + }) + + it(`does not modify "${headerName}" header when no injection is requested, and header exists`, () => { + prepareContext({ + res: { + wantsInjection: false, + }, + config: { + stripCspDirectives: false, + }, + incomingRes: { + headers: { + [`${headerName}`]: 'fake-csp-directive fake-csp-value', + }, + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.setHeader).not.to.be.calledWith(headerName, sinon.match.array) + expect(ctx.res.setHeader).not.to.be.calledWith(headerName.toLowerCase(), ['fake-csp-directive fake-csp-value']) + }) + }) + + it(`removes all instances of "frame-ancestors" directive from "${headerName}" headers if injection is requested and stripCspDirectives is "false"`, () => { + prepareContext({ + res: { + wantsInjection: 'full', + }, + config: { + stripCspDirectives: false, + }, + incomingRes: { + headers: { + [`${headerName}`]: 'frame-ancestors \'none\'; fake-csp-directive-0 fake-csp-value-0,frame-ancestors \'self\'; fake-csp-directive-1 fake-csp-value-1', + }, + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.setHeader).to.be.calledWith(headerName.toLowerCase(), [ + 'fake-csp-directive-0 fake-csp-value-0', + 'fake-csp-directive-1 fake-csp-value-1', + ]) + }) + }) + + it(`removes all instances of "frame-ancestors" directive from "${headerName}" headers if injection is requested and stripCspDirectives is "frame-ancestors"`, () => { + prepareContext({ + res: { + wantsInjection: 'full', + }, + config: { + stripCspDirectives: 'frame-ancestors', + }, + incomingRes: { + headers: { + [`${headerName}`]: 'frame-ancestors \'none\'; fake-csp-directive-0 fake-csp-value-0,frame-ancestors \'self\'; fake-csp-directive-1 fake-csp-value-1', + }, + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.setHeader).to.be.calledWith(headerName.toLowerCase(), [ + 'fake-csp-directive-0 fake-csp-value-0', + 'fake-csp-directive-1 fake-csp-value-1', + ]) + }) + }) + + it(`removes all instances of "frame-ancestors" directive from "${headerName}" headers if injection is requested and stripCspDirectives is an array including "frame-ancestors"`, () => { + prepareContext({ + res: { + wantsInjection: 'full', + }, + config: { + stripCspDirectives: ['fake-csp-directive-0', 'frame-ancestors'], + }, + incomingRes: { + headers: { + [`${headerName}`]: 'frame-ancestors \'none\'; fake-csp-directive-0 fake-csp-value-0,frame-ancestors \'self\'; fake-csp-directive-1 fake-csp-value-1', + }, + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.setHeader).to.be.calledWith(headerName.toLowerCase(), [ + 'fake-csp-directive-1 fake-csp-value-1', + ]) + }) + }) + + it(`does not remove instances of "frame-ancestors" directive from "${headerName}" headers if injection is requested and stripCspDirectives is a string not equal to "frame-ancestors"`, () => { + prepareContext({ + res: { + wantsInjection: 'full', + }, + config: { + stripCspDirectives: 'fake-csp-directive-0', + }, + incomingRes: { + headers: { + [`${headerName}`]: 'frame-ancestors \'none\'; fake-csp-directive-0 fake-csp-value-0,frame-ancestors \'self\'; fake-csp-directive-1 fake-csp-value-1', + }, + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.setHeader).to.be.calledWith(headerName.toLowerCase(), [ + 'frame-ancestors \'none\'', + 'frame-ancestors \'self\'; fake-csp-directive-1 fake-csp-value-1', + ]) + }) + }) + }) + + it(`does not remove instances of "frame-ancestors" directive from "${headerName}" headers if injection is requested and stripCspDirectives is an array not containing "frame-ancestors"`, () => { + prepareContext({ + res: { + wantsInjection: 'full', + }, + config: { + stripCspDirectives: ['fake-csp-directive-0', 'fake-csp-directive-1'], + }, + incomingRes: { + headers: { + [`${headerName}`]: 'frame-ancestors \'none\'; fake-csp-directive-0 fake-csp-value-0,frame-ancestors \'self\'; fake-csp-directive-1 fake-csp-value-1', + }, + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.setHeader).to.be.calledWith(headerName.toLowerCase(), [ + 'frame-ancestors \'none\'', + 'frame-ancestors \'self\'', + ]) + }) + }) + }) + }) + describe('wantsSecurityRemoved', () => { it('removes security if full injection is requested', () => { prepareContext({ @@ -1419,6 +1736,7 @@ describe('http/response-middleware', function () { .then(() => { expect(htmlStub).to.be.calledOnce expect(htmlStub).to.be.calledWith('foo', { + 'cspNonce': undefined, 'deferSourceMapRewrite': undefined, 'domainName': 'foobar.com', 'isNotJavascript': true, @@ -1443,6 +1761,7 @@ describe('http/response-middleware', function () { .then(() => { expect(htmlStub).to.be.calledOnce expect(htmlStub).to.be.calledWith('foo', { + 'cspNonce': undefined, 'deferSourceMapRewrite': undefined, 'domainName': '127.0.0.1', 'isNotJavascript': true, @@ -1475,6 +1794,7 @@ describe('http/response-middleware', function () { .then(() => { expect(htmlStub).to.be.calledOnce expect(htmlStub).to.be.calledWith('foo', { + 'cspNonce': undefined, 'deferSourceMapRewrite': undefined, 'domainName': 'foobar.com', 'isNotJavascript': true, @@ -1490,6 +1810,37 @@ describe('http/response-middleware', function () { }) }) + it('cspNonce is set to the value stored in res.injectionNonce', function () { + prepareContext({ + req: { + proxiedUrl: 'http://www.foobar.com:3501/primary-origin.html', + }, + res: { + injectionNonce: 'fake-nonce', + }, + simulatedCookies: [], + }) + + return testMiddleware([MaybeInjectHtml], ctx) + .then(() => { + expect(htmlStub).to.be.calledOnce + expect(htmlStub).to.be.calledWith('foo', { + 'cspNonce': 'fake-nonce', + 'deferSourceMapRewrite': undefined, + 'domainName': 'foobar.com', + 'isNotJavascript': true, + 'modifyObstructiveCode': true, + 'modifyObstructiveThirdPartyCode': true, + 'shouldInjectDocumentDomain': true, + 'url': 'http://www.foobar.com:3501/primary-origin.html', + 'useAstSourceRewriting': undefined, + 'wantsInjection': 'full', + 'wantsSecurityRemoved': true, + 'simulatedCookies': [], + }) + }) + }) + function prepareContext (props) { const remoteStates = new RemoteStates(() => {}) const stream = Readable.from(['foo']) diff --git a/packages/proxy/test/unit/http/util/csp-header.spec.ts b/packages/proxy/test/unit/http/util/csp-header.spec.ts new file mode 100644 index 000000000000..efb8465f0df8 --- /dev/null +++ b/packages/proxy/test/unit/http/util/csp-header.spec.ts @@ -0,0 +1,165 @@ +import { generateCspDirectives, hasCspHeader, parseCspHeaders } from '../../../../lib/http/util/csp-header' + +import { expect } from 'chai' + +const patchedHeaders = [ + 'content-security-policy', + 'Content-Security-Policy', + 'content-security-policy-report-only', + 'Content-Security-Policy-Report-Only', +] + +const cspDirectiveValues = { + 'base-uri': ['', ' '], + 'block-all-mixed-content': [undefined], + 'child-src': ['', ' '], + 'connect-src': ['', ' '], + 'default-src': ['', ' '], + 'font-src': ['', ' '], + 'form-action': ['', ' '], + 'frame-ancestors': ['\'none\'', '\'self\'', '', ' '], + 'frame-src': ['', ' '], + 'img-src': ['', ' '], + 'manifest-src': ['', ' '], + 'media-src': ['', ' '], + 'object-src': ['', ' '], + 'plugin-types': ['/', '/ /'], + 'prefetch-src': ['', ' '], + 'referrer': [''], + 'report-to': [''], + 'report-uri': ['', ' '], + 'require-trusted-types-for': ['\'script\''], + 'sandbox': [undefined, 'allow-downloads', 'allow-downloads-without-user-activation', 'allow-forms', 'allow-modals', 'allow-orientation-lock', 'allow-pointer-lock', 'allow-popups', 'allow-popups-to-escape-sandbox', 'allow-presentation', 'allow-same-origin', 'allow-scripts', 'allow-storage-access-by-user-activation', 'allow-top-navigation', 'allow-top-navigation-by-user-activation', 'allow-top-navigation-to-custom-protocols'], + 'script-src': ['', ' '], + 'script-src-attr': ['', ' '], + 'script-src-elem': ['', ' '], + 'style-src': ['', ' '], + 'style-src-attr': ['', ' '], + 'style-src-elem': ['', ' '], + 'trusted-types': ['none', '', ' \'allow-duplicates\''], + 'upgrade-insecure-requests': [undefined], + 'worker-src': ['', ' '], +} + +describe('http/util/csp-header', () => { + describe('hasCspHeader', () => { + patchedHeaders.forEach((headerName) => { + it(`should be \`true\` for a CSP header using "${headerName}"`, () => { + expect(hasCspHeader({ + [`${headerName}`]: 'fake-csp-directive fake-csp-value;', + }, headerName)).equal(true) + }) + + it(`should be \`true\` for a CSP header using multiple "${headerName}" headers`, () => { + expect(hasCspHeader({ + [`${headerName}`]: 'fake-csp-directive-0 fake-csp-value-0;,fake-csp-directive-1 fake-csp-value-1;', + }, headerName)).equal(true) + }) + + it('should be `false` when a CSP header is not present', () => { + expect(hasCspHeader({ + 'Content-Type': 'fake-content-type', + }, headerName)).equal(false) + }) + }) + }) + + describe('parseCspHeader', () => { + patchedHeaders.forEach((headerName) => { + it(`should parse a CSP header using "${headerName}"`, () => { + const policyArray = parseCspHeaders({ + 'Content-Type': 'fake-content-type', + [`${headerName}`]: 'fake-csp-directive fake-csp-value;', + }, headerName) + + expect(policyArray.length).to.equal(1) + policyArray.forEach((policyMap) => { + expect(policyMap.get('fake-csp-directive')).to.have.members(['fake-csp-value']) + }, headerName) + }) + + it(`should parse a CSP header using multiple "${headerName}" headers`, () => { + const policyArray = parseCspHeaders({ + 'Content-Type': 'fake-content-type', + [`${headerName}`]: 'fake-csp-directive-0 fake-csp-value-0,fake-csp-directive-1 fake-csp-value-1', + }, headerName) + + expect(policyArray.length).to.equal(2) + policyArray.forEach((policyMap, idx) => { + expect(policyMap.get(`fake-csp-directive-${idx}`)).to.have.members([`fake-csp-value-${idx}`]) + }, headerName) + }) + + it(`should strip a CSP header of all directives specified in the "excludeDirectives" argument for single "${headerName}" headers`, () => { + const policyArray = parseCspHeaders({ + 'Content-Type': 'fake-content-type', + [`${headerName}`]: 'fake-csp-directive-0 fake-csp-value-0;fake-csp-directive-1 fake-csp-value-1', + }, headerName, ['fake-csp-directive-0']) + + expect(policyArray.length).to.equal(1) + policyArray.forEach((policyMap) => { + expect(policyMap.has(`fake-csp-directive-0`)).to.equal(false) + expect(policyMap.get(`fake-csp-directive-1`)).to.have.members([`fake-csp-value-1`]) + }) + }) + + it(`should strip a CSP header of all directives specified in the "excludeDirectives" argument for multiple "${headerName}" headers`, () => { + const policyArray = parseCspHeaders({ + 'Content-Type': 'fake-content-type', + [`${headerName}`]: 'fake-csp-directive-0 fake-csp-value-0,fake-csp-directive-1 fake-csp-value-1', + }, headerName, ['fake-csp-directive-0']) + + expect(policyArray.length).to.equal(2) + policyArray.forEach((policyMap, idx) => { + if (idx === 0) { + expect(policyMap.has(`fake-csp-directive-0`)).to.equal(false) + } else { + expect(policyMap.get(`fake-csp-directive-1`)).to.have.members([`fake-csp-value-1`]) + } + }) + }) + + describe(`Valid CSP Directives`, () => { + Object.entries(cspDirectiveValues).forEach(([directive, values]) => { + values.forEach((value) => { + it(`should parse a CSP header using "${headerName}" with a valid "${directive}" directive for "${value}"`, () => { + const policyArray = parseCspHeaders({ + 'Content-Type': 'fake-content-type', + [`${headerName}`]: `${directive}${value === undefined ? '' : ` ${value}`}`, + }, headerName) + + expect(policyArray.length).to.equal(1) + policyArray.forEach((policyMap) => { + expect(policyMap.has(directive)).to.equal(true) + expect(policyMap.get(directive)).to.have.members(value === undefined ? [] : `${value}`.split(' ')) + }, headerName) + }) + + it(`should strip a CSP header using "${headerName}" with a valid "${directive}" directive for "${value}" if the directive is excluded`, () => { + const policyArray = parseCspHeaders({ + 'Content-Type': 'fake-content-type', + [`${headerName}`]: `${directive}${value === undefined ? '' : ` ${value}`}`, + }, headerName, [directive]) + + expect(policyArray.length).to.equal(1) + policyArray.forEach((policyMap) => { + expect(policyMap.has(directive)).to.equal(false) + }, headerName) + }) + }) + }) + }) + }) + }) + + describe('generateCspDirectives', () => { + it(`should generate a CSP directive string from a policy map`, () => { + const policyMap = new Map() + + policyMap.set('fake-csp-directive', ['\'self\'', 'unsafe-inline', 'fake-csp-value']) + policyMap.set('default', ['\'self\'']) + + expect(generateCspDirectives(policyMap)).equal('fake-csp-directive \'self\' unsafe-inline fake-csp-value; default \'self\'') + }) + }) +}) diff --git a/packages/server/test/integration/http_requests_spec.js b/packages/server/test/integration/http_requests_spec.js index f42b567b1418..8a0687b37299 100644 --- a/packages/server/test/integration/http_requests_spec.js +++ b/packages/server/test/integration/http_requests_spec.js @@ -34,6 +34,7 @@ const { getRunnerInjectionContents } = require(`@packages/resolve-dist`) const { createRoutes } = require(`../../lib/routes`) const { getCtx } = require(`../../lib/makeDataContext`) const dedent = require('dedent') +const { unsupportedCSPDirectives } = require('@packages/proxy/lib/http/util/csp-header') zlib = Promise.promisifyAll(zlib) @@ -1754,7 +1755,7 @@ describe('Routes', () => { }) }) - it('omits content-security-policy', function () { + it('omits content-security-policy by default', function () { nock(this.server.remoteStates.current().origin) .get('/bar') .reply(200, 'OK', { @@ -1775,7 +1776,7 @@ describe('Routes', () => { }) }) - it('omits content-security-policy-report-only', function () { + it('omits content-security-policy-report-only by default', function () { nock(this.server.remoteStates.current().origin) .get('/bar') .reply(200, 'OK', { @@ -1961,6 +1962,316 @@ describe('Routes', () => { }) }) + describe('CSP Header', () => { + describe('provided', () => { + describe('stripCspDirectives: true', () => { + beforeEach(function () { + return this.setup('http://localhost:8080', { + config: { + stripCspDirectives: true, + }, + }) + }) + + it('strips all CSP headers for text/html content-type when "stripCspDirectives" is "true"', function () { + nock(this.server.remoteStates.current().origin) + .get('/bar') + .reply(200, 'OK', { + 'Content-Type': 'text/html', + 'content-security-policy': 'foo \'bar\'', + }) + + return this.rp({ + url: 'http://localhost:8080/bar', + headers: { + 'Cookie': '__cypress.initial=false', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }, + }) + .then((res) => { + expect(res.statusCode).to.eq(200) + expect(res.headers).not.to.have.property('content-security-policy') + }) + }) + }) + + describe('stripCspDirectives: false', () => { + beforeEach(function () { + return this.setup('http://localhost:8080', { + config: { + stripCspDirectives: false, + }, + }) + }) + + it('does not append a "script-src" nonce to CSP header for text/html content-type when no valid directive exists', function () { + nock(this.server.remoteStates.current().origin) + .get('/bar') + .reply(200, 'OK', { + 'Content-Type': 'text/html', + 'content-security-policy': 'foo \'bar\'', + }) + + return this.rp({ + url: 'http://localhost:8080/bar', + headers: { + 'Cookie': '__cypress.initial=false', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }, + }) + .then((res) => { + expect(res.statusCode).to.eq(200) + expect(res.headers).to.have.property('content-security-policy') + expect(res.headers['content-security-policy']).to.match(/^foo 'bar'$/) + }) + }) + + it('appends a nonce to existing CSP header directive "script-src-elem" for text/html content-type when in CSP header', function () { + nock(this.server.remoteStates.current().origin) + .get('/bar') + .reply(200, 'OK', { + 'Content-Type': 'text/html', + 'content-security-policy': 'foo \'bar\'; script-src-elem \'fake-src\';', + }) + + return this.rp({ + url: 'http://localhost:8080/bar', + headers: { + 'Cookie': '__cypress.initial=false', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }, + }) + .then((res) => { + expect(res.statusCode).to.eq(200) + expect(res.headers).to.have.property('content-security-policy') + expect(res.headers['content-security-policy']).to.match(/^foo 'bar'; script-src-elem 'fake-src' 'nonce-[^-A-Za-z0-9+/=]|=[^=]|={3,}';$/) + }) + }) + + it('appends a nonce to existing CSP header directive "script-src" for text/html content-type when in CSP header', function () { + nock(this.server.remoteStates.current().origin) + .get('/bar') + .reply(200, 'OK', { + 'Content-Type': 'text/html', + 'content-security-policy': 'foo \'bar\'; script-src \'fake-src\';', + }) + + return this.rp({ + url: 'http://localhost:8080/bar', + headers: { + 'Cookie': '__cypress.initial=false', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }, + }) + .then((res) => { + expect(res.statusCode).to.eq(200) + expect(res.headers).to.have.property('content-security-policy') + expect(res.headers['content-security-policy']).to.match(/^foo 'bar'; script-src 'fake-src' 'nonce-[^-A-Za-z0-9+/=]|=[^=]|={3,}';$/) + }) + }) + + it('appends a nonce to existing CSP header directive "default-src" for text/html content-type when in CSP header', function () { + nock(this.server.remoteStates.current().origin) + .get('/bar') + .reply(200, 'OK', { + 'Content-Type': 'text/html', + 'content-security-policy': 'foo \'bar\'; default-src \'fake-src\';', + }) + + return this.rp({ + url: 'http://localhost:8080/bar', + headers: { + 'Cookie': '__cypress.initial=false', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }, + }) + .then((res) => { + expect(res.statusCode).to.eq(200) + expect(res.headers).to.have.property('content-security-policy') + expect(res.headers['content-security-policy']).to.match(/^foo 'bar'; default-src 'fake-src' 'nonce-[^-A-Za-z0-9+/=]|=[^=]|={3,}';$/) + }) + }) + + it('appends a nonce to both CSP header directive "script-src" and "default-src" for text/html content-type when in CSP header when both exist', function () { + nock(this.server.remoteStates.current().origin) + .get('/bar') + .reply(200, 'OK', { + 'Content-Type': 'text/html', + 'content-security-policy': 'foo \'bar\'; script-src \'fake-src\'; default-src \'fake-src\';', + }) + + return this.rp({ + url: 'http://localhost:8080/bar', + headers: { + 'Cookie': '__cypress.initial=false', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }, + }) + .then((res) => { + expect(res.statusCode).to.eq(200) + expect(res.headers).to.have.property('content-security-policy') + expect(res.headers['content-security-policy']).to.match(/^foo 'bar'; script-src 'fake-src' 'nonce-[^-A-Za-z0-9+/=]|=[^=]|={3,}'; default-src 'fake-src' 'nonce-[^-A-Za-z0-9+/=]|=[^=]|={3,}';$/) + }) + }) + + it('appends a nonce to all valid CSP header directives for text/html content-type when in CSP header', function () { + nock(this.server.remoteStates.current().origin) + .get('/bar') + .reply(200, 'OK', { + 'Content-Type': 'text/html', + 'content-security-policy': 'foo \'bar\'; script-src-elem \'fake-src\'; script-src \'fake-src\'; default-src \'fake-src\';', + }) + + return this.rp({ + url: 'http://localhost:8080/bar', + headers: { + 'Cookie': '__cypress.initial=false', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }, + }) + .then((res) => { + expect(res.statusCode).to.eq(200) + expect(res.headers).to.have.property('content-security-policy') + expect(res.headers['content-security-policy']).to.match(/^foo 'bar'; script-src-elem 'fake-src' 'nonce-[^-A-Za-z0-9+/=]|=[^=]|={3,}'; script-src 'fake-src' 'nonce-[^-A-Za-z0-9+/=]|=[^=]|={3,}'; default-src 'fake-src' 'nonce-[^-A-Za-z0-9+/=]|=[^=]|={3,}';$/) + }) + }) + + it('does not remove original CSP header for text/html content-type', function () { + nock(this.server.remoteStates.current().origin) + .get('/bar') + .reply(200, 'OK', { + 'Content-Type': 'text/html', + 'content-security-policy': 'foo \'bar\'', + }) + + return this.rp({ + url: 'http://localhost:8080/bar', + headers: { + 'Cookie': '__cypress.initial=false', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }, + }) + .then((res) => { + expect(res.statusCode).to.eq(200) + expect(res.headers).to.have.property('content-security-policy') + expect(res.headers['content-security-policy']).to.match(/foo 'bar'/) + }) + }) + + it('does not append a nonce to CSP header if request is not for html', function () { + nock(this.server.remoteStates.current().origin) + .get('/bar') + .reply(200, 'OK', { + 'Content-Type': 'application/json', + 'content-security-policy': 'foo \'bar\'', + }) + + return this.rp({ + url: 'http://localhost:8080/bar', + headers: { + 'Cookie': '__cypress.initial=false', + }, + }) + .then((res) => { + expect(res.statusCode).to.eq(200) + expect(res.headers).to.have.property('content-security-policy') + expect(res.headers['content-security-policy']).not.to.match(/script-src 'nonce-[^-A-Za-z0-9+/=]|=[^=]|={3,}'/) + }) + }) + + it('does not remove original CSP header if request is not for html', function () { + nock(this.server.remoteStates.current().origin) + .get('/bar') + .reply(200, 'OK', { + 'Content-Type': 'application/json', + 'content-security-policy': 'foo \'bar\'', + }) + + return this.rp({ + url: 'http://localhost:8080/bar', + headers: { + 'Cookie': '__cypress.initial=false', + }, + }) + .then((res) => { + expect(res.statusCode).to.eq(200) + expect(res.headers).to.have.property('content-security-policy') + expect(res.headers['content-security-policy']).to.match(/^foo 'bar'$/) + }) + }) + + // The following directives are not supported by Cypress and should be stripped + unsupportedCSPDirectives.forEach((directive) => { + const headerValue = `${directive} 'none'` + + it(`removes the "${directive}" CSP directive for text/html content-type`, function () { + nock(this.server.remoteStates.current().origin) + .get('/bar') + .reply(200, 'OK', { + 'Content-Type': 'text/html', + 'content-security-policy': `foo \'bar\'; ${headerValue};`, + }) + + return this.rp({ + url: 'http://localhost:8080/bar', + headers: { + 'Cookie': '__cypress.initial=false', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }, + }) + .then((res) => { + expect(res.statusCode).to.eq(200) + expect(res.headers).to.have.property('content-security-policy') + expect(res.headers['content-security-policy']).to.match(/^foo 'bar'/) + expect(res.headers['content-security-policy']).not.to.match(new RegExp(headerValue)) + }) + }) + }) + }) + }) + + describe('not provided', () => { + it('does not append a nonce to CSP header for text/html content-type', function () { + nock(this.server.remoteStates.current().origin) + .get('/bar') + .reply(200, 'OK', { + 'Content-Type': 'text/html', + }) + + return this.rp({ + url: 'http://localhost:8080/bar', + headers: { + 'Cookie': '__cypress.initial=false', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + }, + }) + .then((res) => { + expect(res.statusCode).to.eq(200) + expect(res.headers).not.to.have.property('content-security-policy') + }) + }) + + it('does not append a nonce to CSP header if request is not for html', function () { + nock(this.server.remoteStates.current().origin) + .get('/bar') + .reply(200, 'OK', { + 'Content-Type': 'application/json', + }) + + return this.rp({ + url: 'http://localhost:8080/bar', + headers: { + 'Cookie': '__cypress.initial=false', + }, + }) + .then((res) => { + expect(res.statusCode).to.eq(200) + expect(res.headers).not.to.have.property('content-security-policy') + }) + }) + }) + }) + context('authorization', () => { it('attaches auth headers when matches origin', function () { const username = 'u'