mirror of
https://github.com/cypress-io/cypress.git
synced 2026-05-25 01:49:06 -05:00
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:
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user