From de633a1c0fe5edb6f3ec75c9c1d7a5e0c1b77d85 Mon Sep 17 00:00:00 2001
From: Preston Goforth
Date: Tue, 11 Apr 2023 16:28:03 -0400
Subject: [PATCH 01/20] feat: Selective CSP header directive stripping from
HTTPResponse - uses `stripCspDirectives` config option
---
cli/CHANGELOG.md | 6 +-
cli/types/cypress.d.ts | 16 +-
cli/types/tests/cypress-tests.ts | 1 +
packages/app/cypress.config.ts | 1 +
...ql-CloudViewerAndProject_RequiredData.json | 5 +
.../gql-HeaderBar_HeaderBarQuery.json | 5 +
.../debug-Failing/gql-SpecsPageContainer.json | 5 +
...ql-CloudViewerAndProject_RequiredData.json | 5 +
.../gql-HeaderBar_HeaderBarQuery.json | 5 +
.../debug-Passing/gql-SpecsPageContainer.json | 5 +
.../config/__snapshots__/index.spec.ts.js | 3 +
packages/config/src/options.ts | 6 +
packages/config/src/validation.ts | 29 +-
packages/config/test/project/utils.spec.ts | 24 ++
packages/config/test/validation.spec.ts | 33 ++
.../src/sources/migration/legacyOptions.ts | 4 +
.../driver/cypress/e2e/e2e/csp-headers.cy.js | 62 +++
.../cypress/fixtures/config.json | 5 +
.../proxy/lib/http/response-middleware.ts | 69 +++-
packages/proxy/lib/http/util/csp-header.ts | 114 ++++++
packages/proxy/lib/http/util/inject.ts | 16 +-
packages/proxy/lib/http/util/rewriter.ts | 5 +
packages/proxy/lib/types.ts | 1 +
.../test/integration/net-stubbing.spec.ts | 161 +++++++-
.../unit/http/response-middleware.spec.ts | 384 +++++++++++++++++-
.../test/unit/http/util/csp-header.spec.ts | 143 +++++++
packages/server/index.d.ts | 1 +
.../test/integration/http_requests_spec.js | 315 +++++++++++++-
packages/server/test/unit/config_spec.js | 44 ++
29 files changed, 1456 insertions(+), 17 deletions(-)
create mode 100644 packages/driver/cypress/e2e/e2e/csp-headers.cy.js
create mode 100644 packages/proxy/lib/http/util/csp-header.ts
create mode 100644 packages/proxy/test/unit/http/util/csp-header.spec.ts
diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md
index a8af84a5dcec..3bb7c9454df6 100644
--- a/cli/CHANGELOG.md
+++ b/cli/CHANGELOG.md
@@ -1,8 +1,12 @@
-## 12.13.1
+## 12.14.0
_Released 06/06/2023 (PENDING)_
+**Features:**
+
+- Cypress now allows targeted Content-Security-Policy and Content-Security-Policy-Report-Only header directive stripping from requests via the [`stripCspDirectives`](https://docs.cypress.io/guides/references/configuration#stripCspDirectives) config option. Addresses [#1030](https://github.com/cypress-io/cypress/issues/1030). Addressed in [#26483](https://github.com/cypress-io/cypress/pull/26483).
+
**Bugfixes:**
- Fixes issue not detecting Angular 16 dependencies in launchpad. Addresses [#26852](https://github.com/cypress-io/cypress/issues/26852)
diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts
index e2311554ab94..4601992b2567 100644
--- a/cli/types/cypress.d.ts
+++ b/cli/types/cypress.d.ts
@@ -3048,6 +3048,18 @@ declare namespace Cypress {
* @default 'top'
*/
scrollBehavior: scrollBehaviorOptions
+ /**
+ * Indicates whether Cypress should strip CSP header directives from the application under test.
+ * - When this option is set to `"all"`, Cypress will strip the entire CSP header.
+ * - When this option is set to `"minimum"`, Cypress will only to strip directives that would interfere
+ * with or inhibit Cypress functionality.
+ * - If you do not wish to strip *_any_* CSP directives, set this option to an empty array (`[]`).
+ *
+ * Please see the documentation for more information.
+ * @see https://on.cypress.io/configuration#stripCspDirectives
+ * @default 'all'
+ */
+ stripCspDirectives: 'all' | 'minimum' | string[],
/**
* Allows listening to the `before:run`, `after:run`, `before:spec`, and `after:spec` events in the plugins file during interactive mode.
* @default false
@@ -3247,14 +3259,14 @@ declare namespace Cypress {
}
interface SuiteConfigOverrides extends Partial<
- Pick
+ Pick
>, Partial> {
browser?: IsBrowserMatcher | IsBrowserMatcher[]
keystrokeDelay?: number
}
interface TestConfigOverrides extends Partial<
- Pick
+ Pick
>, Partial> {
browser?: IsBrowserMatcher | IsBrowserMatcher[]
keystrokeDelay?: number
diff --git a/cli/types/tests/cypress-tests.ts b/cli/types/tests/cypress-tests.ts
index 4b036baa10e5..4df7bfa65c8d 100644
--- a/cli/types/tests/cypress-tests.ts
+++ b/cli/types/tests/cypress-tests.ts
@@ -851,6 +851,7 @@ namespace CypressTestConfigOverridesTests {
requestTimeout: 6000,
responseTimeout: 6000,
scrollBehavior: 'center',
+ stripCspDirectives: 'minimum',
taskTimeout: 6000,
viewportHeight: 200,
viewportWidth: 200,
diff --git a/packages/app/cypress.config.ts b/packages/app/cypress.config.ts
index b505c98ee97e..f4b84c95818c 100644
--- a/packages/app/cypress.config.ts
+++ b/packages/app/cypress.config.ts
@@ -12,6 +12,7 @@ export default defineConfig({
reporterOptions: {
configFile: '../../mocha-reporter-config.json',
},
+ stripCspDirectives: 'all',
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..ed2fd6ce39f2 100644
--- a/packages/app/cypress/fixtures/debug-Failing/gql-CloudViewerAndProject_RequiredData.json
+++ b/packages/app/cypress/fixtures/debug-Failing/gql-CloudViewerAndProject_RequiredData.json
@@ -263,6 +263,11 @@
"from": "default",
"field": "scrollBehavior"
},
+ {
+ "value": "all",
+ "from": "default",
+ "field": "stripCspDirectives"
+ },
{
"value": "cypress/support/component.{js,jsx,ts,tsx}",
"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..360a1623ac7d 100644
--- a/packages/app/cypress/fixtures/debug-Failing/gql-HeaderBar_HeaderBarQuery.json
+++ b/packages/app/cypress/fixtures/debug-Failing/gql-HeaderBar_HeaderBarQuery.json
@@ -239,6 +239,11 @@
"from": "default",
"field": "scrollBehavior"
},
+ {
+ "value": "all",
+ "from": "default",
+ "field": "stripCspDirectives"
+ },
{
"value": "cypress/support/component.{js,jsx,ts,tsx}",
"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..cb9a2db53fe2 100644
--- a/packages/app/cypress/fixtures/debug-Failing/gql-SpecsPageContainer.json
+++ b/packages/app/cypress/fixtures/debug-Failing/gql-SpecsPageContainer.json
@@ -624,6 +624,11 @@
"from": "default",
"field": "scrollBehavior"
},
+ {
+ "value": "all",
+ "from": "default",
+ "field": "stripCspDirectives"
+ },
{
"value": "cypress/support/component.{js,jsx,ts,tsx}",
"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..dca92582dd3d 100644
--- a/packages/app/cypress/fixtures/debug-Passing/gql-CloudViewerAndProject_RequiredData.json
+++ b/packages/app/cypress/fixtures/debug-Passing/gql-CloudViewerAndProject_RequiredData.json
@@ -263,6 +263,11 @@
"from": "default",
"field": "scrollBehavior"
},
+ {
+ "value": "all",
+ "from": "default",
+ "field": "stripCspDirectives"
+ },
{
"value": "cypress/support/component.{js,jsx,ts,tsx}",
"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..360a1623ac7d 100644
--- a/packages/app/cypress/fixtures/debug-Passing/gql-HeaderBar_HeaderBarQuery.json
+++ b/packages/app/cypress/fixtures/debug-Passing/gql-HeaderBar_HeaderBarQuery.json
@@ -239,6 +239,11 @@
"from": "default",
"field": "scrollBehavior"
},
+ {
+ "value": "all",
+ "from": "default",
+ "field": "stripCspDirectives"
+ },
{
"value": "cypress/support/component.{js,jsx,ts,tsx}",
"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..f0e726368140 100644
--- a/packages/app/cypress/fixtures/debug-Passing/gql-SpecsPageContainer.json
+++ b/packages/app/cypress/fixtures/debug-Passing/gql-SpecsPageContainer.json
@@ -1625,6 +1625,11 @@
"from": "default",
"field": "scrollBehavior"
},
+ {
+ "value": "all",
+ "from": "default",
+ "field": "stripCspDirectives"
+ },
{
"value": "cypress/support/component.{js,jsx,ts,tsx}",
"from": "default",
diff --git a/packages/config/__snapshots__/index.spec.ts.js b/packages/config/__snapshots__/index.spec.ts.js
index 51e0c8b4f839..45c296daf31a 100644
--- a/packages/config/__snapshots__/index.spec.ts.js
+++ b/packages/config/__snapshots__/index.spec.ts.js
@@ -70,6 +70,7 @@ exports['config/src/index .getDefaultValues returns list of public config keys 1
'screenshotsFolder': 'cypress/screenshots',
'slowTestThreshold': 10000,
'scrollBehavior': 'top',
+ 'stripCspDirectives': 'all',
'supportFile': 'cypress/support/e2e.{js,jsx,ts,tsx}',
'supportFolder': false,
'taskTimeout': 60000,
@@ -157,6 +158,7 @@ exports['config/src/index .getDefaultValues returns list of public config keys f
'screenshotsFolder': 'cypress/screenshots',
'slowTestThreshold': 10000,
'scrollBehavior': 'top',
+ 'stripCspDirectives': 'all',
'supportFile': 'cypress/support/e2e.{js,jsx,ts,tsx}',
'supportFolder': false,
'taskTimeout': 60000,
@@ -239,6 +241,7 @@ exports['config/src/index .getPublicConfigKeys returns list of public config key
'screenshotsFolder',
'slowTestThreshold',
'scrollBehavior',
+ 'stripCspDirectives',
'supportFile',
'supportFolder',
'taskTimeout',
diff --git a/packages/config/src/options.ts b/packages/config/src/options.ts
index d32010375a55..e85efdc5231d 100644
--- a/packages/config/src/options.ts
+++ b/packages/config/src/options.ts
@@ -381,6 +381,12 @@ const driverConfigOptions: Array = [
defaultValue: 'top',
validation: validate.isOneOf('center', 'top', 'bottom', 'nearest', false),
overrideLevel: 'any',
+ }, {
+ name: 'stripCspDirectives',
+ defaultValue: 'all',
+ validation: validate.validateAny(validate.isOneOf('all', 'minimum'), validate.isArrayOfStrings),
+ overrideLevel: 'any',
+ requireRestartOnChange: 'server',
}, {
name: 'supportFile',
defaultValue: (options: Record = {}) => options.testingType === 'component' ? 'cypress/support/component.{js,jsx,ts,tsx}' : 'cypress/support/e2e.{js,jsx,ts,tsx}',
diff --git a/packages/config/src/validation.ts b/packages/config/src/validation.ts
index 8f023db35515..cc2bba26b13c 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.
@@ -321,8 +336,16 @@ export function isFullyQualifiedUrl (key: string, value: any): ErrResult | true
)
}
+export function isArrayOfStrings (key: string, value: any): ErrResult | true {
+ if (isStringArray(value)) {
+ return true
+ }
+
+ return errMsg(key, value, 'an array of strings')
+}
+
export function isStringOrArrayOfStrings (key: string, value: any): ErrResult | true {
- if (_.isString(value) || isArrayOfStrings(value)) {
+ if (_.isString(value) || isStringArray(value)) {
return true
}
@@ -330,7 +353,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..decfd95727f1 100644
--- a/packages/config/test/project/utils.spec.ts
+++ b/packages/config/test/project/utils.spec.ts
@@ -859,6 +859,28 @@ describe('config/src/project/utils', () => {
})
})
+ it('stripCspDirectives="all"', function () {
+ return this.defaults('stripCspDirectives', 'all')
+ })
+
+ it('stripCspDirectives="minimum"', function () {
+ return this.defaults('stripCspDirectives', 'minimum', {
+ stripCspDirectives: 'minimum',
+ })
+ })
+
+ it('stripCspDirectives=[]', function () {
+ return this.defaults('stripCspDirectives', [], {
+ stripCspDirectives: [],
+ })
+ })
+
+ it('stripCspDirectives=["fake-directive"]', function () {
+ return this.defaults('stripCspDirectives', ['fake-directive'], {
+ stripCspDirectives: ['fake-directive'],
+ })
+ })
+
it('resets numTestsKeptInMemory to 0 when runMode', function () {
return mergeDefaults({ projectRoot: '/foo/bar/', supportFile: false }, { isTextTerminal: true }, {}, this.getFilesByGlob)
.then((cfg) => {
@@ -1088,6 +1110,7 @@ describe('config/src/project/utils', () => {
screenshotsFolder: { value: 'cypress/screenshots', from: 'default' },
specPattern: { value: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}', from: 'default' },
slowTestThreshold: { value: 10000, from: 'default' },
+ stripCspDirectives: { value: 'all', from: 'default' },
supportFile: { value: false, from: 'config' },
supportFolder: { value: false, from: 'default' },
taskTimeout: { value: 60000, from: 'default' },
@@ -1207,6 +1230,7 @@ describe('config/src/project/utils', () => {
screenshotsFolder: { value: 'cypress/screenshots', from: 'default' },
slowTestThreshold: { value: 10000, from: 'default' },
specPattern: { value: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}', from: 'default' },
+ stripCspDirectives: { value: 'all', from: 'default' },
supportFile: { value: false, from: 'config' },
supportFolder: { value: false, from: 'default' },
taskTimeout: { value: 60000, from: 'default' },
diff --git a/packages/config/test/validation.spec.ts b/packages/config/test/validation.spec.ts
index fc7b22e5eb31..142f0d179e08 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')
diff --git a/packages/data-context/src/sources/migration/legacyOptions.ts b/packages/data-context/src/sources/migration/legacyOptions.ts
index 78a3146c3da3..72560fd08f94 100644
--- a/packages/data-context/src/sources/migration/legacyOptions.ts
+++ b/packages/data-context/src/sources/migration/legacyOptions.ts
@@ -213,6 +213,10 @@ const resolvedOptions: Array = [
name: 'scrollBehavior',
defaultValue: 'top',
canUpdateDuringTestTime: true,
+ }, {
+ name: 'stripCspDirectives',
+ defaultValue: 'all',
+ canUpdateDuringTestTime: true,
}, {
name: 'supportFile',
defaultValue: 'cypress/support',
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..e71973c71a3c
--- /dev/null
+++ b/packages/driver/cypress/e2e/e2e/csp-headers.cy.js
@@ -0,0 +1,62 @@
+describe('csp-headers', () => {
+ // Currently unable to test spec based config values for stripCspDirectives
+ if (cy.config('stripCspDirectives') === 'all') {
+ it('content-security-policy headers 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 not being stripped')
+ })
+ })
+ } else {
+ it('content-security-policy headers available', () => {
+ 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 blocked, because if they aren't, the CSP header is 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);
+ `)
+ }).to.throw() // CSP should prevent `unsafe-eval`
+
+ // Inline script should not have created the var
+ expect(win[`${inlineId}`]).to.equal(undefined, 'CSP Headers are stripped')
+ })
+ })
+ }
+})
diff --git a/packages/frontend-shared/cypress/fixtures/config.json b/packages/frontend-shared/cypress/fixtures/config.json
index 22b42486130a..e32e7cd5f062 100644
--- a/packages/frontend-shared/cypress/fixtures/config.json
+++ b/packages/frontend-shared/cypress/fixtures/config.json
@@ -207,6 +207,11 @@
"from": "default",
"field": "scrollBehavior"
},
+ {
+ "value": "all",
+ "from": "default",
+ "field": "stripCspDirectives"
+ },
{
"value": "cypress/support/e2e.ts",
"from": "config",
diff --git a/packages/proxy/lib/http/response-middleware.ts b/packages/proxy/lib/http/response-middleware.ts
index 2eaf17e71ddc..57bac5f3fa71 100644
--- a/packages/proxy/lib/http/response-middleware.ts
+++ b/packages/proxy/lib/http/response-middleware.ts
@@ -19,6 +19,9 @@ import type { CypressIncomingRequest, CypressOutgoingResponse } from '@packages/
import type { HttpMiddleware, HttpMiddlewareThis } from '.'
import type { IncomingMessage, IncomingHttpHeaders } from 'http'
+import { cspHeaderNames, generateCspDirectives, nonceDirectives, parseCspHeaders, unsupportedCSPDirectives } from './util/csp-header'
+import crypto from 'crypto'
+
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,37 @@ 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.stripCspDirectives === 'all') {
+ cspHeaderNames.forEach((headerName) => {
+ // Altering the CSP headers using the native response header methods is case-insensitive
+ this.res.removeHeader(headerName)
+ })
+ } else {
+ // If the user has specified CSP directives to strip, we must remove them from the CSP headers
+ const stripDirectives = this.config.stripCspDirectives === 'minimum' ? unsupportedCSPDirectives : this.config.stripCspDirectives
+
+ // 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)
+ }
+ })
+ }
+
this.next()
}
@@ -634,6 +696,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 +798,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..1183975f0270
--- /dev/null
+++ b/packages/proxy/lib/http/util/csp-header.ts
@@ -0,0 +1,114 @@
+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 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',
+ /**
+ * 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..d80cf68ddd95 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 = {
+ stripCspDirectives: 'all',
+ }
+
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.stripCspDirectives = 'minimum'
+ 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.stripCspDirectives = 'minimum'
+ res.setHeader('content-type', 'text/html')
+ res.setHeader(headerName, 'script-src \'self\' localhost')
+ res.send('bar')
+ })
+
+ destinationApp.get('/csp-header-multiple', (req, res) => {
+ const headerName = req.query.headerName
+
+ proxy.http.config.stripCspDirectives = 'minimum'
+ 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..4bebd84f710b 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, 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,135 @@ 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(`removes "${directive}" directive from "${headerName}" headers 'when stripCspDirectives is "minimum"`, () => {
+ prepareContext({
+ [`${headerName}`]: `${directive} 'fake-csp-${directive}-value'; fake-csp-directive fake-csp-value`,
+ }, {
+ stripCspDirectives: 'minimum',
+ })
+
+ return testMiddleware([OmitProblematicHeaders], ctx)
+ .then(() => {
+ expect(ctx.res.setHeader).to.be.calledWith(headerName.toLowerCase(), [
+ 'fake-csp-directive fake-csp-value',
+ ])
+ })
+ })
+
+ it(`does not remove "${directive}" from "${headerName}" headers when stripCspDirectives is an empty array`, () => {
+ prepareContext({
+ [`${headerName}`]: `${directive} 'fake-csp-${directive}-value'; fake-csp-directive fake-csp-value`,
+ }, {
+ stripCspDirectives: [],
+ })
+
+ 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`,
+ ])
+ })
+ })
+ })
+ })
+
+ validCspHeaderNames.forEach((headerName) => {
+ it(`removes "${headerName}" headers when stripCspDirectives is "all"`, () => {
+ prepareContext({
+ [`${headerName}`]: `fake-csp-directive fake-csp-value`,
+ }, {
+ stripCspDirectives: 'all',
+ })
+
+ return testMiddleware([OmitProblematicHeaders], ctx)
+ .then(() => {
+ expect(ctx.res.removeHeader).to.be.calledWith(headerName.toLowerCase())
+ })
+ })
+ })
+
+ validCspHeaderNames.forEach((headerName) => {
+ it(`removes all directives provided from "${headerName}" headers when stripCspDirectives 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`,
+ }, {
+ stripCspDirectives: ['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-2 fake-csp-value-2',
+ ])
+ })
+ })
+ })
+
+ 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: {
+ stripCspDirectives: 'all',
+ ...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 +518,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 +917,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 +1767,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 +1792,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 +1825,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 +1841,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': ['