Skip to content

Commit

Permalink
feat: Do not strip CSP headers from HTTPResponse (#24760)
Browse files Browse the repository at this point in the history
Co-authored-by: Zach Bloomquist <[email protected]>
Closes #1030
  • Loading branch information
pgoforth authored Jan 11, 2023
1 parent 870a658 commit 0472bb9
Show file tree
Hide file tree
Showing 10 changed files with 559 additions and 50 deletions.
17 changes: 17 additions & 0 deletions packages/driver/cypress/e2e/e2e/security.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,21 @@ describe('security', () => {
cy.visit('/fixtures/security.html')
cy.get('div').should('not.exist')
})

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

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

cy.visit('/fixtures/empty.html')
.wait(1000)
})
})
37 changes: 34 additions & 3 deletions packages/proxy/lib/http/response-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { URL } from 'url'
import { CookiesHelper } from './util/cookies'
import { doesTopNeedToBeSimulated } from './util/top-simulation'
import { toughCookieToAutomationCookie } from '@packages/server/lib/util/cookies'
import { generateCspDirectives, hasCspHeader, parseCspHeaders } from './util/csp-header'
import crypto from 'crypto'

interface ResponseMiddlewareProps {
/**
Expand Down Expand Up @@ -311,6 +313,36 @@ 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')

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

if (incomingCspHeaders.length) {
// 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')

this.res.injectionNonce = nonce

// Since CSP headers are not cumulative, the nonce policy must be set on each CSP header individually
const mapPolicies = (policy: Map<string, string[]>) => {
const cspScriptSrc = policy.get('script-src') || []

policy.set('script-src', [...cspScriptSrc, `'nonce-${nonce}'`])

return generateCspDirectives(policy)
}

// Iterate through each CSP header
incomingCspHeaders.forEach((headerName) => {
// Map the nonce on each CSP header
const modifiedCspHeaders = parseCspHeaders(this.incomingRes.headers, headerName).map(mapPolicies)

// To replicate original response CSP headers, we must apply all header values as an array
this.res.setHeader(headerName, modifiedCspHeaders)
})
}
}

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

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

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

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

const caseInsensitiveGetAllHeaders = (headers: OutgoingHttpHeaders, lowercaseProperty: string): string[] => {
return Object.entries(headers).reduce((acc: string[], [key, value]) => {
if (key.toLowerCase() === lowercaseProperty) {
// It's possible to set more than 1 CSP header, and in those instances CSP headers
// are NOT merged by the browser. Instead, the most **restrictive** CSP header
// that applies to the given resource will be used.
// https://www.w3.org/TR/CSP2/#content-security-policy-header-field
//
// Therefore, we need to return each header as it's own value so we can apply
// injection nonce values to each one, because we don't know which will be
// the most restrictive.
acc.push.apply(
acc,
`${value}`.split(',')
.filter(Boolean)
.map((policyString) => `${policyString}`.trim()),
)
}

return acc
}, [])
}

function getCspHeaders (headers: OutgoingHttpHeaders, headerName: string = 'content-security-policy'): string[] {
return caseInsensitiveGetAllHeaders(headers, headerName.toLowerCase())
}

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

export function parseCspHeaders (headers: OutgoingHttpHeaders, headerName: string = 'content-security-policy'): Map<string, string[]>[] {
const cspHeaders = getCspHeaders(headers, headerName)

// We must make an policy map for each CSP header individually
return cspHeaders.reduce((acc: Map<string, string[]>[], cspHeader) => {
const policies = new Map<string, string[]>()
let policy = cspRegExp.exec(cspHeader)

while (policy) {
const [/* regExpMatch */, directive, values] = policy
const currentDirective = policies.get(directive) || []

policies.set(directive, [...currentDirective, ...values.split(' ').filter(Boolean)])
policy = cspRegExp.exec(cspHeader)
}

return [...acc, policies]
}, [])
}

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

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

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

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

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

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

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

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

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

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

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

return oneLine`
<script type='text/javascript'>
<script type='text/javascript'${
cspNonce ? ` nonce="${cspNonce}"` : ''
}>
${documentDomainInjection}
(function (cypressConfig) {
${contents}
}(${JSON.stringify(options)}));
}(${JSON.stringify(crossOriginOptions)}));
</script>
`
}
5 changes: 5 additions & 0 deletions packages/proxy/lib/http/util/rewriter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type SecurityOpts = {
}

