refactor(proxy): document preparation adapter and register CSP policies

Stage 5 (5a+5b): document preparation driven port and configurator policies.

- Expand ForDocumentPreparation and add ProxyDocumentPreparationAdapter
- Delegate InjectHtml, InjectJs, StripCsp, ApplyRewriter through NetworkPolicyCore
- Register CSP and default rewrite policies via ConfiguratorNetworkPolicyAdapter

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Cacie Prins
2026-05-22 12:24:21 -04:00
parent 7b546bef50
commit 9c59944cc6
23 changed files with 757 additions and 316 deletions
@@ -0,0 +1,62 @@
export type InjectionLevel = false | 'full' | 'partial' | 'fullCrossOrigin'
export type InjectionLevelFacts = {
hasFileServerError: boolean
isInitial: boolean
isHTML: boolean
isRenderedHTML: boolean
isReqMatchSuperDomainOrigin: boolean
isAUTFrame: boolean
urlDoesNotMatchPolicyBasedOnDomain: boolean
}
export type SecurityRemovalFacts = {
modifyObstructiveCode: boolean
experimentalModifyObstructiveThirdPartyCode: boolean
wantsInjection: InjectionLevel | false | null | undefined
isHTML: boolean
isRenderedHTML: boolean
isReqMatchSuperDomainOrigin: boolean
isJavaScript: boolean
}
/**
* Pure injection-level decision — extracted from proxy `SetInjectionLevel` middleware.
*/
export function resolveInjectionLevel (facts: InjectionLevelFacts): InjectionLevel | false {
if (facts.hasFileServerError && !facts.isInitial) {
return 'partial'
}
const isHTMLLike = facts.isHTML || facts.isRenderedHTML
if (facts.urlDoesNotMatchPolicyBasedOnDomain && facts.isAUTFrame && isHTMLLike) {
return 'fullCrossOrigin'
}
if (!facts.isHTML || (!facts.isReqMatchSuperDomainOrigin && !facts.isAUTFrame)) {
return false
}
if (facts.isInitial && isHTMLLike) {
return 'full'
}
if (!facts.isRenderedHTML) {
return false
}
return 'partial'
}
/**
* Pure framebusting-removal decision — extracted from proxy `SetInjectionLevel` middleware.
*/
export function resolveWantsSecurityRemoved (facts: SecurityRemovalFacts): boolean {
return (facts.modifyObstructiveCode || facts.experimentalModifyObstructiveThirdPartyCode) &&
((facts.experimentalModifyObstructiveThirdPartyCode
&& (facts.isHTML || facts.isRenderedHTML || facts.isJavaScript)) ||
facts.wantsInjection === 'full' ||
facts.wantsInjection === 'fullCrossOrigin' ||
(facts.isJavaScript && facts.isReqMatchSuperDomainOrigin))
}
@@ -6,4 +6,6 @@ export * from './plan-subscriptions'
export * from './merge-handler-result'
export * from './document-preparation'
export * from './network-policy-core'
@@ -92,6 +92,36 @@ export class NetworkPolicyCore {
return port.interceptResponse(ctx)
}
async setInjectionLevel (ctx: unknown): Promise<void> {
const port = this.options.documentPreparation
if (!port) {
throw new Error('NetworkPolicyCore.documentPreparation is not configured')
}
return port.setInjectionLevel(ctx)
}
async injectHtml (ctx: unknown): Promise<void> {
const port = this.options.documentPreparation
if (!port) {
throw new Error('NetworkPolicyCore.documentPreparation is not configured')
}
return port.injectHtml(ctx)
}
async removeSecurity (ctx: unknown): Promise<void> {
const port = this.options.documentPreparation
if (!port) {
throw new Error('NetworkPolicyCore.documentPreparation is not configured')
}
return port.removeSecurity(ctx)
}
get requestInterception (): ForRequestInterception | undefined {
return this.options.requestInterception
}
@@ -99,4 +129,8 @@ export class NetworkPolicyCore {
get responseInterception (): ForResponseInterception | undefined {
return this.options.responseInterception
}
get documentPreparation (): ForDocumentPreparation | undefined {
return this.options.documentPreparation
}
}
@@ -0,0 +1,23 @@
import type { NetworkPolicy } from './types'
export type CspAllowListConfig = {
experimentalCspAllowList?: boolean | string[] | null
}
/**
* Configurator policy: CSP directive allow-list from `experimentalCspAllowList`.
* Registered at startup; enforcement remains in proxy response middleware until fully wired.
*/
export function CspAllowList (config: CspAllowListConfig): NetworkPolicy {
return {
name: 'csp-allow-list',
provenance: 'config',
phases: ['response'],
when () {
return !!config.experimentalCspAllowList
},
apply (ctx) {
ctx.continue()
},
}
}
@@ -0,0 +1,27 @@
import type { NetworkPolicy } from './types'
export type DocumentRewriteConfig = {
modifyObstructiveCode?: boolean
experimentalModifyObstructiveThirdPartyCode?: boolean
experimentalSourceRewriting?: boolean
}
/**
* Configurator policy: document rewrite / framebusting flags from Cypress config.
* Registered at startup; {@link resolveWantsSecurityRemoved} consumes equivalent facts at runtime.
*/
export function DocumentRewrite (config: DocumentRewriteConfig): NetworkPolicy {
const enabled = !!(config.modifyObstructiveCode || config.experimentalModifyObstructiveThirdPartyCode)
return {
name: 'document-rewrite',
provenance: 'config',
phases: ['response'],
when () {
return enabled
},
apply (ctx) {
ctx.continue()
},
}
}
@@ -2,4 +2,12 @@ export * from './types'
export { BlockedHosts } from './blocked-hosts'
export { CspAllowList } from './csp-allow-list'
export { DocumentRewrite } from './document-rewrite'
export type { BlockedHostsConfig } from './blocked-hosts'
export type { CspAllowListConfig } from './csp-allow-list'
export type { DocumentRewriteConfig } from './document-rewrite'
@@ -22,7 +22,11 @@ export interface ForResponseInterception {
* Driven port: HTML/JS inject, CSP strip, rewriter application.
*/
export interface ForDocumentPreparation {
// Expanded in Stage 5a.
setInjectionLevel (ctx: unknown): Promise<void>
injectHtml (ctx: unknown): Promise<void>
removeSecurity (ctx: unknown): Promise<void>
}
/**
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest'
import { resolveInjectionLevel, resolveWantsSecurityRemoved } from '../../lib/core/document-preparation'
describe('core/document-preparation', () => {
describe('resolveInjectionLevel', () => {
it('returns partial for file-server errors on non-initial loads', () => {
expect(resolveInjectionLevel({
hasFileServerError: true,
isInitial: false,
isHTML: true,
isRenderedHTML: true,
isReqMatchSuperDomainOrigin: true,
isAUTFrame: true,
urlDoesNotMatchPolicyBasedOnDomain: false,
})).toBe('partial')
})
it('returns fullCrossOrigin for cross-origin AUT HTML', () => {
expect(resolveInjectionLevel({
hasFileServerError: false,
isInitial: false,
isHTML: true,
isRenderedHTML: true,
isReqMatchSuperDomainOrigin: false,
isAUTFrame: true,
urlDoesNotMatchPolicyBasedOnDomain: true,
})).toBe('fullCrossOrigin')
})
it('returns full for initial HTML document loads', () => {
expect(resolveInjectionLevel({
hasFileServerError: false,
isInitial: true,
isHTML: true,
isRenderedHTML: true,
isReqMatchSuperDomainOrigin: true,
isAUTFrame: false,
urlDoesNotMatchPolicyBasedOnDomain: false,
})).toBe('full')
})
it('returns false when response is not HTML', () => {
expect(resolveInjectionLevel({
hasFileServerError: false,
isInitial: true,
isHTML: false,
isRenderedHTML: false,
isReqMatchSuperDomainOrigin: true,
isAUTFrame: true,
urlDoesNotMatchPolicyBasedOnDomain: false,
})).toBe(false)
})
})
describe('resolveWantsSecurityRemoved', () => {
it('returns true for full injection when modifyObstructiveCode is enabled', () => {
expect(resolveWantsSecurityRemoved({
modifyObstructiveCode: true,
experimentalModifyObstructiveThirdPartyCode: false,
wantsInjection: 'full',
isHTML: true,
isRenderedHTML: true,
isReqMatchSuperDomainOrigin: true,
isJavaScript: false,
})).toBe(true)
})
it('returns false when no rewrite flags are enabled', () => {
expect(resolveWantsSecurityRemoved({
modifyObstructiveCode: false,
experimentalModifyObstructiveThirdPartyCode: false,
wantsInjection: 'full',
isHTML: true,
isRenderedHTML: true,
isReqMatchSuperDomainOrigin: true,
isJavaScript: false,
})).toBe(false)
})
it('returns true for third-party HTML when experimental flag is enabled', () => {
expect(resolveWantsSecurityRemoved({
modifyObstructiveCode: false,
experimentalModifyObstructiveThirdPartyCode: true,
wantsInjection: false,
isHTML: true,
isRenderedHTML: false,
isReqMatchSuperDomainOrigin: false,
isJavaScript: false,
})).toBe(true)
})
})
})
@@ -184,4 +184,30 @@ describe('NetworkPolicyCore', () => {
await expect(core.interceptResponse({})).rejects.toThrow(/responseInterception/)
})
it('delegates document preparation methods to documentPreparation port', async () => {
const setInjectionLevel = vi.fn().mockResolvedValue(undefined)
const injectHtml = vi.fn().mockResolvedValue(undefined)
const removeSecurity = vi.fn().mockResolvedValue(undefined)
const core = new NetworkPolicyCore({
documentPreparation: { setInjectionLevel, injectHtml, removeSecurity },
})
const ctx = { res: {} }
await core.setInjectionLevel(ctx)
await core.injectHtml(ctx)
await core.removeSecurity(ctx)
expect(setInjectionLevel).toHaveBeenCalledWith(ctx)
expect(injectHtml).toHaveBeenCalledWith(ctx)
expect(removeSecurity).toHaveBeenCalledWith(ctx)
})
it('throws when documentPreparation port is missing', async () => {
const core = new NetworkPolicyCore()
await expect(core.setInjectionLevel({})).rejects.toThrow(/documentPreparation/)
await expect(core.injectHtml({})).rejects.toThrow(/documentPreparation/)
await expect(core.removeSecurity({})).rejects.toThrow(/documentPreparation/)
})
})
+8
View File
@@ -2,8 +2,16 @@ export { ProxyRequestInterceptionAdapter } from './proxy-request-interception'
export { ProxyResponseInterceptionAdapter } from './proxy-response-interception'
export { ProxyDocumentPreparationAdapter } from './proxy-document-preparation'
export { correlateBrowserPreRequest } from './correlate-browser-pre-request'
export { sendRequestOutgoing } from './send-request-outgoing'
export { setInjectionLevel } from './set-injection-level'
export { injectHtml } from './inject-html'
export { removeSecurity } from './remove-security'
export type { RequestInterceptionMiddlewareCtx, ResponseInterceptionMiddlewareCtx } from './types'
@@ -0,0 +1,68 @@
import iconv from 'iconv-lite'
import { PassThrough } from 'stream'
import { concatStream } from '@packages/network'
import { getDomainNameFromUrl, DocumentDomainInjection } from '@packages/network-tools'
import { telemetry } from '@packages/telemetry'
import { isVerboseTelemetry as isVerbose } from '../http'
import * as rewriter from '../http/util/rewriter'
import { getNodeCharsetFromResponse } from '../http/util/response-stream'
import { resContentTypeIsJavaScript } from '../http/util/document-preparation'
import type { ResponseInterceptionMiddlewareCtx } from './types'
/**
* Inject Cypress bridge HTML/JS into proxied document responses.
*/
export async function injectHtml (mw: ResponseInterceptionMiddlewareCtx): Promise<void> {
const span = telemetry.startSpan({ name: 'maybe:inject:html', parentSpan: mw.resMiddlewareSpan, isVerbose })
span?.setAttributes({
wantsInjection: mw.res.wantsInjection,
})
if (!mw.res.wantsInjection) {
span?.end()
return mw.next()
}
mw.skipMiddleware('MaybeRemoveSecurity')
mw.debug('injecting into HTML')
mw.makeResStreamPlainText()
const streamSpan = telemetry.startSpan({ name: `maybe:inject:html-resp:stream`, parentSpan: span, isVerbose })
mw.incomingResStream.pipe(concatStream(async (body) => {
const nodeCharset = getNodeCharsetFromResponse(mw.incomingRes.headers, body, mw.debug)
const decodedBody = iconv.decode(body, nodeCharset)
const injectedBody = await rewriter.html(decodedBody, {
cspNonce: mw.res.injectionNonce,
domainName: getDomainNameFromUrl(mw.req.proxiedUrl),
wantsInjection: mw.res.wantsInjection,
wantsSecurityRemoved: mw.res.wantsSecurityRemoved,
isNotJavascript: !resContentTypeIsJavaScript(mw.incomingRes),
useAstSourceRewriting: mw.config.experimentalSourceRewriting,
modifyObstructiveThirdPartyCode: mw.config.experimentalModifyObstructiveThirdPartyCode && !mw.remoteStates.isPrimarySuperDomainOrigin(mw.req.proxiedUrl),
shouldInjectDocumentDomain: DocumentDomainInjection.InjectionBehavior(mw.config).shouldInjectDocumentDomain(mw.req.proxiedUrl),
modifyObstructiveCode: mw.config.modifyObstructiveCode,
url: mw.req.proxiedUrl,
deferSourceMapRewrite: mw.deferSourceMapRewrite,
simulatedCookies: mw.simulatedCookies,
})
const encodedBody = iconv.encode(injectedBody, nodeCharset)
const pt = new PassThrough
pt.write(encodedBody)
pt.end()
mw.incomingResStream = pt
streamSpan?.end()
mw.next()
})).on('error', mw.onError).once('close', () => {
span?.end()
})
}
@@ -0,0 +1,20 @@
import type { ForDocumentPreparation } from '@packages/network-policy'
import { setInjectionLevel } from './set-injection-level'
import { injectHtml } from './inject-html'
import { removeSecurity } from './remove-security'
import type { ResponseInterceptionMiddlewareCtx } from './types'
/** {@link ForDocumentPreparation} adapter — delegates to legacy proxy document-prep middleware. */
export class ProxyDocumentPreparationAdapter implements ForDocumentPreparation {
setInjectionLevel (ctx: unknown): Promise<void> {
return setInjectionLevel(ctx as ResponseInterceptionMiddlewareCtx)
}
injectHtml (ctx: unknown): Promise<void> {
return injectHtml(ctx as ResponseInterceptionMiddlewareCtx)
}
removeSecurity (ctx: unknown): Promise<void> {
return removeSecurity(ctx as ResponseInterceptionMiddlewareCtx)
}
}
@@ -0,0 +1,44 @@
import { telemetry } from '@packages/telemetry'
import { isVerboseTelemetry as isVerbose } from '../http'
import * as rewriter from '../http/util/rewriter'
import { resContentTypeIsJavaScript } from '../http/util/document-preparation'
import type { ResponseInterceptionMiddlewareCtx } from './types'
/**
* Strip framebusting / obstructive JS from proxied responses when configured.
*/
export async function removeSecurity (mw: ResponseInterceptionMiddlewareCtx): Promise<void> {
const span = telemetry.startSpan({ name: 'maybe:remove:security', parentSpan: mw.resMiddlewareSpan, isVerbose })
span?.setAttributes({
wantsSecurityRemoved: mw.res.wantsSecurityRemoved || false,
})
if (!mw.res.wantsSecurityRemoved) {
span?.end()
return mw.next()
}
mw.debug('removing JS framebusting code')
mw.makeResStreamPlainText()
mw.incomingResStream.setEncoding('utf8')
const streamSpan = telemetry.startSpan({ name: `maybe:remove:security-resp:stream`, parentSpan: span, isVerbose })
mw.incomingResStream = mw.incomingResStream.pipe(rewriter.security({
isNotJavascript: !resContentTypeIsJavaScript(mw.incomingRes),
useAstSourceRewriting: mw.config.experimentalSourceRewriting,
modifyObstructiveThirdPartyCode: mw.config.experimentalModifyObstructiveThirdPartyCode && !mw.remoteStates.isPrimarySuperDomainOrigin(mw.req.proxiedUrl),
modifyObstructiveCode: mw.config.modifyObstructiveCode,
url: mw.req.proxiedUrl,
deferSourceMapRewrite: mw.deferSourceMapRewrite,
})).on('error', mw.onError).once('close', () => {
streamSpan?.end()
})
span?.end()
mw.next()
}
@@ -0,0 +1,146 @@
import crypto from 'crypto'
import _ from 'lodash'
import { URL } from 'url'
import { DocumentDomainInjection } from '@packages/network-tools'
import { resolveInjectionLevel, resolveWantsSecurityRemoved } from '@packages/network-policy'
import { telemetry } from '@packages/telemetry'
import { isVerboseTelemetry as isVerbose } from '../http'
import { cspHeaderNames, generateCspDirectives, nonceDirectives, parseCspHeaders } from '../http/util/csp-header'
import {
reqMatchesPolicyBasedOnDomain,
reqWillRenderHtml,
resContentTypeIs,
resContentTypeIsJavaScript,
} from '../http/util/document-preparation'
import type { ResponseInterceptionMiddlewareCtx } from './types'
/**
* Determine injection level and CSP nonce headers for proxied HTML responses.
*/
export async function setInjectionLevel (mw: ResponseInterceptionMiddlewareCtx): Promise<void> {
const span = telemetry.startSpan({ name: 'set:injection:level', parentSpan: mw.resMiddlewareSpan, isVerbose })
mw.res.isInitial = mw.req.cookies['__cypress.initial'] === 'true'
const isHTML = resContentTypeIs(mw.incomingRes, 'text/html')
const isRenderedHTML = reqWillRenderHtml(mw.req, mw.incomingRes)
if (isRenderedHTML) {
const origin = new URL(mw.req.proxiedUrl).origin
mw.getRenderedHTMLOrigins()[origin] = true
}
mw.debug('determine injection')
const documentDomainInjection = DocumentDomainInjection.InjectionBehavior(mw.config)
const isReqMatchSuperDomainOrigin = reqMatchesPolicyBasedOnDomain(
mw.req,
mw.remoteStates.current(),
documentDomainInjection,
)
span?.setAttributes({
isInitialInjection: mw.res.isInitial,
isHTML,
isRenderedHTML,
isReqMatchSuperDomainOrigin,
})
const urlDoesNotMatchPolicyBasedOnDomain = !reqMatchesPolicyBasedOnDomain(
mw.req,
mw.remoteStates.getPrimary(),
documentDomainInjection,
)
const isAUTFrame = mw.req.isAUTFrame
span?.setAttributes({
isAUTFrame,
urlDoesNotMatchPolicyBasedOnDomain,
})
if (mw.res.wantsInjection != null) {
span?.setAttributes({
isInjectionAlreadySet: true,
})
mw.debug('- already has injection: %s', mw.res.wantsInjection)
}
if (mw.res.wantsInjection == null) {
const level = resolveInjectionLevel({
hasFileServerError: !!mw.incomingRes.headers['x-cypress-file-server-error'],
isInitial: mw.res.isInitial,
isHTML,
isRenderedHTML: !!isRenderedHTML,
isReqMatchSuperDomainOrigin,
isAUTFrame,
urlDoesNotMatchPolicyBasedOnDomain,
})
if (level === 'partial' && mw.incomingRes.headers['x-cypress-file-server-error'] && !mw.res.isInitial) {
mw.debug('- partial injection (x-cypress-file-server-error)')
} else if (level === 'fullCrossOrigin') {
mw.debug('- cross origin injection')
} else if (level === false && !isHTML) {
mw.debug('- no injection (not html)')
} else if (level === 'full') {
mw.debug('- full injection')
} else if (level === false && !isRenderedHTML) {
mw.debug('- no injection (not rendered html)')
} else if (level === 'partial') {
mw.debug('- partial injection (default)')
}
mw.res.wantsInjection = level
}
if (mw.res.wantsInjection) {
mw.res.setHeader('Origin-Agent-Cluster', '?0')
const nonce = crypto.randomBytes(16).toString('base64')
cspHeaderNames.forEach((headerName) => {
const policyArray = parseCspHeaders(mw.res.getHeaders(), headerName)
const usedNonceDirectives = nonceDirectives
.filter((directive) => policyArray.some((policyMap) => policyMap.has(directive)))
if (usedNonceDirectives.length) {
mw.res.injectionNonce = nonce
const modifiedCspHeader = policyArray.map((policies) => {
usedNonceDirectives.forEach((availableNonceDirective) => {
if (policies.has(availableNonceDirective)) {
const cspScriptSrc = policies.get(availableNonceDirective) || []
policies.set(availableNonceDirective, [...cspScriptSrc, `'nonce-${nonce}'`])
}
})
return policies
}).map(generateCspDirectives)
mw.res.setHeader(headerName, modifiedCspHeader)
}
})
}
mw.res.wantsSecurityRemoved = resolveWantsSecurityRemoved({
modifyObstructiveCode: !!mw.config.modifyObstructiveCode,
experimentalModifyObstructiveThirdPartyCode: !!mw.config.experimentalModifyObstructiveThirdPartyCode,
wantsInjection: mw.res.wantsInjection,
isHTML,
isRenderedHTML: !!isRenderedHTML,
isReqMatchSuperDomainOrigin,
isJavaScript: resContentTypeIsJavaScript(mw.incomingRes),
})
span?.setAttributes({
wantsInjection: mw.res.wantsInjection,
wantsSecurityRemoved: mw.res.wantsSecurityRemoved,
})
mw.debug('injection levels: %o', _.pick(mw.res, 'isInitial', 'wantsInjection', 'wantsSecurityRemoved'))
span?.end()
mw.next()
}
+9 -310
View File
@@ -1,30 +1,23 @@
import charset from 'charset'
import crypto from 'crypto'
import iconv from 'iconv-lite'
import _ from 'lodash'
import { PassThrough, Readable } from 'stream'
import { URL } from 'url'
import zlib from 'zlib'
import { InterceptResponse } from '@packages/net-stubbing'
import { concatStream, httpUtils } from '@packages/network'
import { getDomainNameFromUrl, DocumentDomainInjection } from '@packages/network-tools'
import { toughCookieToAutomationCookie } from '@packages/server/lib/util/cookies'
import type { RemoteState } from '@packages/network-tools'
import { telemetry } from '@packages/telemetry'
import { hasServiceWorkerHeader, isVerboseTelemetry as isVerbose } from '.'
import { CookiesHelper } from './util/cookies'
import * as rewriter from './util/rewriter'
import { doesTopNeedToBeSimulated } from './util/top-simulation'
import * as errors from '@packages/errors'
import type Debug from 'debug'
import type { CookieOptions } from 'express'
import type { ResponseStreamOptions } from '@packages/types'
import type { CypressIncomingRequest, CypressOutgoingResponse } from '../types'
import type { CypressOutgoingResponse } from '../types'
import type { HttpMiddleware, HttpMiddlewareThis } from '.'
import type { IncomingMessage, IncomingHttpHeaders } from 'http'
import type { IncomingMessage } from 'http'
import { cspHeaderNames, generateCspDirectives, nonceDirectives, parseCspHeaders, problematicCspDirectives, unsupportedCSPDirectives } from './util/csp-header'
import { cspHeaderNames, generateCspDirectives, parseCspHeaders, problematicCspDirectives, unsupportedCSPDirectives } from './util/csp-header'
import { injectIntoServiceWorker } from './util/service-worker-injector'
import { validateHeaderName, validateHeaderValue } from 'http'
import error from '@packages/errors'
@@ -93,62 +86,6 @@ const zlibBrotliCompressOptions = {
}
// https://github.com/cypress-io/cypress/issues/1543
function getNodeCharsetFromResponse (headers: IncomingHttpHeaders, body: Buffer, debug: Debug.Debugger) {
const httpCharset = (charset(headers, body, 1024) || '').toLowerCase()
debug('inferred charset from response %o', { httpCharset })
if (iconv.encodingExists(httpCharset)) {
return httpCharset
}
// browsers default to latin1
return 'latin1'
}
function reqMatchesPolicyBasedOnDomain (req: CypressIncomingRequest, remoteState: RemoteState, documentDomainInjection: DocumentDomainInjection) {
if (remoteState.strategy === 'http') {
return documentDomainInjection.urlsMatch(
req.proxiedUrl,
remoteState.props || '',
)
}
if (remoteState.strategy === 'file') {
return req.proxiedUrl.startsWith(remoteState.origin)
}
return false
}
function reqWillRenderHtml (req: CypressIncomingRequest, res: IncomingMessage) {
// will this request be rendered in the browser, necessitating injection?
// https://github.com/cypress-io/cypress/issues/288
// don't inject if this is an XHR from jquery
if (req.headers['x-requested-with']) {
return
}
// don't inject if we didn't find both text/html and application/xhtml+xml,
const accept = req.headers['accept']
// only check the content-type value, if it exists, to contains some type of html mimetype
const contentType = res?.headers['content-type'] || ''
const contentTypeIsHtmlIfExists = contentType ? contentType.includes('html') : true
return accept && accept.includes('text/html') && accept.includes('application/xhtml+xml') && contentTypeIsHtmlIfExists
}
function resContentTypeIs (res: IncomingMessage, contentType: string) {
return (res.headers['content-type'] || '').includes(contentType)
}
function resContentTypeIsJavaScript (res: IncomingMessage) {
return _.some(
['application/javascript', 'application/x-javascript', 'text/javascript']
.map(_.partial(resContentTypeIs, res)),
)
}
const SUPPORTED_CONTENT_ENCODINGS = ['gzip', 'br'] as const
@@ -488,163 +425,8 @@ const MaybeSetOriginAgentClusterHeader: ResponseMiddleware = function () {
this.next()
}
const SetInjectionLevel: ResponseMiddleware = function () {
const span = telemetry.startSpan({ name: 'set:injection:level', parentSpan: this.resMiddlewareSpan, isVerbose })
this.res.isInitial = this.req.cookies['__cypress.initial'] === 'true'
const isHTML = resContentTypeIs(this.incomingRes, 'text/html')
const isRenderedHTML = reqWillRenderHtml(this.req, this.incomingRes)
if (isRenderedHTML) {
const origin = new URL(this.req.proxiedUrl).origin
this.getRenderedHTMLOrigins()[origin] = true
}
this.debug('determine injection')
const isReqMatchSuperDomainOrigin = reqMatchesPolicyBasedOnDomain(
this.req,
this.remoteStates.current(),
DocumentDomainInjection.InjectionBehavior(this.config),
)
span?.setAttributes({
isInitialInjection: this.res.isInitial,
isHTML,
isRenderedHTML,
isReqMatchSuperDomainOrigin,
})
const getInjectionLevel = () => {
if (this.incomingRes.headers['x-cypress-file-server-error'] && !this.res.isInitial) {
this.debug('- partial injection (x-cypress-file-server-error)')
return 'partial'
}
const documentDomainInjection = DocumentDomainInjection.InjectionBehavior(this.config)
// NOTE: Only inject fullCrossOrigin if the super domain origins do not match in order to keep parity with cypress application reloads
const urlDoesNotMatchPolicyBasedOnDomain = !reqMatchesPolicyBasedOnDomain(
this.req,
this.remoteStates.getPrimary(),
documentDomainInjection,
)
const isAUTFrame = this.req.isAUTFrame
const isHTMLLike = isHTML || isRenderedHTML
span?.setAttributes({
isAUTFrame,
urlDoesNotMatchPolicyBasedOnDomain,
})
if (urlDoesNotMatchPolicyBasedOnDomain && isAUTFrame && isHTMLLike) {
this.debug('- cross origin injection')
return 'fullCrossOrigin'
}
if (!isHTML || (!isReqMatchSuperDomainOrigin && !isAUTFrame)) {
this.debug('- no injection (not html)')
return false
}
if (this.res.isInitial && isHTMLLike) {
this.debug('- full injection')
return 'full'
}
if (!isRenderedHTML) {
this.debug('- no injection (not rendered html)')
return false
}
this.debug('- partial injection (default)')
return 'partial'
}
if (this.res.wantsInjection != null) {
span?.setAttributes({
isInjectionAlreadySet: true,
})
this.debug('- already has injection: %s', this.res.wantsInjection)
}
if (this.res.wantsInjection == null) {
this.res.wantsInjection = getInjectionLevel()
}
if (this.res.wantsInjection) {
// Chrome plans to make document.domain immutable in Chrome 109, with the default value
// of the Origin-Agent-Cluster header becoming 'true'. We explicitly disable this header
// so that we can continue to support tests that visit multiple subdomains in a single spec.
// https://github.com/cypress-io/cypress/issues/20147
//
// 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) &&
// if experimentalModifyObstructiveThirdPartyCode is enabled, we want to modify all framebusting code that is html or javascript that passes through the proxy
((this.config.experimentalModifyObstructiveThirdPartyCode
&& (isHTML || isRenderedHTML || resContentTypeIsJavaScript(this.incomingRes))) ||
this.res.wantsInjection === 'full' ||
this.res.wantsInjection === 'fullCrossOrigin' ||
// only modify JavasScript if matching the current origin policy or if experimentalModifyObstructiveThirdPartyCode is enabled (above)
(resContentTypeIsJavaScript(this.incomingRes) && isReqMatchSuperDomainOrigin))
span?.setAttributes({
wantsInjection: this.res.wantsInjection,
wantsSecurityRemoved: this.res.wantsSecurityRemoved,
})
this.debug('injection levels: %o', _.pick(this.res, 'isInitial', 'wantsInjection', 'wantsSecurityRemoved'))
span?.end()
this.next()
const SetInjectionLevel: ResponseMiddleware = async function () {
return this.networkPolicyCore.setInjectionLevel(this)
}
// https://github.com/cypress-io/cypress/issues/6480
@@ -931,95 +713,12 @@ const MaybeEndWithEmptyBody: ResponseMiddleware = function () {
this.next()
}
const MaybeInjectHtml: ResponseMiddleware = function () {
const span = telemetry.startSpan({ name: 'maybe:inject:html', parentSpan: this.resMiddlewareSpan, isVerbose })
span?.setAttributes({
wantsInjection: this.res.wantsInjection,
})
if (!this.res.wantsInjection) {
span?.end()
return this.next()
}
this.skipMiddleware('MaybeRemoveSecurity') // we only want to do one or the other
this.debug('injecting into HTML')
this.makeResStreamPlainText()
const streamSpan = telemetry.startSpan({ name: `maybe:inject:html-resp:stream`, parentSpan: span, isVerbose })
this.incomingResStream.pipe(concatStream(async (body) => {
const nodeCharset = getNodeCharsetFromResponse(this.incomingRes.headers, body, this.debug)
const decodedBody = iconv.decode(body, nodeCharset)
const injectedBody = await rewriter.html(decodedBody, {
cspNonce: this.res.injectionNonce,
domainName: getDomainNameFromUrl(this.req.proxiedUrl),
wantsInjection: this.res.wantsInjection,
wantsSecurityRemoved: this.res.wantsSecurityRemoved,
isNotJavascript: !resContentTypeIsJavaScript(this.incomingRes),
useAstSourceRewriting: this.config.experimentalSourceRewriting,
modifyObstructiveThirdPartyCode: this.config.experimentalModifyObstructiveThirdPartyCode && !this.remoteStates.isPrimarySuperDomainOrigin(this.req.proxiedUrl),
shouldInjectDocumentDomain: DocumentDomainInjection.InjectionBehavior(this.config).shouldInjectDocumentDomain(this.req.proxiedUrl),
modifyObstructiveCode: this.config.modifyObstructiveCode,
url: this.req.proxiedUrl,
deferSourceMapRewrite: this.deferSourceMapRewrite,
simulatedCookies: this.simulatedCookies,
})
const encodedBody = iconv.encode(injectedBody, nodeCharset)
const pt = new PassThrough
pt.write(encodedBody)
pt.end()
this.incomingResStream = pt
streamSpan?.end()
this.next()
})).on('error', this.onError).once('close', () => {
span?.end()
})
const MaybeInjectHtml: ResponseMiddleware = async function () {
return this.networkPolicyCore.injectHtml(this)
}
const MaybeRemoveSecurity: ResponseMiddleware = function () {
const span = telemetry.startSpan({ name: 'maybe:remove:security', parentSpan: this.resMiddlewareSpan, isVerbose })
span?.setAttributes({
wantsSecurityRemoved: this.res.wantsSecurityRemoved || false,
})
if (!this.res.wantsSecurityRemoved) {
span?.end()
return this.next()
}
this.debug('removing JS framebusting code')
this.makeResStreamPlainText()
this.incomingResStream.setEncoding('utf8')
const streamSpan = telemetry.startSpan({ name: `maybe:remove:security-resp:stream`, parentSpan: span, isVerbose })
this.incomingResStream = this.incomingResStream.pipe(rewriter.security({
isNotJavascript: !resContentTypeIsJavaScript(this.incomingRes),
useAstSourceRewriting: this.config.experimentalSourceRewriting,
modifyObstructiveThirdPartyCode: this.config.experimentalModifyObstructiveThirdPartyCode && !this.remoteStates.isPrimarySuperDomainOrigin(this.req.proxiedUrl),
modifyObstructiveCode: this.config.modifyObstructiveCode,
url: this.req.proxiedUrl,
deferSourceMapRewrite: this.deferSourceMapRewrite,
})).on('error', this.onError).once('close', () => {
streamSpan?.end()
})
span?.end()
this.next()
const MaybeRemoveSecurity: ResponseMiddleware = async function () {
return this.networkPolicyCore.removeSecurity(this)
}
const MaybeInjectServiceWorker: ResponseMiddleware = function () {
@@ -0,0 +1,46 @@
import _ from 'lodash'
import type { IncomingMessage } from 'http'
import type { RemoteState, DocumentDomainInjection } from '@packages/network-tools'
import type { CypressIncomingRequest } from '../../types'
export function reqMatchesPolicyBasedOnDomain (
req: CypressIncomingRequest,
remoteState: RemoteState,
documentDomainInjection: DocumentDomainInjection,
) {
if (remoteState.strategy === 'http') {
return documentDomainInjection.urlsMatch(
req.proxiedUrl,
remoteState.props || '',
)
}
if (remoteState.strategy === 'file') {
return req.proxiedUrl.startsWith(remoteState.origin)
}
return false
}
export function reqWillRenderHtml (req: CypressIncomingRequest, res: IncomingMessage) {
if (req.headers['x-requested-with']) {
return
}
const accept = req.headers['accept']
const contentType = res?.headers['content-type'] || ''
const contentTypeIsHtmlIfExists = contentType ? contentType.includes('html') : true
return accept && accept.includes('text/html') && accept.includes('application/xhtml+xml') && contentTypeIsHtmlIfExists
}
export function resContentTypeIs (res: IncomingMessage, contentType: string) {
return (res.headers['content-type'] || '').includes(contentType)
}
export function resContentTypeIsJavaScript (res: IncomingMessage) {
return _.some(
['application/javascript', 'application/x-javascript', 'text/javascript']
.map(_.partial(resContentTypeIs, res)),
)
}
@@ -0,0 +1,15 @@
import charset from 'charset'
import iconv from 'iconv-lite'
import type Debug from 'debug'
import type { IncomingHttpHeaders } from 'http'
export function getNodeCharsetFromResponse (headers: IncomingHttpHeaders, body: Buffer, debug: Debug.Debugger) {
const httpCharset = (charset(headers, body, 1024) || '').toLowerCase()
debug('inferred charset from response %o', { httpCharset })
if (iconv.encodingExists(httpCharset)) {
return httpCharset
}
return 'latin1'
}
@@ -12,7 +12,11 @@ import { allowDestroy } from '@packages/network'
import { DocumentDomainInjection, RemoteStates } from '@packages/network-tools'
import { EventEmitter } from 'events'
import { NetworkPolicyCore } from '@packages/network-policy'
import { ProxyRequestInterceptionAdapter, ProxyResponseInterceptionAdapter } from '../../lib/adapters'
import {
ProxyDocumentPreparationAdapter,
ProxyRequestInterceptionAdapter,
ProxyResponseInterceptionAdapter,
} from '../../lib/adapters'
import { Request as ServerRequest } from '@packages/server/lib/request'
const getFixture = async () => {}
@@ -57,6 +61,7 @@ describe('network stubbing', () => {
networkPolicyCore: new NetworkPolicyCore({
requestInterception: new ProxyRequestInterceptionAdapter(),
responseInterception: new ProxyResponseInterceptionAdapter(),
documentPreparation: new ProxyDocumentPreparationAdapter(),
}),
config,
middleware: defaultMiddleware,
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ProxyDocumentPreparationAdapter } from '../../../lib/adapters/proxy-document-preparation'
import { setInjectionLevel } from '../../../lib/adapters/set-injection-level'
import { injectHtml } from '../../../lib/adapters/inject-html'
import { removeSecurity } from '../../../lib/adapters/remove-security'
vi.mock('../../../lib/adapters/set-injection-level', () => {
return {
setInjectionLevel: vi.fn(),
}
})
vi.mock('../../../lib/adapters/inject-html', () => {
return {
injectHtml: vi.fn(),
}
})
vi.mock('../../../lib/adapters/remove-security', () => {
return {
removeSecurity: vi.fn(),
}
})
describe('ProxyDocumentPreparationAdapter', () => {
beforeEach(() => {
vi.mocked(setInjectionLevel).mockReset()
vi.mocked(injectHtml).mockReset()
vi.mocked(removeSecurity).mockReset()
})
it('delegates setInjectionLevel to setInjectionLevel helper', async () => {
const adapter = new ProxyDocumentPreparationAdapter()
const ctx = { res: { wantsInjection: null } }
vi.mocked(setInjectionLevel).mockResolvedValue(undefined)
await adapter.setInjectionLevel(ctx)
expect(setInjectionLevel).toHaveBeenCalledOnce()
expect(setInjectionLevel).toHaveBeenCalledWith(ctx)
})
it('delegates injectHtml to injectHtml helper', async () => {
const adapter = new ProxyDocumentPreparationAdapter()
const ctx = { res: { wantsInjection: 'full' } }
vi.mocked(injectHtml).mockResolvedValue(undefined)
await adapter.injectHtml(ctx)
expect(injectHtml).toHaveBeenCalledOnce()
expect(injectHtml).toHaveBeenCalledWith(ctx)
})
it('delegates removeSecurity to removeSecurity helper', async () => {
const adapter = new ProxyDocumentPreparationAdapter()
const ctx = { res: { wantsSecurityRemoved: true } }
vi.mocked(removeSecurity).mockResolvedValue(undefined)
await adapter.removeSecurity(ctx)
expect(removeSecurity).toHaveBeenCalledOnce()
expect(removeSecurity).toHaveBeenCalledWith(ctx)
})
})
+6 -1
View File
@@ -1,11 +1,16 @@
import { HttpMiddleware, HttpStages, _runStage } from '../../../lib/http'
import { NetworkPolicyCore } from '@packages/network-policy'
import { ProxyRequestInterceptionAdapter, ProxyResponseInterceptionAdapter } from '../../../lib/adapters'
import {
ProxyDocumentPreparationAdapter,
ProxyRequestInterceptionAdapter,
ProxyResponseInterceptionAdapter,
} from '../../../lib/adapters'
export function createTestNetworkPolicyCore () {
return new NetworkPolicyCore({
requestInterception: new ProxyRequestInterceptionAdapter(),
responseInterception: new ProxyResponseInterceptionAdapter(),
documentPreparation: new ProxyDocumentPreparationAdapter(),
})
}
+6 -1
View File
@@ -1,6 +1,10 @@
import type EventEmitter from 'events'
import { NetworkProxy, BrowserPreRequest } from '@packages/proxy'
import { ProxyRequestInterceptionAdapter, ProxyResponseInterceptionAdapter } from '@packages/proxy/lib/adapters'
import {
ProxyDocumentPreparationAdapter,
ProxyRequestInterceptionAdapter,
ProxyResponseInterceptionAdapter,
} from '@packages/proxy/lib/adapters'
import { defaultMiddleware } from '@packages/proxy/lib/http'
import { netStubbingState, NetStubbingState } from '@packages/net-stubbing'
import type { NetworkInterceptionRuntime, ForNetworkPolicyRegistration } from '@packages/network-policy'
@@ -42,6 +46,7 @@ export function createProxyRuntime (deps: CreateProxyRuntimeDeps): ProxyNetworkR
const networkPolicyCore = new NetworkPolicyCore({
requestInterception: new ProxyRequestInterceptionAdapter(),
responseInterception: new ProxyResponseInterceptionAdapter(),
documentPreparation: new ProxyDocumentPreparationAdapter(),
})
registerDefaultNetworkPolicies(networkPolicyRegistration, deps.config)
@@ -1,9 +1,13 @@
import { blocked } from '@packages/network'
import { BlockedHosts } from '@packages/network-policy'
import { BlockedHosts, CspAllowList, DocumentRewrite } from '@packages/network-policy'
import type { ForNetworkPolicyRegistration } from '@packages/network-policy'
export type RegisterDefaultNetworkPoliciesConfig = {
blockHosts?: string | string[] | null
experimentalCspAllowList?: boolean | string[] | null
modifyObstructiveCode?: boolean
experimentalModifyObstructiveThirdPartyCode?: boolean
experimentalSourceRewriting?: boolean
}
/**
@@ -18,4 +22,14 @@ export function registerDefaultNetworkPolicies (
blockHosts: config.blockHosts,
matchesBlockedHost: blocked.matches,
}))
policies.add(CspAllowList({
experimentalCspAllowList: config.experimentalCspAllowList,
}))
policies.add(DocumentRewrite({
modifyObstructiveCode: config.modifyObstructiveCode,
experimentalModifyObstructiveThirdPartyCode: config.experimentalModifyObstructiveThirdPartyCode,
experimentalSourceRewriting: config.experimentalSourceRewriting,
}))
}
@@ -44,12 +44,33 @@ describe('lib/network-runtime', () => {
const policies = runtime.networkPolicyRegistration.getPolicies()
expect(policies).to.have.length(1)
expect(policies).to.have.length(3)
expect(policies[0].name).to.eq('blocked-hosts')
expect(policies[0].when({ url: 'http://localhost:3131/' })).to.be.true
expect(runtime.networkPolicyCore).to.be.instanceOf(NetworkPolicyCore)
expect(runtime.networkPolicyCore.requestInterception).to.exist
expect(runtime.networkPolicyCore.responseInterception).to.exist
expect(runtime.networkPolicyCore.documentPreparation).to.exist
})
it('registers configurator CSP and document rewrite policies at startup', () => {
const runtime = createProxyRuntime({
...baseDeps(),
config: {
clientRoute: '/__/',
responseTimeout: 30000,
experimentalCspAllowList: ['script-src'],
modifyObstructiveCode: true,
} as Cypress.Config,
})
const policies = runtime.networkPolicyRegistration.getPolicies()
expect(policies.map((p) => p.name)).to.include.members([
'blocked-hosts',
'csp-allow-list',
'document-rewrite',
])
})
it('handleHttpRequest delegates to networkProxy.handleHttpRequest', async () => {