diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 1597ec1e6707..31b72ee2ac7d 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -6,6 +6,7 @@ _Released 06/20/2023 (PENDING)_ **Features:** - Added support for running Cypress tests with [Chrome's new `--headless=new` flag](https://developer.chrome.com/articles/new-headless/). Chrome versions 112 and above will now be run in the `headless` mode that matches the `headed` browser implementation. Addresses [#25972](https://github.com/cypress-io/cypress/issues/25972). +- Cypress can now test pages with targeted `Content-Security-Policy` and `Content-Security-Policy-Report-Only` header directives by specifying the allow list via the [`experimentalCspAllowList`](https://docs.cypress.io/guides/references/configuration#Experimental-Csp-Allow-List) configuration option. Addresses [#1030](https://github.com/cypress-io/cypress/issues/1030). Addressed in [#26483](https://github.com/cypress-io/cypress/pull/26483) - The [`videoCompression`](https://docs.cypress.io/guides/references/configuration#Videos) configuration option now accepts both a boolean or a Constant Rate Factor (CRF) number between `1` and `51`. The `videoCompression` default value is still `32` CRF and when `videoCompression` is set to `true` the default of `32` CRF will be used. Addresses [#26658](https://github.com/cypress-io/cypress/issues/26658). **Bugfixes:** diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index 804895976be1..c59a94f1f7c8 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -2672,6 +2672,8 @@ declare namespace Cypress { force: boolean } + type experimentalCspAllowedDirectives = 'default-src' | 'child-src' | 'frame-src' | 'script-src' | 'script-src-elem' | 'form-action' + type scrollBehaviorOptions = false | 'center' | 'top' | 'bottom' | 'nearest' /** @@ -3051,6 +3053,19 @@ declare namespace Cypress { * @default 'top' */ scrollBehavior: scrollBehaviorOptions + /** + * Indicates whether Cypress should allow CSP header directives from the application under test. + * - When this option is set to `false`, Cypress will strip the entire CSP header. + * - When this option is set to `true`, Cypress will only to strip directives that would interfere + * with or inhibit Cypress functionality. + * - When this option to an array of allowable directives (`[ 'default-src', ... ]`), the directives + * specified will remain in the response headers. + * + * Please see the documentation for more information. + * @see https://on.cypress.io/configuration#experimentalCspAllowList + * @default false + */ + experimentalCspAllowList: boolean | experimentalCspAllowedDirectives[], /** * Allows listening to the `before:run`, `after:run`, `before:spec`, and `after:spec` events in the plugins file during interactive mode. * @default false diff --git a/packages/app/cypress.config.ts b/packages/app/cypress.config.ts index b505c98ee97e..adb652b825cc 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', }, + experimentalCspAllowList: false, experimentalInteractiveRunEvents: true, component: { experimentalSingleTabRunMode: true, diff --git a/packages/app/cypress/fixtures/debug-Failing/gql-CloudViewerAndProject_RequiredData.json b/packages/app/cypress/fixtures/debug-Failing/gql-CloudViewerAndProject_RequiredData.json index cbc94dc8e9aa..df9bf17111fe 100644 --- a/packages/app/cypress/fixtures/debug-Failing/gql-CloudViewerAndProject_RequiredData.json +++ b/packages/app/cypress/fixtures/debug-Failing/gql-CloudViewerAndProject_RequiredData.json @@ -83,6 +83,11 @@ "from": "default", "field": "execTimeout" }, + { + "value": false, + "from": "default", + "field": "experimentalCspAllowList" + }, { "value": false, "from": "default", diff --git a/packages/app/cypress/fixtures/debug-Failing/gql-HeaderBar_HeaderBarQuery.json b/packages/app/cypress/fixtures/debug-Failing/gql-HeaderBar_HeaderBarQuery.json index 2dfc59d03100..c9c094137b66 100644 --- a/packages/app/cypress/fixtures/debug-Failing/gql-HeaderBar_HeaderBarQuery.json +++ b/packages/app/cypress/fixtures/debug-Failing/gql-HeaderBar_HeaderBarQuery.json @@ -59,6 +59,11 @@ "from": "default", "field": "execTimeout" }, + { + "value": false, + "from": "default", + "field": "experimentalCspAllowList" + }, { "value": false, "from": "default", diff --git a/packages/app/cypress/fixtures/debug-Failing/gql-SpecsPageContainer.json b/packages/app/cypress/fixtures/debug-Failing/gql-SpecsPageContainer.json index d0907f4ffbf2..9ffbedf671a2 100644 --- a/packages/app/cypress/fixtures/debug-Failing/gql-SpecsPageContainer.json +++ b/packages/app/cypress/fixtures/debug-Failing/gql-SpecsPageContainer.json @@ -444,6 +444,11 @@ "from": "default", "field": "execTimeout" }, + { + "value": false, + "from": "default", + "field": "experimentalCspAllowList" + }, { "value": false, "from": "default", diff --git a/packages/app/cypress/fixtures/debug-Passing/gql-CloudViewerAndProject_RequiredData.json b/packages/app/cypress/fixtures/debug-Passing/gql-CloudViewerAndProject_RequiredData.json index 582bab644481..1f982a1f7a95 100644 --- a/packages/app/cypress/fixtures/debug-Passing/gql-CloudViewerAndProject_RequiredData.json +++ b/packages/app/cypress/fixtures/debug-Passing/gql-CloudViewerAndProject_RequiredData.json @@ -83,6 +83,11 @@ "from": "default", "field": "execTimeout" }, + { + "value": false, + "from": "default", + "field": "experimentalCspAllowList" + }, { "value": false, "from": "default", diff --git a/packages/app/cypress/fixtures/debug-Passing/gql-HeaderBar_HeaderBarQuery.json b/packages/app/cypress/fixtures/debug-Passing/gql-HeaderBar_HeaderBarQuery.json index 2dfc59d03100..c9c094137b66 100644 --- a/packages/app/cypress/fixtures/debug-Passing/gql-HeaderBar_HeaderBarQuery.json +++ b/packages/app/cypress/fixtures/debug-Passing/gql-HeaderBar_HeaderBarQuery.json @@ -59,6 +59,11 @@ "from": "default", "field": "execTimeout" }, + { + "value": false, + "from": "default", + "field": "experimentalCspAllowList" + }, { "value": false, "from": "default", diff --git a/packages/app/cypress/fixtures/debug-Passing/gql-SpecsPageContainer.json b/packages/app/cypress/fixtures/debug-Passing/gql-SpecsPageContainer.json index 2398607a62b6..81ff604e8629 100644 --- a/packages/app/cypress/fixtures/debug-Passing/gql-SpecsPageContainer.json +++ b/packages/app/cypress/fixtures/debug-Passing/gql-SpecsPageContainer.json @@ -1445,6 +1445,11 @@ "from": "default", "field": "execTimeout" }, + { + "value": false, + "from": "default", + "field": "experimentalCspAllowList" + }, { "value": false, "from": "default", diff --git a/packages/config/__snapshots__/index.spec.ts.js b/packages/config/__snapshots__/index.spec.ts.js index 51e0c8b4f839..ee999fa83308 100644 --- a/packages/config/__snapshots__/index.spec.ts.js +++ b/packages/config/__snapshots__/index.spec.ts.js @@ -34,6 +34,7 @@ exports['config/src/index .getDefaultValues returns list of public config keys 1 }, 'env': {}, 'execTimeout': 60000, + 'experimentalCspAllowList': false, 'experimentalFetchPolyfill': false, 'experimentalInteractiveRunEvents': false, 'experimentalRunAllSpecs': false, @@ -121,6 +122,7 @@ exports['config/src/index .getDefaultValues returns list of public config keys f }, 'env': {}, 'execTimeout': 60000, + 'experimentalCspAllowList': false, 'experimentalFetchPolyfill': false, 'experimentalInteractiveRunEvents': false, 'experimentalRunAllSpecs': false, @@ -204,6 +206,7 @@ exports['config/src/index .getPublicConfigKeys returns list of public config key 'e2e', 'env', 'execTimeout', + 'experimentalCspAllowList', 'experimentalFetchPolyfill', 'experimentalInteractiveRunEvents', 'experimentalRunAllSpecs', diff --git a/packages/config/__snapshots__/validation.spec.ts.js b/packages/config/__snapshots__/validation.spec.ts.js index ebd24b7fcfa9..ace0c97230bd 100644 --- a/packages/config/__snapshots__/validation.spec.ts.js +++ b/packages/config/__snapshots__/validation.spec.ts.js @@ -225,6 +225,31 @@ exports['config/src/validation .isStringOrFalse returns error message when value 'type': 'a string or false', } +exports['not an array error message'] = { + 'key': 'fakeKey', + 'value': 'fakeValue', + 'type': 'an array including any of these values: [true, false]', +} + +exports['not a subset of error message'] = { + 'key': 'fakeKey', + 'value': [ + null, + ], + 'type': 'an array including any of these values: ["fakeValue", "fakeValue1", "fakeValue2"]', +} + +exports['not all in subset error message'] = { + 'key': 'fakeKey', + 'value': [ + 'fakeValue', + 'fakeValue1', + 'fakeValue2', + 'fakeValue3', + ], + 'type': 'an array including any of these values: ["fakeValue", "fakeValue1", "fakeValue2"]', +} + exports['invalid lower bound'] = { 'key': 'test', 'value': -1, diff --git a/packages/config/src/options.ts b/packages/config/src/options.ts index 66eab7fa18f3..3128d671f7f4 100644 --- a/packages/config/src/options.ts +++ b/packages/config/src/options.ts @@ -198,6 +198,12 @@ const driverConfigOptions: Array = [ defaultValue: 60000, validation: validate.isNumber, overrideLevel: 'any', + }, { + name: 'experimentalCspAllowList', + defaultValue: false, + validation: validate.validateAny(validate.isBoolean, validate.isArrayIncludingAny('script-src-elem', 'script-src', 'default-src', 'form-action', 'child-src', 'frame-src')), + overrideLevel: 'never', + requireRestartOnChange: 'server', }, { name: 'experimentalFetchPolyfill', defaultValue: false, diff --git a/packages/config/src/validation.ts b/packages/config/src/validation.ts index 49f2f7eeb420..1cbcb34c71b1 100644 --- a/packages/config/src/validation.ts +++ b/packages/config/src/validation.ts @@ -33,7 +33,7 @@ const _isFullyQualifiedUrl = (value: any): ErrResult | boolean => { return _.isString(value) && /^https?\:\/\//.test(value) } -const isArrayOfStrings = (value: any): ErrResult | boolean => { +const isStringArray = (value: any): ErrResult | boolean => { return _.isArray(value) && _.every(value, _.isString) } @@ -41,6 +41,21 @@ 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) + } +} + /** * 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. @@ -148,6 +163,29 @@ export const isOneOf = (...values: any[]): ((key: string, value: any) => ErrResu } } +/** + * Checks if given array value for a key includes only members of the provided values. + * @example + ``` + validate = v.isArrayIncludingAny("foo", "bar", "baz") + validate("example", ["foo"]) // true + validate("example", ["bar", "baz"]) // true + validate("example", ["foo", "else"]) // error message string + validate("example", ["foo", "bar", "baz", "else"]) // error message string + ``` + */ +export const isArrayIncludingAny = (...values: any[]): ((key: string, value: any) => ErrResult | true) => { + const validValues = values.map((a) => str(a)).join(', ') + + return (key, value) => { + if (!Array.isArray(value) || !value.every((v) => values.includes(v))) { + return errMsg(key, value, `an array including any of these values: [${validValues}]`) + } + + return true + } +} + /** * Validates whether the supplied set of cert information is valid * @returns {string|true} Returns `true` if the information set is valid. Returns an error message if it is not. @@ -332,7 +370,7 @@ export function isFullyQualifiedUrl (key: string, value: any): ErrResult | true } export function isStringOrArrayOfStrings (key: string, value: any): ErrResult | true { - if (_.isString(value) || isArrayOfStrings(value)) { + if (_.isString(value) || isStringArray(value)) { return true } @@ -340,7 +378,7 @@ export function isStringOrArrayOfStrings (key: string, value: any): ErrResult | } export function isNullOrArrayOfStrings (key: string, value: any): ErrResult | true { - if (_.isNull(value) || isArrayOfStrings(value)) { + if (_.isNull(value) || isStringArray(value)) { return true } diff --git a/packages/config/test/project/utils.spec.ts b/packages/config/test/project/utils.spec.ts index 87c713d06e4c..d76d8f239aa2 100644 --- a/packages/config/test/project/utils.spec.ts +++ b/packages/config/test/project/utils.spec.ts @@ -859,6 +859,34 @@ describe('config/src/project/utils', () => { }) }) + it('experimentalCspAllowList=false', function () { + return this.defaults('experimentalCspAllowList', false) + }) + + it('experimentalCspAllowList=true', function () { + return this.defaults('experimentalCspAllowList', true, { + experimentalCspAllowList: true, + }) + }) + + it('experimentalCspAllowList=[]', function () { + return this.defaults('experimentalCspAllowList', [], { + experimentalCspAllowList: [], + }) + }) + + it('experimentalCspAllowList=default-src|script-src', function () { + return this.defaults('experimentalCspAllowList', ['default-src', 'script-src'], { + experimentalCspAllowList: ['default-src', 'script-src'], + }) + }) + + it('experimentalCspAllowList=["default-src","script-src"]', function () { + return this.defaults('experimentalCspAllowList', ['default-src', 'script-src'], { + experimentalCspAllowList: ['default-src', 'script-src'], + }) + }) + it('resets numTestsKeptInMemory to 0 when runMode', function () { return mergeDefaults({ projectRoot: '/foo/bar/', supportFile: false }, { isTextTerminal: true }, {}, this.getFilesByGlob) .then((cfg) => { @@ -1053,6 +1081,7 @@ describe('config/src/project/utils', () => { execTimeout: { value: 60000, from: 'default' }, experimentalModifyObstructiveThirdPartyCode: { value: false, from: 'default' }, experimentalSkipDomainInjection: { value: null, from: 'default' }, + experimentalCspAllowList: { value: false, from: 'default' }, experimentalFetchPolyfill: { value: false, from: 'default' }, experimentalInteractiveRunEvents: { value: false, from: 'default' }, experimentalMemoryManagement: { value: false, from: 'default' }, @@ -1150,6 +1179,7 @@ describe('config/src/project/utils', () => { execTimeout: { value: 60000, from: 'default' }, experimentalModifyObstructiveThirdPartyCode: { value: false, from: 'default' }, experimentalSkipDomainInjection: { value: null, from: 'default' }, + experimentalCspAllowList: { value: false, from: 'default' }, experimentalFetchPolyfill: { value: false, from: 'default' }, experimentalInteractiveRunEvents: { value: false, from: 'default' }, experimentalMemoryManagement: { value: false, from: 'default' }, diff --git a/packages/config/test/validation.spec.ts b/packages/config/test/validation.spec.ts index 36bc180d5575..05af3d8d8d44 100644 --- a/packages/config/test/validation.spec.ts +++ b/packages/config/test/validation.spec.ts @@ -6,6 +6,39 @@ import * as validation from '../src/validation' describe('config/src/validation', () => { const mockKey = 'mockConfigKey' + 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 validatePass1 = validation.validateAny((k, v) => `${value}`, (k, v) => true) + + expect(validatePass1(key, value)).to.equal(true) + + const validatePass2 = validation.validateAny((k, v) => true, (k, v) => `${value}`) + + expect(validatePass2(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') @@ -389,6 +422,54 @@ describe('config/src/validation', () => { }) }) + describe('.isArrayIncludingAny', () => { + it('returns new validation function that accepts 2 arguments', () => { + const validate = validation.isArrayIncludingAny(true, false) + + expect(validate).to.be.a.instanceof(Function) + expect(validate.length).to.eq(2) + }) + + it('returned validation function will return true when value is a subset of the provided values', () => { + const value = 'fakeValue' + const key = 'fakeKey' + const validatePass1 = validation.isArrayIncludingAny(true, false) + + expect(validatePass1(key, [false])).to.equal(true) + + const validatePass2 = validation.isArrayIncludingAny(value, value + 1, value + 2) + + expect(validatePass2(key, [value])).to.equal(true) + }) + + it('returned validation function will fail if values is not an array', () => { + const value = 'fakeValue' + const key = 'fakeKey' + const validateFail = validation.isArrayIncludingAny(true, false) + + let msg = validateFail(key, value) + + expect(msg).to.not.be.true + snapshot('not an array error message', msg) + }) + + it('returned validation function will fail if any values are not present in the provided values', () => { + const value = 'fakeValue' + const key = 'fakeKey' + const validateFail = validation.isArrayIncludingAny(value, value + 1, value + 2) + + let msg = validateFail(key, [null]) + + expect(msg).to.not.be.true + snapshot('not a subset of error message', msg) + + msg = validateFail(key, [value, value + 1, value + 2, value + 3]) + + expect(msg).to.not.be.true + snapshot('not all in subset error message', msg) + }) + }) + describe('.isValidCrfOrBoolean', () => { it('validates booleans', () => { const validate = validation.isValidCrfOrBoolean diff --git a/packages/driver/cypress/e2e/e2e/csp_headers.cy.js b/packages/driver/cypress/e2e/e2e/csp_headers.cy.js new file mode 100644 index 000000000000..d6ca6a1974ea --- /dev/null +++ b/packages/driver/cypress/e2e/e2e/csp_headers.cy.js @@ -0,0 +1,30 @@ +describe('csp-headers', () => { + it('content-security-policy headers are always stripped', () => { + const route = '/fixtures/empty.html' + + cy.intercept(route, (req) => { + req.continue((res) => { + res.headers['content-security-policy'] = `script-src http://not-here.net;` + }) + }) + + cy.visit(route) + .wait(1000) + + // Next verify that inline scripts are allowed, because if they aren't, the CSP header is not getting stripped + const inlineId = `__${Math.random()}` + + cy.window().then((win) => { + expect(() => { + return win.eval(` + var script = document.createElement('script'); + script.textContent = "window['${inlineId}'] = '${inlineId}'"; + document.head.appendChild(script); + `) + }).not.to.throw() // CSP should be stripped, so this should not throw + + // Inline script should have created the var + expect(win[`${inlineId}`]).to.equal(`${inlineId}`, 'CSP Headers are being stripped') + }) + }) +}) diff --git a/packages/driver/cypress/e2e/e2e/origin/commands/waiting.cy.ts b/packages/driver/cypress/e2e/e2e/origin/commands/waiting.cy.ts index 20d62ed5771e..66571c82f965 100644 --- a/packages/driver/cypress/e2e/e2e/origin/commands/waiting.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/commands/waiting.cy.ts @@ -74,7 +74,7 @@ context('cy.origin waiting', { browser: '!webkit' }, () => { cy.intercept('/foo', (req) => { // delay the response to ensure the wait will wait for response req.reply({ - delay: 100, + delay: 200, body: response, }) }).as('foo') diff --git a/packages/frontend-shared/cypress/fixtures/config.json b/packages/frontend-shared/cypress/fixtures/config.json index 22b42486130a..cdaec6de852f 100644 --- a/packages/frontend-shared/cypress/fixtures/config.json +++ b/packages/frontend-shared/cypress/fixtures/config.json @@ -82,6 +82,11 @@ "from": "default", "field": "execTimeout" }, + { + "value": false, + "from": "default", + "field": "experimentalCspAllowList" + }, { "value": false, "from": "default", diff --git a/packages/frontend-shared/src/locales/en-US.json b/packages/frontend-shared/src/locales/en-US.json index 8e78ad0c5fd0..d964c64cd5ae 100644 --- a/packages/frontend-shared/src/locales/en-US.json +++ b/packages/frontend-shared/src/locales/en-US.json @@ -544,6 +544,10 @@ "experiments": { "title": "Experiments", "description": "If you'd like to try out new features that we're working on, you can enable beta features for your project by turning on the experimental features you'd like to try. {0}", + "experimentalCspAllowList": { + "name": "CSP Allow List", + "description": "Enables Cypress to selectively permit Content-Security-Policy and Content-Security-Policy-Report-Only header directives, including those that might otherwise block Cypress from running." + }, "experimentalFetchPolyfill": { "name": "Fetch polyfill", "description": "Automatically replaces `window.fetch` with a polyfill that Cypress can spy on and stub. Note: `experimentalFetchPolyfill` has been deprecated in Cypress 6.0.0 and will be removed in a future release. Consider using [`cy.intercept()`](https://on.cypress.io/intercept) to intercept `fetch` requests instead." diff --git a/packages/proxy/lib/http/response-middleware.ts b/packages/proxy/lib/http/response-middleware.ts index 2eaf17e71ddc..d4f6bb36d784 100644 --- a/packages/proxy/lib/http/response-middleware.ts +++ b/packages/proxy/lib/http/response-middleware.ts @@ -1,4 +1,5 @@ import charset from 'charset' +import crypto from 'crypto' import iconv from 'iconv-lite' import _ from 'lodash' import { PassThrough, Readable } from 'stream' @@ -19,6 +20,8 @@ import type { CypressIncomingRequest, CypressOutgoingResponse } from '@packages/ import type { HttpMiddleware, HttpMiddlewareThis } from '.' import type { IncomingMessage, IncomingHttpHeaders } from 'http' +import { cspHeaderNames, generateCspDirectives, nonceDirectives, parseCspHeaders, problematicCspDirectives, unsupportedCSPDirectives } from './util/csp-header' + export interface ResponseMiddlewareProps { /** * Before using `res.incomingResStream`, `prepareResStream` can be used @@ -345,6 +348,41 @@ 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') + + // 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 + cspHeaderNames.forEach((headerName) => { + const policyArray = parseCspHeaders(this.res.getHeaders(), headerName) + const usedNonceDirectives = nonceDirectives + // 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) => policyArray.some((policyMap) => policyMap.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 + const modifiedCspHeader = policyArray.map((policies) => { + usedNonceDirectives.forEach((availableNonceDirective) => { + if (policies.has(availableNonceDirective)) { + const cspScriptSrc = policies.get(availableNonceDirective) || [] + + // We are mutating the policy map, and we will set it back to the response headers later + policies.set(availableNonceDirective, [...cspScriptSrc, `'nonce-${nonce}'`]) + } + }) + + return policies + }).map(generateCspDirectives) + + // To replicate original response CSP headers, we must apply all header values as an array + this.res.setHeader(headerName, modifiedCspHeader) + } + }) } this.res.wantsSecurityRemoved = (this.config.modifyObstructiveCode || this.config.experimentalModifyObstructiveThirdPartyCode) && @@ -403,13 +441,39 @@ const OmitProblematicHeaders: ResponseMiddleware = function () { 'x-frame-options', 'content-length', 'transfer-encoding', - 'content-security-policy', - 'content-security-policy-report-only', 'connection', ]) this.res.set(headers) + if (this.config.experimentalCspAllowList) { + const allowedDirectives = this.config.experimentalCspAllowList === true ? [] : this.config.experimentalCspAllowList as Cypress.experimentalCspAllowedDirectives[] + + // If the user has specified CSP directives to allow, we must not remove them from the CSP headers + const stripDirectives = [...unsupportedCSPDirectives, ...problematicCspDirectives.filter((directive) => !allowedDirectives.includes(directive))] + + // Iterate through each CSP header + cspHeaderNames.forEach((headerName) => { + const modifiedCspHeaders = parseCspHeaders(this.incomingRes.headers, headerName, stripDirectives) + .map(generateCspDirectives) + .filter(Boolean) + + if (modifiedCspHeaders.length === 0) { + // If there are no CSP policies after stripping directives, we will remove it from the response + // Altering the CSP headers using the native response header methods is case-insensitive + 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) + } + }) + } else { + cspHeaderNames.forEach((headerName) => { + // Altering the CSP headers using the native response header methods is case-insensitive + this.res.removeHeader(headerName) + }) + } + this.next() } @@ -634,6 +698,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, @@ -735,8 +800,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..94bc14dc1a27 --- /dev/null +++ b/packages/proxy/lib/http/util/csp-header.ts @@ -0,0 +1,127 @@ +import type { OutgoingHttpHeaders } from 'http' + +const cspRegExp = /[; ]*([^\n\r; ]+) ?([^\n\r;]+)*/g + +export const cspHeaderNames = ['content-security-policy', 'content-security-policy-report-only'] as const + +export const nonceDirectives = ['script-src-elem', 'script-src', 'default-src'] + +export const problematicCspDirectives = [ + ...nonceDirectives, + 'child-src', 'frame-src', 'form-action', +] as Cypress.experimentalCspAllowedDirectives[] + +export const unsupportedCSPDirectives = [ + /** + * In order for Cypress to run content in an iframe, we must remove the `frame-ancestors` directive + * from the CSP header. This is because this directive behaves like the `X-Frame-Options='deny'` header + * and prevents the iframe content from being loaded if it detects that it is not being loaded in the + * top-level frame. + */ + 'frame-ancestors', + /** + * The `navigate-to` directive is not yet fully supported, so we are erring on the side of caution + */ + 'navigate-to', + /** + * The `sandbox` directive seems to affect all iframes on the page, even if the page is a direct child of Cypress + */ + 'sandbox', + /** + * Since Cypress might modify the DOM of the application under test, `trusted-types` would prevent the + * DOM injection from occurring. + */ + 'trusted-types', + 'require-trusted-types-for', +] + +const caseInsensitiveGetAllHeaders = (headers: OutgoingHttpHeaders, lowercaseProperty: string): string[] => { + return Object.entries(headers).reduce((acc: string[], [key, value]) => { + if (key.toLowerCase() === lowercaseProperty) { + // It's possible to set more than 1 CSP header, and in those instances CSP headers + // are NOT merged by the browser. Instead, the most **restrictive** CSP header + // that applies to the given resource will be used. + // https://www.w3.org/TR/CSP2/#content-security-policy-header-field + // + // Therefore, we need to return each header as it's own value so we can apply + // injection nonce values to each one, because we don't know which will be + // the most restrictive. + acc.push.apply( + acc, + `${value}`.split(',') + .filter(Boolean) + .map((policyString) => `${policyString}`.trim()), + ) + } + + return acc + }, []) +} + +function getCspHeaders (headers: OutgoingHttpHeaders, headerName: string = 'content-security-policy'): string[] { + return caseInsensitiveGetAllHeaders(headers, headerName.toLowerCase()) +} + +/** + * Parses the provided headers object and returns an array of policy Map objects. + * This will parse all CSP headers that match the provided `headerName` parameter, + * even if they are not lower case. + * @param headers - The headers object to parse + * @param headerName - The name of the header to parse. Defaults to `content-security-policy` + * @param excludeDirectives - An array of directives to exclude from the returned policy maps + * @returns An array of policy Map objects + * + * @example + * const policyMaps = parseCspHeaders({ + * 'Content-Security-Policy': 'default-src self; script-src self https://www.google-analytics.com', + * 'content-security-policy': 'default-src self; script-src https://www.mydomain.com', + * }) + * // policyMaps = [ + * // Map { + * // 'default-src' => [ 'self' ], + * // 'script-src' => [ 'self', 'https://www.google-analytics.com' ] + * // }, + * // Map { + * // 'default-src' => [ 'self' ], + * // 'script-src' => [ 'https://www.mydomain.com' ] + * // } + * // ] + */ +export function parseCspHeaders (headers: OutgoingHttpHeaders, headerName: string = 'content-security-policy', excludeDirectives: string[] = []): Map[] { + 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] + }, []) +} + +/** + * Generates a CSP header string from the provided policy map. + * @param policies - The policy map to generate the CSP header string from + * @returns A CSP header policy string + * @example + * const policyString = generateCspHeader(new Map([ + * ['default-src', ['self']], + * ['script-src', ['self', 'https://www.google-analytics.com']], + * ])) + * // policyString = 'default-src self; script-src self https://www.google-analytics.com' + */ +export function generateCspDirectives (policies: Map): string { + 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..7046657b4e93 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 { @@ -11,6 +12,12 @@ interface FullCrossOriginOpts { simulatedCookies: SerializableAutomationCookie[] } +function injectCspNonce (options: InjectionOpts) { + const { cspNonce } = options + + return cspNonce ? ` nonce="${cspNonce}"` : '' +} + export function partial (domain, options: InjectionOpts) { let documentDomainInjection = `document.domain = '${domain}';` @@ -21,7 +28,7 @@ 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` - ` @@ -36,7 +43,7 @@ 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..6437cdb429c4 100644 --- a/packages/proxy/test/integration/net-stubbing.spec.ts +++ b/packages/proxy/test/integration/net-stubbing.spec.ts @@ -28,7 +28,10 @@ context('network stubbing', () => { let socket beforeEach((done) => { - config = {} + config = { + experimentalCspAllowList: false, + } + remoteStates = new RemoteStates(() => {}) socket = new EventEmitter() socket.toDriver = sinon.stub() @@ -72,9 +75,48 @@ context('network stubbing', () => { destinationApp.get('/', (req, res) => res.send('it worked')) + destinationApp.get('/csp-header-strip', (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-none', (req, res) => { + const headerName = req.query.headerName + + proxy.http.config.experimentalCspAllowList = true + 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 + + proxy.http.config.experimentalCspAllowList = ['script-src'] + res.setHeader('content-type', 'text/html') + res.setHeader(headerName, ['default-src \'self\'', 'script-src \'self\' localhost']) + res.send('bar') + }) + + destinationApp.get('/csp-header-multiple', (req, res) => { + const headerName = req.query.headerName + + proxy.http.config.experimentalCspAllowList = ['script-src', 'default-src'] + 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-strip`) + remoteStates.set(`http://localhost:${destinationPort}/csp-header-none`) + remoteStates.set(`http://localhost:${destinationPort}/csp-header-single`) + remoteStates.set(`http://localhost:${destinationPort}/csp-header-multiple`) done() })) }) @@ -285,4 +327,121 @@ 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('removes CSP header by default if not injecting JS and original response had CSP header', () => { + return supertest(app) + .get(`/http://localhost:${destinationPort}/csp-header-strip?headerName=${headerName}`) + .then((res) => { + expect(res.headers[headerName]).to.be.undefined + expect(res.headers[headerName.toLowerCase()]).to.be.undefined + }) + }) + + it('removes CSP header by default if injecting JS and original response had CSP header', () => { + return supertest(app) + .get(`/http://localhost:${destinationPort}/csp-header-strip?headerName=${headerName}`) + .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-none?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-single?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..52829fad3414 100644 --- a/packages/proxy/test/unit/http/response-middleware.spec.ts +++ b/packages/proxy/test/unit/http/response-middleware.spec.ts @@ -7,6 +7,7 @@ import { testMiddleware } from './helpers' import { RemoteStates } from '@packages/server/lib/remote_states' import { Readable } from 'stream' import * as rewriter from '../../../lib/http/util/rewriter' +import { nonceDirectives, problematicCspDirectives, unsupportedCSPDirectives } from '../../../lib/http/util/csp-header' describe('http/response-middleware', function () { it('exports the members in the correct order', function () { @@ -15,8 +16,8 @@ describe('http/response-middleware', function () { 'AttachPlainTextStreamFn', 'InterceptResponse', 'PatchExpressSetHeader', - 'SetInjectionLevel', 'OmitProblematicHeaders', + 'SetInjectionLevel', 'MaybePreventCaching', 'MaybeStripDocumentDomainFeaturePolicy', 'MaybeCopyCookiesFromIncomingRes', @@ -187,6 +188,7 @@ describe('http/response-middleware', function () { ctx = { res: { + getHeaders: () => headers, set: sinon.stub(), removeHeader: sinon.stub(), on: (event, listener) => {}, @@ -199,6 +201,203 @@ describe('http/response-middleware', function () { } }) + describe('OmitProblematicHeaders', function () { + const { OmitProblematicHeaders } = ResponseMiddleware + let ctx + + [ + 'set-cookie', + 'x-frame-options', + 'content-length', + 'transfer-encoding', + 'connection', + ].forEach((prop) => { + it(`always removes "${prop}" from incoming headers`, function () { + prepareContext({ [prop]: 'foo' }) + + return testMiddleware([OmitProblematicHeaders], ctx) + .then(() => { + expect(ctx.res.set).to.be.calledWith(sinon.match(function (actual) { + return actual[prop] === undefined + })) + }) + }) + }) + + const validCspHeaderNames = [ + 'content-security-policy', + 'Content-Security-Policy', + 'content-security-policy-report-only', + 'Content-Security-Policy-Report-Only', + ] + + unsupportedCSPDirectives.forEach((directive) => { + validCspHeaderNames.forEach((headerName) => { + it(`always removes "${directive}" directive from "${headerName}" headers 'when experimentalCspAllowList is true`, () => { + prepareContext({ + [`${headerName}`]: `${directive} 'fake-csp-${directive}-value'; fake-csp-directive fake-csp-value`, + }, { + experimentalCspAllowList: true, + }) + + return testMiddleware([OmitProblematicHeaders], ctx) + .then(() => { + expect(ctx.res.setHeader).to.be.calledWith(headerName.toLowerCase(), [ + 'fake-csp-directive fake-csp-value', + ]) + }) + }) + + it(`always removes "${directive}" from "${headerName}" headers when experimentalCspAllowList is an empty array`, () => { + prepareContext({ + [`${headerName}`]: `${directive} 'fake-csp-${directive}-value'; fake-csp-directive fake-csp-value`, + }, { + experimentalCspAllowList: [], + }) + + return testMiddleware([OmitProblematicHeaders], ctx) + .then(() => { + expect(ctx.res.setHeader).to.be.calledWith(headerName.toLowerCase(), [ + 'fake-csp-directive fake-csp-value', + ]) + }) + }) + + it(`always removes "${directive}" from "${headerName}" headers when experimentalCspAllowList is an array including "${directive}"`, () => { + prepareContext({ + [`${headerName}`]: `${directive} 'fake-csp-${directive}-value'; fake-csp-directive fake-csp-value`, + }, { + experimentalCspAllowList: [`${directive}`], + }) + + return testMiddleware([OmitProblematicHeaders], ctx) + .then(() => { + expect(ctx.res.setHeader).to.be.calledWith(headerName.toLowerCase(), [ + 'fake-csp-directive fake-csp-value', + ]) + }) + }) + }) + }) + + validCspHeaderNames.forEach((headerName) => { + it(`removes "${headerName}" headers when experimentalCspAllowList is false`, () => { + prepareContext({ + [`${headerName}`]: `fake-csp-directive fake-csp-value`, + }, { + experimentalCspAllowList: false, + }) + + return testMiddleware([OmitProblematicHeaders], ctx) + .then(() => { + expect(ctx.res.removeHeader).to.be.calledWith(headerName.toLowerCase()) + }) + }) + }) + + validCspHeaderNames.forEach((headerName) => { + it(`will not remove invalid problematicCspDirectives directives provided from "${headerName}" headers when experimentalCspAllowList is an array of directives`, () => { + prepareContext({ + [`${headerName}`]: `fake-csp-directive-0 fake-csp-value-0; fake-csp-directive-1 fake-csp-value-1; fake-csp-directive-2 fake-csp-value-2`, + }, { + experimentalCspAllowList: ['fake-csp-directive-1'], + }) + + return testMiddleware([OmitProblematicHeaders], 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; fake-csp-directive-2 fake-csp-value-2', + ]) + }) + }) + }) + + validCspHeaderNames.forEach((headerName) => { + problematicCspDirectives.forEach((directive) => { + it(`will allow problematicCspDirectives provided from "${headerName}" headers when experimentalCspAllowList is an array including "${directive}"`, () => { + prepareContext({ + [`${headerName}`]: `fake-csp-directive fake-csp-value; ${directive} fake-csp-${directive}-value`, + }, { + experimentalCspAllowList: [directive], + }) + + return testMiddleware([OmitProblematicHeaders], ctx) + .then(() => { + expect(ctx.res.setHeader).to.be.calledWith(headerName.toLowerCase(), [ + `fake-csp-directive fake-csp-value; ${directive} fake-csp-${directive}-value`, + ]) + }) + }) + + problematicCspDirectives.forEach((otherDirective) => { + if (directive === otherDirective) return + + it(`will still remove other problematicCspDirectives provided from "${headerName}" headers when experimentalCspAllowList is an array including singe directives "${directive}"`, () => { + prepareContext({ + [`${headerName}`]: `${directive} fake-csp-${directive}-value; fake-csp-directive fake-csp-value; ${otherDirective} fake-csp-${otherDirective}-value`, + }, { + experimentalCspAllowList: [directive], + }) + + return testMiddleware([OmitProblematicHeaders], ctx) + .then(() => { + expect(ctx.res.setHeader).to.be.calledWith(headerName.toLowerCase(), [ + `${directive} fake-csp-${directive}-value; fake-csp-directive fake-csp-value`, + ]) + }) + }) + + it(`will allow both problematicCspDirectives provided from "${headerName}" headers when experimentalCspAllowList is an array including multiple directives ["${directive}","${otherDirective}"]`, () => { + prepareContext({ + [`${headerName}`]: `${directive} fake-csp-${directive}-value; fake-csp-directive fake-csp-value; ${otherDirective} fake-csp-${otherDirective}-value`, + }, { + experimentalCspAllowList: [directive, otherDirective], + }) + + return testMiddleware([OmitProblematicHeaders], ctx) + .then(() => { + expect(ctx.res.setHeader).to.be.calledWith(headerName.toLowerCase(), [ + `${directive} fake-csp-${directive}-value; fake-csp-directive fake-csp-value; ${otherDirective} fake-csp-${otherDirective}-value`, + ]) + }) + }) + }) + }) + }) + + function prepareContext (additionalHeaders = {}, config = {}) { + const headers = { + 'content-type': 'text/html', + 'content-length': '123', + 'content-encoding': 'gzip', + 'transfer-encoding': 'chunked', + 'set-cookie': 'foo=bar', + 'x-frame-options': 'DENY', + 'connection': 'keep-alive', + } + + ctx = { + config: { + experimentalCspAllowList: false, + ...config, + }, + incomingRes: { + headers: { + ...headers, + ...additionalHeaders, + }, + }, + res: { + removeHeader: sinon.stub(), + set: sinon.stub(), + setHeader: sinon.stub(), + on: (event, listener) => {}, + off: (event, listener) => {}, + }, + } + } + }) + describe('SetInjectionLevel', function () { const { SetInjectionLevel } = ResponseMiddleware let ctx @@ -387,6 +586,220 @@ describe('http/response-middleware', function () { }) }) + describe('CSP header nonce injection', () => { + // 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: { + getHeaders () { + return { + [`${headerName}`]: `fake-csp-directive fake-csp-value; ${validNonceDirectiveName} \'fake-src\'`, + } + }, + wantsInjection: 'full', + }, + }) + + 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: { + getHeaders () { + return { + [`${headerName}`]: `fake-csp-directive-0 fake-csp-value-0; ${validNonceDirectiveName} \'fake-src-0\',${validNonceDirectiveName} \'fake-src-1\'`, + } + }, + wantsInjection: 'full', + }, + }) + + 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,}'$`)), + ]) + }) + }) + + it(`does not modify existing "${validNonceDirectiveName}" directive for "${headerName}" header if injection is not requested`, () => { + prepareContext({ + res: { + getHeaders () { + return { + [`${headerName}`]: `fake-csp-directive fake-csp-value; ${validNonceDirectiveName} \'fake-src\'`, + } + }, + wantsInjection: 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 existing "${validNonceDirectiveName}" directive for non-csp headers`, () => { + const nonCspHeader = 'Non-Csp-Header' + + prepareContext({ + res: { + getHeaders () { + return { + [`${nonCspHeader}`]: `${validNonceDirectiveName} \'fake-src\'`, + } + }, + wantsInjection: 'full', + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + expect(ctx.res.setHeader).not.to.be.calledWith(nonCspHeader, sinon.match.array) + expect(ctx.res.setHeader).not.to.be.calledWith(nonCspHeader.toLowerCase(), sinon.match.array) + }) + }) + + 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: { + getHeaders () { + return { + [`${headerName}`]: `${validNonceDirectiveName} \'self\'; fake-csp-directive fake-csp-value; ${otherNonceDirective} \'fake-src\'`, + } + }, + wantsInjection: 'full', + }, + }) + + 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: { + getHeaders () { + return { + [`${headerName}`]: `${validNonceDirectiveName} \'self\',fake-csp-directive fake-csp-value; ${otherNonceDirective} \'fake-src\'`, + } + }, + wantsInjection: 'full', + }, + }) + + 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: { + getHeaders () { + return { + [`${headerName}`]: 'fake-csp-directive fake-csp-value;', + } + }, + wantsInjection: 'full', + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + // If directive doesn't exist, it shouldn't be updated + 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 append script-src directive in "${headerName}" headers if injection is requested, and multiple headers exists, but no valid directive exists`, () => { + prepareContext({ + res: { + getHeaders: () => { + return { + [`${headerName}`]: 'fake-csp-directive-0 fake-csp-value-0,fake-csp-directive-1 fake-csp-value-1', + } + }, + wantsInjection: 'full', + }, + }) + + return testMiddleware([SetInjectionLevel], ctx) + .then(() => { + // If directive doesn't exist, it shouldn't be updated + 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 if full injection is requested, and header does not exist`, () => { + prepareContext({ + res: { + getHeaders: () => { + return {} + }, + wantsInjection: 'full', + }, + }) + + 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: { + getHeaders: () => { + return { + [`${headerName}`]: 'fake-csp-directive fake-csp-value', + } + }, + wantsInjection: 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) + }) + }) + }) + }) + }) + describe('wantsSecurityRemoved', () => { it('removes security if full injection is requested', () => { prepareContext({ @@ -572,6 +985,9 @@ describe('http/response-middleware', function () { }, res: { headers: {}, + getHeaders: sinon.stub().callsFake(() => { + return ctx.res.headers + }), setHeader: sinon.stub(), on: (event, listener) => {}, off: (event, listener) => {}, @@ -1419,6 +1835,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 +1860,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 +1893,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 +1909,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..3c776f2ae01e --- /dev/null +++ b/packages/proxy/test/unit/http/util/csp-header.spec.ts @@ -0,0 +1,143 @@ +import { generateCspDirectives, 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('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/index.d.ts b/packages/server/index.d.ts index 0d8bada3a755..eb4b7faa288b 100644 --- a/packages/server/index.d.ts +++ b/packages/server/index.d.ts @@ -19,6 +19,7 @@ export namespace CyServer { export interface Config { blockHosts: string | string[] clientRoute: string + experimentalCspAllowList: boolean | Cypress.experimentalCspAllowedDirectives[] experimentalSourceRewriting: boolean modifyObstructiveCode: boolean experimentalModifyObstructiveThirdPartyCode: boolean diff --git a/packages/server/test/integration/http_requests_spec.js b/packages/server/test/integration/http_requests_spec.js index f42b567b1418..257e5510f38f 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,326 @@ describe('Routes', () => { }) }) + describe('CSP Header', () => { + describe('provided', () => { + describe('experimentalCspAllowList: false', () => { + beforeEach(function () { + return this.setup('http://localhost:8080', { + config: { + experimentalCspAllowList: false, + }, + }) + }) + + it('strips all CSP headers for text/html content-type when "experimentalCspAllowList" is false', 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('experimentalCspAllowList: true', () => { + beforeEach(function () { + return this.setup('http://localhost:8080', { + config: { + experimentalCspAllowList: true, + }, + }) + }) + + 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'$/) + }) + }) + }) + + describe('experimentalCspAllowList: ["script-src-element", "script-src", "default-src"]', () => { + beforeEach(function () { + return this.setup('http://localhost:8080', { + config: { + experimentalCspAllowList: ['script-src-elem', 'script-src', 'default-src'], + }, + }) + }) + + 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' diff --git a/packages/server/test/unit/config_spec.js b/packages/server/test/unit/config_spec.js index d517e3f0efb8..5c3fae8fc6f9 100644 --- a/packages/server/test/unit/config_spec.js +++ b/packages/server/test/unit/config_spec.js @@ -572,6 +572,64 @@ describe('lib/config', () => { }) }) + context('experimentalCspAllowList', () => { + const experimentalCspAllowedDirectives = JSON.stringify(['script-src-elem', 'script-src', 'default-src', 'form-action', 'child-src', 'frame-src']).split(',').join(', ') + + it('passes if false', function () { + this.setup({ experimentalCspAllowList: false }) + + return this.expectValidationPasses() + }) + + it('passes if true', function () { + this.setup({ experimentalCspAllowList: true }) + + return this.expectValidationPasses() + }) + + it('fails if string', function () { + this.setup({ experimentalCspAllowList: 'fake-directive' }) + + return this.expectValidationFails(`be an array including any of these values: ${experimentalCspAllowedDirectives}`) + }) + + it('passes if an empty array', function () { + this.setup({ experimentalCspAllowList: [] }) + + return this.expectValidationPasses() + }) + + it('passes if subset of Cypress.experimentalCspAllowedDirectives[]', function () { + this.setup({ experimentalCspAllowList: ['default-src', 'form-action'] }) + + return this.expectValidationPasses() + }) + + it('passes if null', function () { + this.setup({ experimentalCspAllowList: null }) + + return this.expectValidationPasses() + }) + + it('fails if string[]', function () { + this.setup({ experimentalCspAllowList: ['script-src', 'fake-directive-2'] }) + + return this.expectValidationFails(`be an array including any of these values: ${experimentalCspAllowedDirectives}`) + }) + + it('fails if any[]', function () { + this.setup({ experimentalCspAllowList: [true, 'default-src'] }) + + return this.expectValidationFails(`be an array including any of these values: ${experimentalCspAllowedDirectives}`) + }) + + it('fails if not falsy, or subset of Cypress.experimentalCspAllowedDirectives[]', function () { + this.setup({ experimentalCspAllowList: 1 }) + + return this.expectValidationFails(`be an array including any of these values: ${experimentalCspAllowedDirectives}`) + }) + }) + context('supportFile', () => { it('passes if false', function () { this.setup({ e2e: { supportFile: false } }) diff --git a/system-tests/__snapshots__/experimental_csp_allow_list_spec.ts.js b/system-tests/__snapshots__/experimental_csp_allow_list_spec.ts.js new file mode 100644 index 000000000000..7abd6f330b7a --- /dev/null +++ b/system-tests/__snapshots__/experimental_csp_allow_list_spec.ts.js @@ -0,0 +1,354 @@ +exports['e2e experimentalCspAllowList / experimentalCspAllowList=[\'script-src-elem\', \'script-src\', \'default-src\', \'form-action\'] / works with [\'script-src-elem\', \'script-src\', \'default-src\'] directives'] = ` + +==================================================================================================== + + (Run Starting) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Cypress: 1.2.3 │ + │ Browser: FooBrowser 88 │ + │ Specs: 1 found (with_allow_list_custom.cy.ts) │ + │ Searched: cypress/e2e/experimental_csp_allow_list_spec/with_allow_list_custom.cy.ts │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + +──────────────────────────────────────────────────────────────────────────────────────────────────── + + Running: with_allow_list_custom.cy.ts (1 of 1) + + + experimentalCspAllowList=['script-src-elem', 'script-src', 'default-src'] + content-security-policy directive script-src-elem should not be stripped and + ✓ allows Cypress to run, including configured inline nonces/hashes + ✓ allows Cypress to run, but doesn't allow none configured inline scripts + content-security-policy directive script-src should not be stripped and + ✓ allows Cypress to run, including configured inline nonces/hashes + ✓ allows Cypress to run, but doesn't allow none configured inline scripts + content-security-policy directive default-src should not be stripped and + ✓ allows Cypress to run, including configured inline nonces/hashes + ✓ allows Cypress to run, but doesn't allow none configured inline scripts + + + 6 passing + + + (Results) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Tests: 6 │ + │ Passing: 6 │ + │ Failing: 0 │ + │ Pending: 0 │ + │ Skipped: 0 │ + │ Screenshots: 0 │ + │ Video: true │ + │ Duration: X seconds │ + │ Spec Ran: with_allow_list_custom.cy.ts │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + + (Video) + + - Video output: /XXX/XXX/XXX/cypress/videos/with_allow_list_custom.cy.ts.mp4 + + +==================================================================================================== + + (Run Finished) + + + Spec Tests Passing Failing Pending Skipped + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ ✔ with_allow_list_custom.cy.ts XX:XX 6 6 - - - │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + ✔ All specs passed! XX:XX 6 6 - - - + + +` + +exports['e2e experimentalCspAllowList / experimentalCspAllowList=true / strips out [\'script-src-elem\', \'script-src\', \'default-src\', \'form-action\'] directives'] = ` + +==================================================================================================== + + (Run Starting) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Cypress: 1.2.3 │ + │ Browser: FooBrowser 88 │ + │ Specs: 1 found (with_allow_list_true.cy.ts) │ + │ Searched: cypress/e2e/experimental_csp_allow_list_spec/with_allow_list_true.cy.ts │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + +──────────────────────────────────────────────────────────────────────────────────────────────────── + + Running: with_allow_list_true.cy.ts (1 of 1) + + + experimentalCspAllowList=true + ✓ passes on inline form action + content-security-policy directive script-src-elem should be stripped and + ✓ regardless of nonces/hashes + content-security-policy directive script-src should be stripped and + ✓ regardless of nonces/hashes + content-security-policy directive default-src should be stripped and + ✓ regardless of nonces/hashes + + + 4 passing + + + (Results) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Tests: 4 │ + │ Passing: 4 │ + │ Failing: 0 │ + │ Pending: 0 │ + │ Skipped: 0 │ + │ Screenshots: 0 │ + │ Video: true │ + │ Duration: X seconds │ + │ Spec Ran: with_allow_list_true.cy.ts │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + + (Video) + + - Video output: /XXX/XXX/XXX/cypress/videos/with_allow_list_true.cy.ts.mp4 + + +==================================================================================================== + + (Run Finished) + + + Spec Tests Passing Failing Pending Skipped + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ ✔ with_allow_list_true.cy.ts XX:XX 4 4 - - - │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + ✔ All specs passed! XX:XX 4 4 - - - + + +` + +exports['e2e experimentalCspAllowList / experimentalCspAllowList=true / always strips known problematic directives and is passive with known working directives'] = ` + +==================================================================================================== + + (Run Starting) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Cypress: 1.2.3 │ + │ Browser: FooBrowser 88 │ + │ Specs: 1 found (with_allow_list_custom_or_true.cy.ts) │ + │ Searched: cypress/e2e/experimental_csp_allow_list_spec/with_allow_list_custom_or_true.cy.ts │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + +──────────────────────────────────────────────────────────────────────────────────────────────────── + + Running: with_allow_list_custom_or_true.cy.ts (1 of 1) + + + experimentalCspAllowList is custom or true + disallowed + ✓ frame-ancestors are always stripped + ✓ trusted-types & require-trusted-types-for are always stripped + ✓ sandbox is always stripped + ✓ navigate-to is always stripped + allowed + ✓ sample: style-src is not stripped + ✓ sample: upgrade-insecure-requests is not stripped + + + 6 passing + + + (Results) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Tests: 6 │ + │ Passing: 6 │ + │ Failing: 0 │ + │ Pending: 0 │ + │ Skipped: 0 │ + │ Screenshots: 0 │ + │ Video: true │ + │ Duration: X seconds │ + │ Spec Ran: with_allow_list_custom_or_true.cy.ts │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + + (Video) + + - Video output: /XXX/XXX/XXX/cypress/videos/with_allow_list_custom_or_true.cy.ts.mp4 + + +==================================================================================================== + + (Run Finished) + + + Spec Tests Passing Failing Pending Skipped + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ ✔ with_allow_list_custom_or_true.cy.t XX:XX 6 6 - - - │ + │ s │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + ✔ All specs passed! XX:XX 6 6 - - - + + +` + +exports['e2e experimentalCspAllowList / experimentalCspAllowList=[\'script-src-elem\', \'script-src\', \'default-src\', \'form-action\'] / always strips known problematic directives and is passive with known working directives'] = ` + +==================================================================================================== + + (Run Starting) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Cypress: 1.2.3 │ + │ Browser: FooBrowser 88 │ + │ Specs: 1 found (with_allow_list_custom_or_true.cy.ts) │ + │ Searched: cypress/e2e/experimental_csp_allow_list_spec/with_allow_list_custom_or_true.cy.ts │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + +──────────────────────────────────────────────────────────────────────────────────────────────────── + + Running: with_allow_list_custom_or_true.cy.ts (1 of 1) + + + experimentalCspAllowList is custom or true + disallowed + ✓ frame-ancestors are always stripped + ✓ trusted-types & require-trusted-types-for are always stripped + ✓ sandbox is always stripped + ✓ navigate-to is always stripped + allowed + ✓ sample: style-src is not stripped + ✓ sample: upgrade-insecure-requests is not stripped + + + 6 passing + + + (Results) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Tests: 6 │ + │ Passing: 6 │ + │ Failing: 0 │ + │ Pending: 0 │ + │ Skipped: 0 │ + │ Screenshots: 0 │ + │ Video: true │ + │ Duration: X seconds │ + │ Spec Ran: with_allow_list_custom_or_true.cy.ts │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + + (Video) + + - Video output: /XXX/XXX/XXX/cypress/videos/with_allow_list_custom_or_true.cy.ts.mp4 + + +==================================================================================================== + + (Run Finished) + + + Spec Tests Passing Failing Pending Skipped + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ ✔ with_allow_list_custom_or_true.cy.t XX:XX 6 6 - - - │ + │ s │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + ✔ All specs passed! XX:XX 6 6 - - - + + +` + +exports['e2e experimentalCspAllowList / experimentalCspAllowList=[\'script-src-elem\', \'script-src\', \'default-src\', \'form-action\'] / works with [\'form-action\'] directives'] = ` + +==================================================================================================== + + (Run Starting) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Cypress: 1.2.3 │ + │ Browser: FooBrowser 88 │ + │ Specs: 1 found (form_action_with_allow_list_custom.cy.ts) │ + │ Searched: cypress/e2e/experimental_csp_allow_list_spec/form_action_with_allow_list_custom.cy │ + │ .ts │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + +──────────────────────────────────────────────────────────────────────────────────────────────────── + + Running: form_action_with_allow_list_custom.cy.ts (1 of 1) + + + experimentalCspAllowList=['script-src-elem', 'script-src', 'default-src'] + 1) fails on inline form action + + + 0 passing + 1 failing + + 1) experimentalCspAllowList=['script-src-elem', 'script-src', 'default-src'] + fails on inline form action: + CypressError: Timed out after waiting \`1000ms\` for your remote page to load. + +Your page did not fire its \`load\` event within \`1000ms\`. + +You can try increasing the \`pageLoadTimeout\` value in \`cypress.config.js\` to wait longer. + +Browsers will not fire the \`load\` event until all stylesheets and scripts are done downloading. + +When this \`load\` event occurs, Cypress will continue running commands. + [stack trace lines] + + + + + (Results) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Tests: 1 │ + │ Passing: 0 │ + │ Failing: 1 │ + │ Pending: 0 │ + │ Skipped: 0 │ + │ Screenshots: 1 │ + │ Video: true │ + │ Duration: X seconds │ + │ Spec Ran: form_action_with_allow_list_custom.cy.ts │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + + (Screenshots) + + - /XXX/XXX/XXX/cypress/screenshots/form_action_with_allow_list_custom.cy.ts/experi (1280x720) + mentalCspAllowList=['script-src-elem', 'script-src', 'default-src'] -- fails on + inline form action (failed).png + + + (Video) + + - Video output: /XXX/XXX/XXX/cypress/videos/form_action_with_allow_list_custom.cy.ts.mp4 + + +==================================================================================================== + + (Run Finished) + + + Spec Tests Passing Failing Pending Skipped + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ ✖ form_action_with_allow_list_custom. XX:XX 1 - 1 - - │ + │ cy.ts │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + ✖ 1 of 1 failed (100%) XX:XX 1 - 1 - - + + +` diff --git a/system-tests/projects/e2e/csp_script_test.html b/system-tests/projects/e2e/csp_script_test.html new file mode 100644 index 000000000000..95b5cb15d19f --- /dev/null +++ b/system-tests/projects/e2e/csp_script_test.html @@ -0,0 +1,26 @@ + + + + + + +

CSP Script Test

+ + + + + + + + + + + + +
+ + +
+ + + \ No newline at end of file diff --git a/system-tests/projects/e2e/cypress/e2e/experimental_csp_allow_list_spec/form_action_with_allow_list_custom.cy.ts b/system-tests/projects/e2e/cypress/e2e/experimental_csp_allow_list_spec/form_action_with_allow_list_custom.cy.ts new file mode 100644 index 000000000000..cc2336fd1b8f --- /dev/null +++ b/system-tests/projects/e2e/cypress/e2e/experimental_csp_allow_list_spec/form_action_with_allow_list_custom.cy.ts @@ -0,0 +1,19 @@ +describe(`experimentalCspAllowList=['script-src-elem', 'script-src', 'default-src']`, () => { + let visitUrl: URL + const timeout = 1000 + + beforeEach(() => { + visitUrl = new URL('http://localhost:4466/csp_script_test.html') + }) + + it('fails on inline form action', { + pageLoadTimeout: timeout, + // @ts-expect-error + }, () => { + visitUrl.searchParams.append('csp', `form-action 'none'`) + + cy.visit(visitUrl.toString()) + + cy.get('#submit').click() + }) +}) diff --git a/system-tests/projects/e2e/cypress/e2e/experimental_csp_allow_list_spec/with_allow_list_custom.cy.ts b/system-tests/projects/e2e/cypress/e2e/experimental_csp_allow_list_spec/with_allow_list_custom.cy.ts new file mode 100644 index 000000000000..db4d56c1067a --- /dev/null +++ b/system-tests/projects/e2e/cypress/e2e/experimental_csp_allow_list_spec/with_allow_list_custom.cy.ts @@ -0,0 +1,103 @@ +describe(`experimentalCspAllowList=['script-src-elem', 'script-src', 'default-src']`, () => { + let cspLogMessages = [] + let visitUrl: URL + let postMessageHandler = ({ data }) => { + if (data.event === 'csp-script-ran') { + cspLogMessages.push(data.data) + } + } + + beforeEach(() => { + cspLogMessages = [] + visitUrl = new URL('http://localhost:4466/csp_script_test.html') + + // To test scripts for execution under CSP, we send messages of postMessage to verify a script has run to prevent any cross origin iframe issues + window.top.addEventListener('message', postMessageHandler, false) + }) + + afterEach(() => { + window.top.removeEventListener('message', postMessageHandler, false) + }) + + ;['script-src-elem', 'script-src', 'default-src'].forEach((CSP_directive) => { + describe(`content-security-policy directive ${CSP_directive} should not be stripped and`, () => { + it(`allows Cypress to run, including configured inline nonces/hashes`, () => { + visitUrl.searchParams.append('csp', `${CSP_directive} http://www.foobar.com:4466 http://localhost:4466 'nonce-random_nonce' 'sha256-YM+jfV8mJ3IaF5lqpgvjnYAWdy0k77pupK3tsdMuZv8'`) + + cy.visit(visitUrl.toString()) + + // NOTE: for script-src-elem, eval() is allowed to run and is only forbidden if script-src or default-src (as a fallback to script-src) is set. + // However, the inline script still needs to have an appropriate hash/nonce in order to execute, hence adding a nonce before adding the script onto the page + + cy.window().then((win) => { + try { + win.eval(` + var script = document.createElement('script'); + script.textContent = "window.top.postMessage({ event: 'csp-script-ran', data: 'eval script ran'}, '*')"; + script.nonce = "random_nonce" + document.head.appendChild(script); + `) + } catch (e) { + // this fails execution with script-src and default-src as expected. If another condition is met, throw + if (CSP_directive === 'script-src-elem') { + throw e + } + } + }) + + // make sure the stylesheet is loaded with the color purple + cy.get('h1').contains('CSP Script Test').should('have.css', 'color', 'rgb(128, 0, 128)') + + // wait a small amount of time for all postMessages to trickle in + cy.wait(1000).then(() => { + // localhost:4466 and www.foobar.com:4466 script src's are allowed to run + expect(cspLogMessages).to.contain('script src origin www.foobar.com:4466 script ran') + expect(cspLogMessages).to.contain('script src origin localhost:4466 script ran') + + // since we told the server via query params to let 'random_nonce' and 'sha256-YM+jfV8mJ3IaF5lqpgvjnYAWdy0k77pupK3tsdMuZv8=' inline scripts to execute, these scripts should have executed + expect(cspLogMessages).to.contain('nonce script ran') + + // chromium browsers support some features of CSP 3.0, such as hash-source on src like directives + // currently, Firefox and Webkit seem to be a bit behind. @see https://www.w3.org/TR/CSP3/ + if (!['firefox', 'webkit'].includes(Cypress.browser.name)) { + expect(cspLogMessages).to.contain('hash script ran') + } + + // should have been blocked by CSP as it isn't configured by the server to run + expect(cspLogMessages).to.not.contain('script src origin app.foobar.com:4466 script ran') + + // if the src type is script-src-eval, the eval should have ran. Otherwise, it should have been blocked + if (CSP_directive === 'script-src-elem') { + expect(cspLogMessages).to.contain('eval script ran') + } else { + expect(cspLogMessages).to.not.contain('eval script ran') + } + }) + }) + + it(`allows Cypress to run, but doesn't allow none configured inline scripts`, () => { + visitUrl.searchParams.append('csp', `${CSP_directive} http://www.foobar.com:4466 http://localhost:4466`) + + cy.visit(visitUrl.toString()) + + // make sure the stylesheet is loaded with the color purple + cy.get('h1').contains('CSP Script Test').should('have.css', 'color', 'rgb(128, 0, 128)') + + // wait a small amount of time for all postMessages to trickle in + cy.wait(1000).then(() => { + // localhost:4466 and www.foobar.com:4466 script src's are allowed to run + expect(cspLogMessages).to.contain('script src origin www.foobar.com:4466 script ran') + expect(cspLogMessages).to.contain('script src origin localhost:4466 script ran') + + // We did not configure any inline script to run, therefore these messages should have never been reported + expect(cspLogMessages).to.not.contain('nonce script ran') + expect(cspLogMessages).to.not.contain('hash script ran') + expect(cspLogMessages).to.not.contain('eval script ran') + + // should have been blocked by CSP as it isn't configured by the server to run + expect(cspLogMessages).to.not.contain('script src origin app.foobar.com:4466 script ran') + }) + }) + }) + }) +}) diff --git a/system-tests/projects/e2e/cypress/e2e/experimental_csp_allow_list_spec/with_allow_list_custom_or_true.cy.ts b/system-tests/projects/e2e/cypress/e2e/experimental_csp_allow_list_spec/with_allow_list_custom_or_true.cy.ts new file mode 100644 index 000000000000..b55415c514e3 --- /dev/null +++ b/system-tests/projects/e2e/cypress/e2e/experimental_csp_allow_list_spec/with_allow_list_custom_or_true.cy.ts @@ -0,0 +1,107 @@ +describe('experimentalCspAllowList is custom or true', () => { + let cspLogMessages = [] + let visitUrl: URL + let postMessageHandler = ({ data }) => { + if (data.event === 'csp-script-ran') { + cspLogMessages.push(data.data) + } + } + + beforeEach(() => { + cspLogMessages = [] + visitUrl = new URL('http://localhost:4466/csp_script_test.html') + + // To test scripts for execution under CSP, we send messages of postMessage to verify a script has run to prevent any cross origin iframe issues + window.top.addEventListener('message', postMessageHandler, false) + }) + + afterEach(() => { + window.top.removeEventListener('message', postMessageHandler, false) + }) + + describe('disallowed', () => { + it('frame-ancestors are always stripped', () => { + visitUrl.searchParams.append('csp', `frame-ancestors 'none'`) + cy.visit(visitUrl.toString()) + + // expect the iframe to load, which implies the csp directive was stripped out + cy.get('h1').contains('CSP Script Test').should('be.visible') + }) + + it('trusted-types & require-trusted-types-for are always stripped', () => { + visitUrl.searchParams.append('csp', `require-trusted-types-for 'script'; trusted-types foo bar 'allow-duplicates'`) + cy.visit(visitUrl.toString()) + + // expect to be able to manipulate the DOM as trusted-types policies are stripped out allowing for injection sink like methods + cy.get('h1').its(0).then(($el) => { + $el.innerHTML = 'CSP Script Test Modified' + }) + + cy.get('h1').contains('CSP Script Test Modified').should('be.visible') + }) + + it('sandbox is always stripped', () => { + // Since sandbox is inclusive, all other sandbox actions would be restricted except for `allow-downloads` + visitUrl.searchParams.append('csp', `sandbox 'allow-downloads'`) + cy.visit(visitUrl.toString()) + + // expect the form to post and navigate to a new page, meaning the sandbox directive was stripped + cy.get('#submit').click() + cy.contains('Cannot POST /').should('exist') + }) + + it('navigate-to is always stripped', () => { + visitUrl.searchParams.append('csp', `navigate-to 'none'`) + cy.visit(visitUrl.toString()) + + // expect the form to post and navigate to a new page, meaning the navigate-to directive was stripped + cy.get('#submit').click() + cy.contains('Cannot POST /').should('exist') + }) + }) + + describe('allowed', () => { + it('sample: style-src is not stripped', () => { + visitUrl.searchParams.append('csp', `style-src http://www.foobar.com:4466`) + cy.visit(visitUrl.toString()) + + // make sure the stylesheet is loaded with the color purple + cy.get('h1').contains('CSP Script Test').should('have.css', 'color', 'rgb(128, 0, 128)') + }) + + it('sample: upgrade-insecure-requests is not stripped', () => { + // fake the https automatic upgrade by fulfilling the http request to the express server. verify the requests are actually upraded + const requestsFulfilled = { + www_foobar_com_script: false, + app_foobar_com_script: false, + www_foobar_com_style: false, + } + + cy.intercept('https://www.foobar.com:4466/csp_empty_script.js', (req) => { + requestsFulfilled.www_foobar_com_script = true + req.reply('') + }) + + cy.intercept('https://app.foobar.com:4466/csp_empty_script.js', (req) => { + requestsFulfilled.app_foobar_com_script = true + req.reply('') + }) + + cy.intercept('https://www.foobar.com:4466/csp_empty_style.css', (req) => { + requestsFulfilled.www_foobar_com_style = true + req.reply('') + }) + + visitUrl.searchParams.append('csp', `upgrade-insecure-requests`) + cy.visit(visitUrl.toString()) + + cy.get('h1').contains('CSP Script Test').should('be.visible') + + cy.then(() => { + Object.keys(requestsFulfilled).forEach((key) => { + expect(requestsFulfilled[key]).to.be.true + }) + }) + }) + }) +}) diff --git a/system-tests/projects/e2e/cypress/e2e/experimental_csp_allow_list_spec/with_allow_list_true.cy.ts b/system-tests/projects/e2e/cypress/e2e/experimental_csp_allow_list_spec/with_allow_list_true.cy.ts new file mode 100644 index 000000000000..b9163b6da47c --- /dev/null +++ b/system-tests/projects/e2e/cypress/e2e/experimental_csp_allow_list_spec/with_allow_list_true.cy.ts @@ -0,0 +1,73 @@ +describe(`experimentalCspAllowList=true`, () => { + let cspLogMessages = [] + let visitUrl: URL + let postMessageHandler = ({ data }) => { + if (data.event === 'csp-script-ran') { + cspLogMessages.push(data.data) + } + } + + beforeEach(() => { + cspLogMessages = [] + visitUrl = new URL('http://localhost:4466/csp_script_test.html') + + // To test scripts for execution under CSP, we send messages of postMessage to verify a script has run to prevent any cross origin iframe issues + // since ['script-src-elem', 'script-src', 'default-src'] are all stripped out when experimentalCspAllowList=true by default, the messages should always be present + window.top.addEventListener('message', postMessageHandler, false) + }) + + afterEach(() => { + window.top.removeEventListener('message', postMessageHandler, false) + }) + + ;['script-src-elem', 'script-src', 'default-src'].forEach((CSP_directive) => { + describe(`content-security-policy directive ${CSP_directive} should be stripped and`, () => { + it(` regardless of nonces/hashes`, () => { + visitUrl.searchParams.append('csp', `${CSP_directive} http://www.foobar.com:4466 http://localhost:4466 'nonce-random_nonce' 'sha256-YM+jfV8mJ3IaF5lqpgvjnYAWdy0k77pupK3tsdMuZv8'`) + + cy.visit(visitUrl.toString()) + + cy.window().then((win) => { + return win.eval(` + var script = document.createElement('script'); + script.textContent = "window.top.postMessage({ event: 'csp-script-ran', data: 'eval script ran'}, '*')"; + script.nonce = "random_nonce" + document.head.appendChild(script); + `) + }) + + // make sure the stylesheet is loaded with the color purple + cy.get('h1').contains('CSP Script Test').should('have.css', 'color', 'rgb(128, 0, 128)') + + // wait a small amount of time for all postMessages to trickle in + cy.wait(1000).then(() => { + // since problematic CSP headers are stripped by default, we should have every message from every script + expect(cspLogMessages).to.contain('script src origin www.foobar.com:4466 script ran') + expect(cspLogMessages).to.contain('script src origin localhost:4466 script ran') + expect(cspLogMessages).to.contain('nonce script ran') + expect(cspLogMessages).to.contain('hash script ran') + expect(cspLogMessages).to.contain('script src origin app.foobar.com:4466 script ran') + expect(cspLogMessages).to.contain('eval script ran') + }) + }) + }) + }) + + const timeout = 1000 + + it('passes on inline form action', { + pageLoadTimeout: timeout, + // @ts-expect-error + }, () => { + // this should be stripped out in the middleware + visitUrl.searchParams.append('csp', `form-action 'none'`) + + cy.visit(visitUrl.toString()) + + // expect the form to submit + cy.get('#submit').click() + + // expect the form action to go through and NOT be blocked by CSP (even though the action itself fails which is OK) + cy.contains('Cannot POST /').should('exist') + }) +}) diff --git a/system-tests/projects/e2e/static/csp_styles.css b/system-tests/projects/e2e/static/csp_styles.css new file mode 100644 index 000000000000..c17c8634b7f4 --- /dev/null +++ b/system-tests/projects/e2e/static/csp_styles.css @@ -0,0 +1,3 @@ +h1{ + color: purple; +} \ No newline at end of file diff --git a/system-tests/test/experimental_csp_allow_list_spec.ts b/system-tests/test/experimental_csp_allow_list_spec.ts new file mode 100644 index 000000000000..ebec531dc521 --- /dev/null +++ b/system-tests/test/experimental_csp_allow_list_spec.ts @@ -0,0 +1,114 @@ +import path from 'path' +import systemTests from '../lib/system-tests' +import Fixtures from '../lib/fixtures' + +const e2ePath = Fixtures.projectPath('e2e') + +const PORT = 3500 +const onServer = function (app) { + app.get(`/csp_empty_style.css`, (req, res) => { + // instead of logging, check the color of the h1 inside csp_script_test.html to see if the h1 text color is purple to verify the script ran + res.sendFile(path.join(e2ePath, `static/csp_styles.css`)) + }) + + app.get(`/csp_empty_script.js`, (req, res) => { + // log the host of the script to postMessage to verify if the script ran or not depending on the test + const script = `window.top.postMessage({ event: 'csp-script-ran', data: 'script src origin ${req.get('host')} script ran'}, '*')` + + res.send(script) + }) + + app.get(`/csp_script_test.html`, (req, res) => { + const { csp } = req.query + + res.setHeader('Content-Security-Policy', csp) + res.sendFile(path.join(e2ePath, `csp_script_test.html`)) + }) +} + +// NOTE: 'navigate-to' is a CSP 3.0 feature and currently is not shipped with any major browser version. @see https://csplite.com/csp123/. +describe('e2e experimentalCspAllowList', () => { + systemTests.setup({ + servers: [{ + port: 4466, + onServer, + }], + settings: { + hosts: { + '*.foobar.com': '127.0.0.1', + }, + e2e: {}, + }, + }) + + describe('experimentalCspAllowList=true', () => { + systemTests.it('strips out [\'script-src-elem\', \'script-src\', \'default-src\', \'form-action\'] directives', { + browser: '!webkit', // TODO(webkit): fix+unskip + port: PORT, + spec: 'experimental_csp_allow_list_spec/with_allow_list_true.cy.ts', + snapshot: true, + expectedExitCode: 0, + config: { + videoCompression: false, + retries: 0, + experimentalCspAllowList: true, + }, + }) + + systemTests.it('always strips known problematic directives and is passive with known working directives', { + browser: '!webkit', // TODO(webkit): fix+unskip + port: PORT, + spec: 'experimental_csp_allow_list_spec/with_allow_list_custom_or_true.cy.ts', + snapshot: true, + expectedExitCode: 0, + config: { + videoCompression: false, + retries: 0, + experimentalCspAllowList: true, + }, + }) + }) + + // NOTE: these tests do not 100% work in webkit and are problematic in CI with firefox. + describe('experimentalCspAllowList=[\'script-src-elem\', \'script-src\', \'default-src\', \'form-action\']', () => { + systemTests.it('works with [\'script-src-elem\', \'script-src\', \'default-src\'] directives', { + browser: ['chrome', 'electron'], + port: PORT, + spec: 'experimental_csp_allow_list_spec/with_allow_list_custom.cy.ts', + snapshot: true, + expectedExitCode: 0, + config: { + videoCompression: false, + retries: 0, + experimentalCspAllowList: ['script-src-elem', 'script-src', 'default-src'], + }, + }) + + systemTests.it('always strips known problematic directives and is passive with known working directives', { + browser: ['chrome', 'electron'], + port: PORT, + spec: 'experimental_csp_allow_list_spec/with_allow_list_custom_or_true.cy.ts', + snapshot: true, + expectedExitCode: 0, + config: { + videoCompression: false, + retries: 0, + experimentalCspAllowList: ['script-src-elem', 'script-src', 'default-src', 'form-action'], + }, + }) + + systemTests.it('works with [\'form-action\'] directives', { + // NOTE: firefox respects on form action, but the submit handler does not trigger a error + browser: ['chrome', 'electron'], + port: PORT, + spec: 'experimental_csp_allow_list_spec/form_action_with_allow_list_custom.cy.ts', + snapshot: true, + expectedExitCode: 1, + config: { + videoCompression: false, + retries: 0, + experimentalCspAllowList: ['form-action'], + }, + }) + }) +})