export type InjectionOpts = {
cspNonce?: string
domainName: string
wantsInjection: CypressWantsInjection
wantsSecurityRemoved: any
Expand All @@ -32,6 +33,7 @@ function getRewriter (useAstSourceRewriting: boolean) {

function getHtmlToInject (opts: InjectionOpts & SecurityOpts) {
const {
cspNonce,
domainName,
wantsInjection,
modifyObstructiveThirdPartyCode,
Expand All @@ -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,
Expand All @@ -55,6 +59,7 @@ function getHtmlToInject (opts: InjectionOpts & SecurityOpts) {
case 'partial':
return inject.partial(domainName, {
shouldInjectDocumentDomain,
cspNonce,
})
default:
return
Expand Down
1 change: 1 addition & 0 deletions packages/proxy/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,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
Expand Down
109 changes: 109 additions & 0 deletions packages/proxy/test/integration/net-stubbing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,27 @@ context('network stubbing', () => {

destinationApp.get('/', (req, res) => res.send('it worked'))

destinationApp.get('/csp-header', (req, res) => {
const headerName = req.query.headerName

res.setHeader('content-type', 'text/html')
res.setHeader(headerName, 'fake-directive fake-value')
res.send('<foo>bar</foo>')
})

destinationApp.get('/csp-header-multiple', (req, res) => {
const headerName = req.query.headerName

res.setHeader('content-type', 'text/html')
res.setHeader(headerName, ['default \'self\'', 'script-src \'self\' localhost'])
res.send('<foo>bar</foo>')
})

server = allowDestroy(destinationApp.listen(() => {
destinationPort = server.address().port
remoteStates.set(`http://localhost:${destinationPort}`)
remoteStates.set(`http://localhost:${destinationPort}/csp-header`)
remoteStates.set(`http://localhost:${destinationPort}/csp-header-multiple`)
done()
}))
})
Expand Down Expand Up @@ -285,4 +303,95 @@ 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: '<foo>bar</foo>',
},
getFixture: async () => {},
matches: 1,
})

return supertest(app)
.get(`/http://localhost:${destinationPort}`)
.set('Accept', 'text/html,application/xhtml+xml')
.then((res) => {
expect(res.headers[headerName]).to.be.undefined
expect(res.headers[headerName.toLowerCase()]).to.be.undefined
})
})

it('does not modify CSP header if not injecting JS and original response had CSP header', () => {
return supertest(app)
.get(`/http://localhost:${destinationPort}/csp-header?headerName=${headerName}`)
.then((res) => {
expect(res.headers[headerName.toLowerCase()]).to.equal('fake-directive fake-value')
})
})

it('modifies CSP header if injecting JS and original response had CSP header', () => {
return supertest(app)
.get(`/http://localhost:${destinationPort}/csp-header?headerName=${headerName}`)
.set('Accept', 'text/html,application/xhtml+xml')
.then((res) => {
expect(res.headers[headerName.toLowerCase()]).to.match(/^fake-directive fake-value; script-src 'nonce-[^-A-Za-z0-9+/=]|=[^=]|={3,}'/)
})
})

it('modifies CSP header if injecting JS and original response had 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.toLowerCase()]).to.match(/default 'self'; script-src 'nonce-[^-A-Za-z0-9+/=]|=[^=]|={3,}'/)
expect(res.headers[headerName.toLowerCase()]).to.match(/script-src 'self' localhost 'nonce-[^-A-Za-z0-9+/=]|=[^=]|={3,}'/)
})
})

if (headerName !== headerName.toLowerCase()) {
// Do not add a non-lowercase version of a CSP header, because most-restrictive is used
it('removes non-lowercase CSP header to avoid conflicts on unmodified CSP headers', () => {
return supertest(app)
.get(`/http://localhost:${destinationPort}/csp-header?headerName=${headerName}`)
.then((res) => {
expect(res.headers[headerName]).to.be.undefined
})
})

it('removes non-lowercase CSP header to avoid conflicts on modified CSP headers', () => {
return supertest(app)
.get(`/http://localhost:${destinationPort}/csp-header?headerName=${headerName}`)
.set('Accept', 'text/html,application/xhtml+xml')
.then((res) => {
expect(res.headers[headerName]).to.be.undefined
})
})

it('removes non-lowercase CSP header to avoid conflicts on multiple CSP headers', () => {
return supertest(app)
.get(`/http://localhost:${destinationPort}/csp-header-multiple?headerName=${headerName}`)
.set('Accept', 'text/html,application/xhtml+xml')
.then((res) => {
expect(res.headers[headerName]).to.be.undefined
})
})
}
})
})
})
})
Loading

5 comments on commit 0472bb9

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on 0472bb9 Jan 11, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circle has built the linux arm64 version of the Test Runner.

Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/12.4.0/linux-arm64/develop-0472bb9cdbd4bb7604572baeed0873a08a81b6a9/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on 0472bb9 Jan 11, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circle has built the linux x64 version of the Test Runner.

Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/12.4.0/linux-x64/develop-0472bb9cdbd4bb7604572baeed0873a08a81b6a9/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on 0472bb9 Jan 11, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circle has built the darwin arm64 version of the Test Runner.

Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/12.4.0/darwin-arm64/develop-0472bb9cdbd4bb7604572baeed0873a08a81b6a9/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on 0472bb9 Jan 11, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circle has built the darwin x64 version of the Test Runner.

Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/12.4.0/darwin-x64/develop-0472bb9cdbd4bb7604572baeed0873a08a81b6a9/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on 0472bb9 Jan 11, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circle has built the win32 x64 version of the Test Runner.

Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/12.4.0/win32-x64/develop-0472bb9cdbd4bb7604572baeed0873a08a81b6a9/cypress.tgz

Please sign in to comment.