Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Do not strip CSP headers from HTTPResponse #24760

Merged
merged 3 commits into from
Jan 11, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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')
Copy link
Contributor

Choose a reason for hiding this comment

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

For some reason I always thought that this was a hash of the script's source, this is way simpler than I thought.


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