refactor(proxy): network capture and cookie state adapters

Stage 6 (6a+6b): capture/cookie driven ports and middleware delegation.

- Add ProxyNetworkCaptureAdapter and ProxyCookieStateAdapter in proxy/lib/adapters
- Expand ForNetworkCapture / ForCookieState and wire through NetworkPolicyCore
- Delegate RecordRequest, RecordResponse, and cookie middleware through the core

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Cacie Prins
2026-05-22 12:24:37 -04:00
parent 9c59944cc6
commit 8f137a6722
29 changed files with 724 additions and 326 deletions
+1
View File
@@ -26,6 +26,7 @@
"@packages/config": "0.0.0-development",
"@packages/errors": "0.0.0-development",
"@packages/net-stubbing": "0.0.0-development",
"@packages/network-policy": "0.0.0-development",
"@packages/network-tools": "0.0.0-development",
"@packages/proxy": "0.0.0-development",
"@packages/rewriter": "0.0.0-development",
@@ -0,0 +1,22 @@
import type {
CommandLogInterceptionInput,
CommandLogInterceptionResult,
ForCommandLog,
} from '@packages/network-policy'
export type DriverCommandLogAdapterOptions = {
logInterception: (interception: unknown, route: unknown) => CommandLogInterceptionResult
}
/** Driver-side {@link ForCommandLog} adapter — delegates to {@link Cypress.ProxyLogging}. */
export class DriverCommandLogAdapter implements ForCommandLog {
constructor (private readonly options: DriverCommandLogAdapterOptions) {}
notifyIncomingRequest (_ctx: unknown): void {
// Server-side only — driver receives incoming requests via socket events.
}
logInterception (input: CommandLogInterceptionInput): CommandLogInterceptionResult {
return this.options.logInterception(input.interception, input.route)
}
}
@@ -0,0 +1,3 @@
export { DriverCommandLogAdapter } from './driver-command-log'
export type { DriverCommandLogAdapterOptions } from './driver-command-log'
@@ -17,6 +17,7 @@ import type { HandlerFn, HandlerResult } from '.'
import Bluebird from 'bluebird'
import type { NetEvent } from '@packages/net-stubbing/lib/types'
import Debug from 'debug'
import { DriverCommandLogAdapter } from '../adapters'
const debug = Debug('cypress:driver:net-stubbing:events:before-request')
@@ -306,7 +307,11 @@ export const onBeforeRequest: HandlerFn<CyHttpMessages.IncomingRequest> = (Cypre
resolve = _resolve
})
request.setLogFlag = Cypress.ProxyLogging.logInterception(request, route)?.setFlag || (() => {})
const commandLog = new DriverCommandLogAdapter({
logInterception: (interception, route) => Cypress.ProxyLogging.logInterception(interception as Interception, route),
})
request.setLogFlag = commandLog.logInterception({ interception: request, route })?.setFlag || (() => {})
// TODO: this misnomer is a holdover from XHR, should be numRequests
route.log.set('numResponses', (route.log.get('numResponses') || 0) + 1)
@@ -0,0 +1,23 @@
import { describe, it, expect, vi } from 'vitest'
import { DriverCommandLogAdapter } from '../../../src/cy/net-stubbing/adapters/driver-command-log'
describe('DriverCommandLogAdapter', () => {
it('delegates logInterception to supplied proxy logging hook', () => {
const setFlag = vi.fn()
const logInterception = vi.fn().mockReturnValue({ setFlag })
const adapter = new DriverCommandLogAdapter({ logInterception })
const interception = { requestId: 'req-1' }
const route = { routeId: 'route-1' }
const result = adapter.logInterception({ interception, route })
expect(logInterception).toHaveBeenCalledWith(interception, route)
expect(result?.setFlag).toBe(setFlag)
})
it('notifyIncomingRequest is a no-op on the driver', () => {
const adapter = new DriverCommandLogAdapter({ logInterception: vi.fn() })
expect(() => adapter.notifyIncomingRequest({})).not.toThrow()
})
})
@@ -8,4 +8,6 @@ export * from './merge-handler-result'
export * from './document-preparation'
export * from './request-logging'
export * from './network-policy-core'
@@ -122,6 +122,56 @@ export class NetworkPolicyCore {
return port.removeSecurity(ctx)
}
notifyIncomingRequest (ctx: unknown): void {
const port = this.options.commandLog
if (!port) {
throw new Error('NetworkPolicyCore.commandLog is not configured')
}
return port.notifyIncomingRequest(ctx)
}
async attachCrossOriginCookies (ctx: unknown): Promise<void> {
const port = this.options.cookieState
if (!port) {
throw new Error('NetworkPolicyCore.cookieState is not configured')
}
return port.attachCrossOriginCookies(ctx)
}
async copyCookiesFromResponse (ctx: unknown): Promise<void> {
const port = this.options.cookieState
if (!port) {
throw new Error('NetworkPolicyCore.cookieState is not configured')
}
return port.copyCookiesFromResponse(ctx)
}
async notifyResponseStreamReceived (ctx: unknown): Promise<void> {
const port = this.options.networkCapture
if (!port) {
throw new Error('NetworkPolicyCore.networkCapture is not configured')
}
return port.notifyResponseStreamReceived(ctx)
}
notifyResponseEndedWithEmptyBody (ctx: unknown, options: { isCached: boolean }): void {
const port = this.options.networkCapture
if (!port) {
throw new Error('NetworkPolicyCore.networkCapture is not configured')
}
return port.notifyResponseEndedWithEmptyBody(ctx, options)
}
get requestInterception (): ForRequestInterception | undefined {
return this.options.requestInterception
}
@@ -133,4 +183,16 @@ export class NetworkPolicyCore {
get documentPreparation (): ForDocumentPreparation | undefined {
return this.options.documentPreparation
}
get networkCapture (): ForNetworkCapture | undefined {
return this.options.networkCapture
}
get cookieState (): ForCookieState | undefined {
return this.options.cookieState
}
get commandLog (): ForCommandLog | undefined {
return this.options.commandLog
}
}
@@ -0,0 +1,23 @@
export type ShouldLogRequestFacts = {
matchingRoutes?: Array<{ staticResponse?: { log?: boolean } }>
resourceType?: string
}
/**
* Pure intercept/request logging decision — extracted from proxy `SendToDriver` middleware.
*/
export function shouldLogRequest (facts: ShouldLogRequestFacts): boolean {
if (facts.matchingRoutes?.length) {
const lastMatchingRoute = facts.matchingRoutes[0]
if (!lastMatchingRoute.staticResponse) {
return true
}
if (lastMatchingRoute.staticResponse.log !== undefined) {
return Boolean(lastMatchingRoute.staticResponse.log)
}
}
return facts.resourceType === 'fetch' || facts.resourceType === 'xhr'
}
@@ -33,21 +33,36 @@ export interface ForDocumentPreparation {
* Driven port: Test Replay / protocol capture at the proxy boundary.
*/
export interface ForNetworkCapture {
// Expanded in Stage 6a.
notifyResponseStreamReceived (ctx: unknown): Promise<void>
notifyResponseEndedWithEmptyBody (ctx: unknown, options: { isCached: boolean }): void
}
/**
* Driven port: cookie jar read/write for proxied requests.
*/
export interface ForCookieState {
// Expanded in Stage 6a.
attachCrossOriginCookies (ctx: unknown): Promise<void>
copyCookiesFromResponse (ctx: unknown): Promise<void>
}
export type CommandLogInterceptionInput = {
interception: unknown
route: unknown
}
export type CommandLogInterceptionResult = {
setFlag?: (flag: string) => void
} | undefined
/**
* Driven port: command log entries for intercept provenance.
*/
export interface ForCommandLog {
// Expanded in Stage 6a.
notifyIncomingRequest (ctx: unknown): void
logInterception (input: CommandLogInterceptionInput): CommandLogInterceptionResult
}
/**
@@ -210,4 +210,32 @@ describe('NetworkPolicyCore', () => {
await expect(core.injectHtml({})).rejects.toThrow(/documentPreparation/)
await expect(core.removeSecurity({})).rejects.toThrow(/documentPreparation/)
})
it('delegates capture, cookie, and command log ports', async () => {
const notifyIncomingRequest = vi.fn()
const attachCrossOriginCookies = vi.fn().mockResolvedValue(undefined)
const copyCookiesFromResponse = vi.fn().mockResolvedValue(undefined)
const notifyResponseStreamReceived = vi.fn().mockResolvedValue(undefined)
const notifyResponseEndedWithEmptyBody = vi.fn()
const core = new NetworkPolicyCore({
commandLog: { notifyIncomingRequest, logInterception: vi.fn() },
cookieState: { attachCrossOriginCookies, copyCookiesFromResponse },
networkCapture: { notifyResponseStreamReceived, notifyResponseEndedWithEmptyBody },
})
const ctx = { req: {} }
core.notifyIncomingRequest(ctx)
await core.attachCrossOriginCookies(ctx)
await core.copyCookiesFromResponse(ctx)
await core.notifyResponseStreamReceived(ctx)
core.notifyResponseEndedWithEmptyBody(ctx, { isCached: false })
expect(notifyIncomingRequest).toHaveBeenCalledWith(ctx)
expect(attachCrossOriginCookies).toHaveBeenCalledWith(ctx)
expect(copyCookiesFromResponse).toHaveBeenCalledWith(ctx)
expect(notifyResponseStreamReceived).toHaveBeenCalledWith(ctx)
expect(notifyResponseEndedWithEmptyBody).toHaveBeenCalledWith(ctx, { isCached: false })
})
})
@@ -0,0 +1,24 @@
import { describe, it, expect } from 'vitest'
import { shouldLogRequest } from '../../lib/core/request-logging'
describe('core/request-logging', () => {
it('logs intercept routes without static responses by default', () => {
expect(shouldLogRequest({
matchingRoutes: [{ staticResponse: undefined }],
resourceType: 'image',
})).toBe(true)
})
it('respects staticResponse.log when set', () => {
expect(shouldLogRequest({
matchingRoutes: [{ staticResponse: { log: false } }],
resourceType: 'xhr',
})).toBe(false)
})
it('logs xhr and fetch when no intercept routes match', () => {
expect(shouldLogRequest({ resourceType: 'xhr' })).toBe(true)
expect(shouldLogRequest({ resourceType: 'fetch' })).toBe(true)
expect(shouldLogRequest({ resourceType: 'image' })).toBe(false)
})
})
@@ -0,0 +1,79 @@
import { telemetry } from '@packages/telemetry'
import { getSameSiteContext, shouldAttachAndSetCookies, addCookieJarCookiesToRequest } from '../http/util/cookies'
import { doesTopNeedToBeSimulated } from '../http/util/top-simulation'
import { isVerboseTelemetry as isVerbose } from '../http'
import * as errors from '@packages/errors'
import type { RequestInterceptionMiddlewareCtx } from './types'
/**
* Attach cross-origin cookies from the server-side cookie jar to proxied requests.
*/
export async function attachCrossOriginCookies (mw: RequestInterceptionMiddlewareCtx): Promise<void> {
const span = telemetry.startSpan({ name: 'maybe:attach:cross:origin:cookies', parentSpan: mw.reqMiddlewareSpan, isVerbose })
const doesTopNeedSimulation = doesTopNeedToBeSimulated(mw)
span?.setAttributes({
doesTopNeedToBeSimulated: doesTopNeedSimulation,
resourceType: mw.req.resourceType,
})
if (!doesTopNeedSimulation) {
span?.end()
return mw.next()
}
if (mw.req.isSyncRequest) {
errors.warning('SYNCHRONOUS_XHR_REQUEST_COOKIES_NOT_APPLIED', mw.req.proxiedUrl)
}
const currentAUTUrl = mw.getAUTUrl()
const shouldCookiesBeAttachedToRequest = shouldAttachAndSetCookies(mw.req.proxiedUrl, currentAUTUrl, mw.req.resourceType, mw.req.credentialsLevel, mw.req.isAUTFrame)
span?.setAttributes({
currentAUTUrl,
shouldCookiesBeAttachedToRequest,
})
mw.debug(`should cookies be attached to request?: ${shouldCookiesBeAttachedToRequest}`)
if (!shouldCookiesBeAttachedToRequest) {
span?.end()
return mw.next()
}
const sameSiteContext = getSameSiteContext(
currentAUTUrl,
mw.req.proxiedUrl,
mw.req.isAUTFrame,
)
span?.setAttributes({
sameSiteContext,
currentAUTUrl,
isAUTFrame: mw.req.isAUTFrame,
})
const applicableCookiesInCookieJar = mw.getCookieJar().getCookies(mw.req.proxiedUrl, sameSiteContext)
const cookiesOnRequest = (mw.req.headers['cookie'] || '').split('; ')
const existingCookiesInJar = applicableCookiesInCookieJar.join('; ')
const addedCookiesFromHeader = cookiesOnRequest.join('; ')
mw.debug('existing cookies on request from cookie jar: %s', existingCookiesInJar)
mw.debug('add cookies to request from header: %s', addedCookiesFromHeader)
mw.req.headers['cookie'] = addCookieJarCookiesToRequest(applicableCookiesInCookieJar, cookiesOnRequest) || undefined
span?.setAttributes({
existingCookiesInJar,
addedCookiesFromHeader,
cookieHeader: mw.req.headers['cookie'],
})
mw.debug('cookies being sent with request: %s', mw.req.headers['cookie'])
span?.end()
mw.next()
}
@@ -0,0 +1,119 @@
import { URL } from 'url'
import { telemetry } from '@packages/telemetry'
import { toughCookieToAutomationCookie } from '@packages/server/lib/util/cookies'
import { CookiesHelper } from '../http/util/cookies'
import { doesTopNeedToBeSimulated } from '../http/util/top-simulation'
import { isVerboseTelemetry as isVerbose } from '../http'
import * as errors from '@packages/errors'
import type { ResponseInterceptionMiddlewareCtx } from './types'
function setSimulatedCookies (mw: ResponseInterceptionMiddlewareCtx) {
if (mw.res.wantsInjection !== 'fullCrossOrigin') return
const defaultDomain = (new URL(mw.req.proxiedUrl)).hostname
const allCookiesForRequest = mw.getCookieJar()
.getCookies(mw.req.proxiedUrl)
.map((cookie) => toughCookieToAutomationCookie(cookie, defaultDomain))
mw.simulatedCookies = allCookiesForRequest
}
/**
* Capture Set-Cookie headers into the server-side cookie jar and browser automation.
*/
export async function copyCookiesFromResponse (mw: ResponseInterceptionMiddlewareCtx): Promise<void> {
const span = telemetry.startSpan({ name: 'maybe:copy:cookies:from:incoming:res', parentSpan: mw.resMiddlewareSpan, isVerbose })
const cookies: string | string[] | undefined = mw.incomingRes.headers['set-cookie']
const areCookiesPresent = !cookies || !cookies.length
span?.setAttributes({
areCookiesPresent,
})
if (areCookiesPresent) {
setSimulatedCookies(mw)
span?.end()
return mw.next()
}
const doesTopNeedSimulating = doesTopNeedToBeSimulated(mw)
span?.setAttributes({
doesTopNeedSimulating,
})
const appendCookie = (cookie: string) => {
const headerName = 'Set-Cookie'
try {
mw.res.append(headerName, cookie)
} catch (err) {
mw.debug(`failed to append header ${headerName}, continuing %o`, { err, cookie })
}
}
if (!doesTopNeedSimulating) {
([] as string[]).concat(cookies).forEach((cookie) => {
appendCookie(cookie)
})
span?.end()
return mw.next()
}
const cookiesHelper = new CookiesHelper({
cookieJar: mw.getCookieJar(),
currentAUTUrl: mw.getAUTUrl(),
debug: mw.debug,
request: {
url: mw.req.proxiedUrl,
isAUTFrame: mw.req.isAUTFrame,
doesTopNeedSimulating,
resourceType: mw.req.resourceType,
credentialLevel: mw.req.credentialsLevel,
},
})
await cookiesHelper.capturePreviousCookies()
;([] as string[]).concat(cookies).forEach((cookie) => {
cookiesHelper.setCookie(cookie)
appendCookie(cookie)
})
setSimulatedCookies(mw)
const addedCookies = await cookiesHelper.getAddedCookies()
const wereSimCookiesAdded = addedCookies.length
span?.setAttributes({
wereSimCookiesAdded,
})
if (!wereSimCookiesAdded) {
span?.end()
return mw.next()
}
if (mw.req.isSyncRequest) {
errors.warning('SYNCHRONOUS_XHR_REQUEST_COOKIES_NOT_SET', mw.req.proxiedUrl)
span?.end()
return mw.next()
}
mw.serverBus.once('cross:origin:cookies:received', () => {
span?.end()
mw.next()
})
mw.serverBus.emit('cross:origin:cookies', addedCookies)
}
+14
View File
@@ -4,6 +4,12 @@ export { ProxyResponseInterceptionAdapter } from './proxy-response-interception'
export { ProxyDocumentPreparationAdapter } from './proxy-document-preparation'
export { ProxyNetworkCaptureAdapter } from './proxy-network-capture'
export { ProxyCookieStateAdapter } from './proxy-cookie-state'
export { ProxyCommandLogAdapter } from './proxy-command-log'
export { correlateBrowserPreRequest } from './correlate-browser-pre-request'
export { sendRequestOutgoing } from './send-request-outgoing'
@@ -14,4 +20,12 @@ export { injectHtml } from './inject-html'
export { removeSecurity } from './remove-security'
export { sendToDriver } from './send-to-driver'
export { attachCrossOriginCookies } from './attach-cross-origin-cookies'
export { copyCookiesFromResponse } from './copy-cookies-from-response'
export { notifyResponseEndedWithEmptyBody, notifyResponseStreamReceived } from './network-capture'
export type { RequestInterceptionMiddlewareCtx, ResponseInterceptionMiddlewareCtx } from './types'
@@ -0,0 +1,71 @@
import type { ResponseStreamOptions } from '@packages/types'
import { telemetry } from '@packages/telemetry'
import { isVerboseTelemetry as isVerbose } from '../http'
import { getOriginalRequestId } from '../http/util/protocol-capture'
import type { ResponseInterceptionMiddlewareCtx } from './types'
/**
* Notify the protocol manager that a response stream is available for capture.
*/
export async function notifyResponseStreamReceived (mw: ResponseInterceptionMiddlewareCtx): Promise<void> {
if (!mw.protocolManager || !mw.req.browserPreRequest?.requestId) {
return
}
const preRequest = mw.req.browserPreRequest
const requestId = getOriginalRequestId(preRequest.requestId)
const span = telemetry.startSpan({ name: 'gzip:body:protocol-notification', parentSpan: mw.resMiddlewareSpan, isVerbose })
const streamOptions: ResponseStreamOptions = {
requestId,
responseHeaders: mw.incomingRes.headers,
isAlreadyGunzipped: mw.isGunzipped,
isAlreadyBrotliDecompressed: mw.isBrotliDecompressed,
responseStream: mw.incomingResStream,
res: mw.res,
timings: {
cdpRequestWillBeSentTimestamp: preRequest.cdpRequestWillBeSentTimestamp,
cdpRequestWillBeSentReceivedTimestamp: preRequest.cdpRequestWillBeSentReceivedTimestamp,
proxyRequestReceivedTimestamp: preRequest.proxyRequestReceivedTimestamp,
cdpLagDuration: preRequest.cdpLagDuration,
proxyRequestCorrelationDuration: preRequest.proxyRequestCorrelationDuration,
},
}
const resultingStream = mw.protocolManager.responseStreamReceived(streamOptions)
if (resultingStream) {
mw.incomingResStream = resultingStream.on('error', mw.onError).once('close', () => {
span?.end()
})
} else {
span?.end()
}
}
/**
* Notify the protocol manager that a response ended with an empty body.
*/
export function notifyResponseEndedWithEmptyBody (
mw: ResponseInterceptionMiddlewareCtx,
options: { isCached: boolean },
): void {
if (!mw.protocolManager || !mw.req.browserPreRequest?.requestId) {
return
}
const requestId = getOriginalRequestId(mw.req.browserPreRequest.requestId)
mw.protocolManager.responseEndedWithEmptyBody({
requestId,
isCached: options.isCached,
timings: {
cdpRequestWillBeSentTimestamp: mw.req.browserPreRequest.cdpRequestWillBeSentTimestamp,
cdpRequestWillBeSentReceivedTimestamp: mw.req.browserPreRequest.cdpRequestWillBeSentReceivedTimestamp,
proxyRequestReceivedTimestamp: mw.req.browserPreRequest.proxyRequestReceivedTimestamp,
cdpLagDuration: mw.req.browserPreRequest.cdpLagDuration,
proxyRequestCorrelationDuration: mw.req.browserPreRequest.proxyRequestCorrelationDuration,
},
})
}
@@ -0,0 +1,14 @@
import type { CommandLogInterceptionInput, CommandLogInterceptionResult, ForCommandLog } from '@packages/network-policy'
import { sendToDriver } from './send-to-driver'
import type { RequestInterceptionMiddlewareCtx } from './types'
/** Server-side {@link ForCommandLog} adapter — emits incoming request events to the driver. */
export class ProxyCommandLogAdapter implements ForCommandLog {
notifyIncomingRequest (ctx: unknown): void {
sendToDriver(ctx as RequestInterceptionMiddlewareCtx)
}
logInterception (_input: CommandLogInterceptionInput): CommandLogInterceptionResult {
return undefined
}
}
@@ -0,0 +1,15 @@
import type { ForCookieState } from '@packages/network-policy'
import { attachCrossOriginCookies } from './attach-cross-origin-cookies'
import { copyCookiesFromResponse } from './copy-cookies-from-response'
import type { RequestInterceptionMiddlewareCtx, ResponseInterceptionMiddlewareCtx } from './types'
/** {@link ForCookieState} adapter — delegates to legacy proxy cookie jar middleware. */
export class ProxyCookieStateAdapter implements ForCookieState {
attachCrossOriginCookies (ctx: unknown): Promise<void> {
return attachCrossOriginCookies(ctx as RequestInterceptionMiddlewareCtx)
}
copyCookiesFromResponse (ctx: unknown): Promise<void> {
return copyCookiesFromResponse(ctx as ResponseInterceptionMiddlewareCtx)
}
}
@@ -0,0 +1,14 @@
import type { ForNetworkCapture } from '@packages/network-policy'
import { notifyResponseEndedWithEmptyBody, notifyResponseStreamReceived } from './network-capture'
import type { ResponseInterceptionMiddlewareCtx } from './types'
/** {@link ForNetworkCapture} adapter — delegates to legacy proxy protocol capture hooks. */
export class ProxyNetworkCaptureAdapter implements ForNetworkCapture {
notifyResponseStreamReceived (ctx: unknown): Promise<void> {
return notifyResponseStreamReceived(ctx as ResponseInterceptionMiddlewareCtx)
}
notifyResponseEndedWithEmptyBody (ctx: unknown, options: { isCached: boolean }): void {
return notifyResponseEndedWithEmptyBody(ctx as ResponseInterceptionMiddlewareCtx, options)
}
}
@@ -0,0 +1,28 @@
import { shouldLogRequest } from '@packages/network-policy'
import { telemetry } from '@packages/telemetry'
import { isVerboseTelemetry as isVerbose } from '../http'
import type { RequestInterceptionMiddlewareCtx } from './types'
/**
* Emit incoming request events to the driver for proxy command logging.
*/
export function sendToDriver (mw: RequestInterceptionMiddlewareCtx): void {
const span = telemetry.startSpan({ name: 'send:to:driver', parentSpan: mw.reqMiddlewareSpan, isVerbose })
const shouldLogReq = shouldLogRequest({
matchingRoutes: mw.req.matchingRoutes,
resourceType: mw.req.resourceType,
})
if (shouldLogReq && mw.req.browserPreRequest) {
mw.socket.toDriver('request:event', 'incoming:request', mw.req.browserPreRequest)
}
span?.setAttributes({
shouldLogReq,
hasBrowserPreRequest: !!mw.req.browserPreRequest,
})
span?.end()
mw.next()
}
+3 -109
View File
@@ -3,15 +3,10 @@ import { blocked } from '@packages/network'
import { InterceptRequest, SetMatchingRoutes } from '@packages/net-stubbing'
import { telemetry } from '@packages/telemetry'
import { isVerboseTelemetry as isVerbose } from '.'
import {
addCookieJarCookiesToRequest, getSameSiteContext, shouldAttachAndSetCookies,
} from './util/cookies'
import { doesTopNeedToBeSimulated } from './util/top-simulation'
import { resourceTypeAndCredentialManager } from '../resourceTypeAndCredentialManager'
import type { HttpMiddleware } from './'
import type { CypressIncomingRequest } from '../types'
import { getSupportedAcceptEncoding, urlMatchesOriginProtectionSpace } from '@packages/network-tools'
import * as errors from '@packages/errors'
// do not use a debug namespace in this file - use the per-request `this.debug` instead
// available as cypress-verbose:proxy:http
@@ -139,113 +134,12 @@ const FormatCookiesIfApplicable: RequestMiddleware = function () {
return this.next()
}
const MaybeAttachCrossOriginCookies: RequestMiddleware = function () {
const span = telemetry.startSpan({ name: 'maybe:attach:cross:origin:cookies', parentSpan: this.reqMiddlewareSpan, isVerbose })
const doesTopNeedSimulation = doesTopNeedToBeSimulated(this)
span?.setAttributes({
doesTopNeedToBeSimulated: doesTopNeedSimulation,
resourceType: this.req.resourceType,
})
if (!doesTopNeedSimulation) {
span?.end()
return this.next()
}
if (this.req.isSyncRequest) {
errors.warning('SYNCHRONOUS_XHR_REQUEST_COOKIES_NOT_APPLIED', this.req.proxiedUrl)
}
// Top needs to be simulated since the AUT is in a cross origin state. Get the "requested with" and credentials and see what cookies need to be attached
const currentAUTUrl = this.getAUTUrl()
const shouldCookiesBeAttachedToRequest = shouldAttachAndSetCookies(this.req.proxiedUrl, currentAUTUrl, this.req.resourceType, this.req.credentialsLevel, this.req.isAUTFrame)
span?.setAttributes({
currentAUTUrl,
shouldCookiesBeAttachedToRequest,
})
this.debug(`should cookies be attached to request?: ${shouldCookiesBeAttachedToRequest}`)
if (!shouldCookiesBeAttachedToRequest) {
span?.end()
return this.next()
}
const sameSiteContext = getSameSiteContext(
currentAUTUrl,
this.req.proxiedUrl,
this.req.isAUTFrame,
)
span?.setAttributes({
sameSiteContext,
currentAUTUrl,
isAUTFrame: this.req.isAUTFrame,
})
const applicableCookiesInCookieJar = this.getCookieJar().getCookies(this.req.proxiedUrl, sameSiteContext)
const cookiesOnRequest = (this.req.headers['cookie'] || '').split('; ')
const existingCookiesInJar = applicableCookiesInCookieJar.join('; ')
const addedCookiesFromHeader = cookiesOnRequest.join('; ')
this.debug('existing cookies on request from cookie jar: %s', existingCookiesInJar)
this.debug('add cookies to request from header: %s', addedCookiesFromHeader)
// if the cookie header is empty (i.e. ''), set it to undefined for expected behavior
this.req.headers['cookie'] = addCookieJarCookiesToRequest(applicableCookiesInCookieJar, cookiesOnRequest) || undefined
span?.setAttributes({
existingCookiesInJar,
addedCookiesFromHeader,
cookieHeader: this.req.headers['cookie'],
})
this.debug('cookies being sent with request: %s', this.req.headers['cookie'])
span?.end()
this.next()
}
function shouldLog (req: CypressIncomingRequest) {
// 1. Any matching `cy.intercept()` should cause `req` to be logged by default, unless `log: false` is passed explicitly.
if (req.matchingRoutes?.length) {
const lastMatchingRoute = req.matchingRoutes[0]
if (!lastMatchingRoute.staticResponse) {
// No StaticResponse is set, therefore the request must be logged.
return true
}
if (lastMatchingRoute.staticResponse.log !== undefined) {
return Boolean(lastMatchingRoute.staticResponse.log)
}
}
// 2. Otherwise, only log if it is an XHR or fetch.
return req.resourceType === 'fetch' || req.resourceType === 'xhr'
const MaybeAttachCrossOriginCookies: RequestMiddleware = async function () {
return this.networkPolicyCore.attachCrossOriginCookies(this)
}
const SendToDriver: RequestMiddleware = function () {
const span = telemetry.startSpan({ name: 'send:to:driver', parentSpan: this.reqMiddlewareSpan, isVerbose })
const shouldLogReq = shouldLog(this.req)
if (shouldLogReq && this.req.browserPreRequest) {
this.socket.toDriver('request:event', 'incoming:request', this.req.browserPreRequest)
}
span?.setAttributes({
shouldLogReq,
hasBrowserPreRequest: !!this.req.browserPreRequest,
})
span?.end()
this.next()
this.networkPolicyCore.notifyIncomingRequest(this)
}
const MaybeEndRequestWithBufferedResponse: RequestMiddleware = function () {
+7 -213
View File
@@ -4,17 +4,12 @@ import { URL } from 'url'
import zlib from 'zlib'
import { InterceptResponse } from '@packages/net-stubbing'
import { concatStream, httpUtils } from '@packages/network'
import { toughCookieToAutomationCookie } from '@packages/server/lib/util/cookies'
import { telemetry } from '@packages/telemetry'
import { hasServiceWorkerHeader, isVerboseTelemetry as isVerbose } from '.'
import { CookiesHelper } from './util/cookies'
import { doesTopNeedToBeSimulated } from './util/top-simulation'
import * as errors from '@packages/errors'
import type { CookieOptions } from 'express'
import type { ResponseStreamOptions } from '@packages/types'
import type { CypressOutgoingResponse } from '../types'
import type { HttpMiddleware, HttpMiddlewareThis } from '.'
import type { HttpMiddleware } from '.'
import type { IncomingMessage } from 'http'
import { cspHeaderNames, generateCspDirectives, parseCspHeaders, problematicCspDirectives, unsupportedCSPDirectives } from './util/csp-header'
@@ -150,18 +145,6 @@ const stringifyFeaturePolicy = (policy: any): string => {
return pairs.map((directive) => directive.join(' ')).join('; ')
}
const requestIdRegEx = /^(.*)-retry-([\d]+)$/
const getOriginalRequestId = (requestId: string) => {
let originalRequestId = requestId
const match = requestIdRegEx.exec(requestId)
if (match) {
[, originalRequestId] = match
}
return originalRequestId
}
const LogResponse: ResponseMiddleware = function () {
this.debug('received response %o', {
browserPreRequest: _.pick(this.req.browserPreRequest, 'requestId'),
@@ -469,142 +452,8 @@ const MaybePreventCaching: ResponseMiddleware = function () {
this.next()
}
const setSimulatedCookies = (ctx: HttpMiddlewareThis<ResponseMiddlewareProps>) => {
if (ctx.res.wantsInjection !== 'fullCrossOrigin') return
const defaultDomain = (new URL(ctx.req.proxiedUrl)).hostname
const allCookiesForRequest = ctx.getCookieJar()
.getCookies(ctx.req.proxiedUrl)
.map((cookie) => toughCookieToAutomationCookie(cookie, defaultDomain))
ctx.simulatedCookies = allCookiesForRequest
}
const MaybeCopyCookiesFromIncomingRes: ResponseMiddleware = async function () {
const span = telemetry.startSpan({ name: 'maybe:copy:cookies:from:incoming:res', parentSpan: this.resMiddlewareSpan, isVerbose })
const cookies: string | string[] | undefined = this.incomingRes.headers['set-cookie']
const areCookiesPresent = !cookies || !cookies.length
span?.setAttributes({
areCookiesPresent,
})
if (areCookiesPresent) {
setSimulatedCookies(this)
span?.end()
return this.next()
}
// Simulated Top Cookie Handling
// ---------------------------
// - We capture cookies sent by responses and add them to our own server-side
// tough-cookie cookie jar. All request cookies are captured, since any
// future request could be cross-origin in the context of top, even if the response that sets them
// is not.
// - If we sent the cookie header, it may fail to be set by the browser
// (in most cases). However, we cannot determine all the cases in which Set-Cookie
// will currently fail. We try to address this in our tough cookie jar
// by only setting cookies that would otherwise work in the browser if the AUT url was top
// - We also set the cookies through automation so they are available in the
// browser via document.cookie and via Cypress cookie APIs
// (e.g. cy.getCookie). This is only done when the AUT url and top do not match responses,
// since AUT and Top being same origin will be successfully set in the browser
// automatically as expected.
// - In the request middleware, we retrieve the cookies for a given URL
// and attach them to the request, like the browser normally would.
// tough-cookie handles retrieving the correct cookies based on domain,
// path, etc. It also removes cookies from the cookie jar if they've expired.
const doesTopNeedSimulating = doesTopNeedToBeSimulated(this)
span?.setAttributes({
doesTopNeedSimulating,
})
const appendCookie = (cookie: string) => {
// always call 'Set-Cookie' in the browser as cross origin or same site requests
// can effectively set cookies in the browser if given correct credential permissions
const headerName = 'Set-Cookie'
try {
this.res.append(headerName, cookie)
} catch (err) {
this.debug(`failed to append header ${headerName}, continuing %o`, { err, cookie })
}
}
if (!doesTopNeedSimulating) {
([] as string[]).concat(cookies).forEach((cookie) => {
appendCookie(cookie)
})
span?.end()
return this.next()
}
const cookiesHelper = new CookiesHelper({
cookieJar: this.getCookieJar(),
currentAUTUrl: this.getAUTUrl(),
debug: this.debug,
request: {
url: this.req.proxiedUrl,
isAUTFrame: this.req.isAUTFrame,
doesTopNeedSimulating,
resourceType: this.req.resourceType,
credentialLevel: this.req.credentialsLevel,
},
})
await cookiesHelper.capturePreviousCookies()
;([] as string[]).concat(cookies).forEach((cookie) => {
cookiesHelper.setCookie(cookie)
appendCookie(cookie)
})
setSimulatedCookies(this)
const addedCookies = await cookiesHelper.getAddedCookies()
const wereSimCookiesAdded = addedCookies.length
span?.setAttributes({
wereSimCookiesAdded,
})
if (!wereSimCookiesAdded) {
span?.end()
return this.next()
}
// if the request is sync, we cannot wait on the cross:origin:cookies:received
// event since the sync request is blocking. This means that the cross-origin cookies
// may not have been applied.
if (this.req.isSyncRequest) {
errors.warning('SYNCHRONOUS_XHR_REQUEST_COOKIES_NOT_SET', this.req.proxiedUrl)
span?.end()
return this.next()
}
// we want to set the cookies via automation so they exist in the browser
// itself. however, firefox will hang if we try to use the extension
// to set cookies on a url that's in-flight, so we send the cookies down to
// the driver, let the response go, and set the cookies via automation
// from the driver once the page has loaded but before we run any further
// commands
this.serverBus.once('cross:origin:cookies:received', () => {
span?.end()
this.next()
})
this.serverBus.emit('cross:origin:cookies', addedCookies)
return this.networkPolicyCore.copyCookiesFromResponse(this)
}
const REDIRECT_STATUS_CODES: any[] = [301, 302, 303, 307, 308]
@@ -661,40 +510,16 @@ const ClearCyInitialCookie: ResponseMiddleware = function () {
}
const MaybeEndWithEmptyBody: ResponseMiddleware = function () {
const notifyProtocolManagerOfEmptyBody = (isCached: boolean) => {
if (this.protocolManager && this.req.browserPreRequest?.requestId) {
const requestId = getOriginalRequestId(this.req.browserPreRequest.requestId)
this.protocolManager.responseEndedWithEmptyBody({
requestId,
isCached,
timings: {
cdpRequestWillBeSentTimestamp: this.req.browserPreRequest.cdpRequestWillBeSentTimestamp,
cdpRequestWillBeSentReceivedTimestamp: this.req.browserPreRequest.cdpRequestWillBeSentReceivedTimestamp,
proxyRequestReceivedTimestamp: this.req.browserPreRequest.proxyRequestReceivedTimestamp,
cdpLagDuration: this.req.browserPreRequest.cdpLagDuration,
proxyRequestCorrelationDuration: this.req.browserPreRequest.proxyRequestCorrelationDuration,
},
})
}
}
if (httpUtils.responseMustHaveEmptyBody(this.req, this.incomingRes)) {
notifyProtocolManagerOfEmptyBody(this.incomingRes.statusCode === 304)
this.networkPolicyCore.notifyResponseEndedWithEmptyBody(this, {
isCached: this.incomingRes.statusCode === 304,
})
this.res.end()
return this.end()
}
// When the origin response declared `Content-Length: 0`, short-circuit with an
// explicit Content-Length: 0 instead of streaming an empty body — otherwise
// OmitProblematicHeaders has stripped Content-Length and Node's HTTP layer
// adds `Transfer-Encoding: chunked`, which breaks clients that assume a
// response for chunked encoding. See cypress-io/cypress#16469.
// Skip when downstream middleware will rewrite the body or when a cy.intercept
// route matched (the interceptor may have replaced the body without updating
// the upstream Content-Length header).
const wasIntercepted = !!this.netStubbingState?.requests?.[this.req.requestId]
if (
@@ -703,7 +528,7 @@ const MaybeEndWithEmptyBody: ResponseMiddleware = function () {
&& !this.res.wantsInjection
&& !this.res.wantsSecurityRemoved
) {
notifyProtocolManagerOfEmptyBody(false)
this.networkPolicyCore.notifyResponseEndedWithEmptyBody(this, { isCached: false })
this.res.setHeader('Content-Length', '0')
this.res.end()
@@ -755,38 +580,7 @@ const MaybeInjectServiceWorker: ResponseMiddleware = function () {
}
const CompressBody: ResponseMiddleware = async function () {
if (this.protocolManager && this.req.browserPreRequest?.requestId) {
const preRequest = this.req.browserPreRequest
const requestId = getOriginalRequestId(preRequest.requestId)
const span = telemetry.startSpan({ name: 'gzip:body:protocol-notification', parentSpan: this.resMiddlewareSpan, isVerbose })
const streamOptions: ResponseStreamOptions = {
requestId,
responseHeaders: this.incomingRes.headers,
isAlreadyGunzipped: this.isGunzipped,
isAlreadyBrotliDecompressed: this.isBrotliDecompressed,
responseStream: this.incomingResStream,
res: this.res,
timings: {
cdpRequestWillBeSentTimestamp: preRequest.cdpRequestWillBeSentTimestamp,
cdpRequestWillBeSentReceivedTimestamp: preRequest.cdpRequestWillBeSentReceivedTimestamp,
proxyRequestReceivedTimestamp: preRequest.proxyRequestReceivedTimestamp,
cdpLagDuration: preRequest.cdpLagDuration,
proxyRequestCorrelationDuration: preRequest.proxyRequestCorrelationDuration,
},
}
const resultingStream = this.protocolManager.responseStreamReceived(streamOptions)
if (resultingStream) {
this.incomingResStream = resultingStream.on('error', this.onError).once('close', () => {
span?.end()
})
} else {
span?.end()
}
}
await this.networkPolicyCore.notifyResponseStreamReceived(this)
// Re-compress in the same order as the original content-encoding (innermost first).
const order = this.contentEncodingOrder ?? []
@@ -0,0 +1,15 @@
const requestIdRegEx = /^(.*)-retry-([\d]+)$/
/**
* Strip synthetic retry suffixes from CDP request IDs before protocol capture.
*/
export function getOriginalRequestId (requestId: string) {
let originalRequestId = requestId
const match = requestIdRegEx.exec(requestId)
if (match) {
[, originalRequestId] = match
}
return originalRequestId
}
@@ -13,7 +13,10 @@ import { DocumentDomainInjection, RemoteStates } from '@packages/network-tools'
import { EventEmitter } from 'events'
import { NetworkPolicyCore } from '@packages/network-policy'
import {
ProxyCommandLogAdapter,
ProxyCookieStateAdapter,
ProxyDocumentPreparationAdapter,
ProxyNetworkCaptureAdapter,
ProxyRequestInterceptionAdapter,
ProxyResponseInterceptionAdapter,
} from '../../lib/adapters'
@@ -62,6 +65,9 @@ describe('network stubbing', () => {
requestInterception: new ProxyRequestInterceptionAdapter(),
responseInterception: new ProxyResponseInterceptionAdapter(),
documentPreparation: new ProxyDocumentPreparationAdapter(),
networkCapture: new ProxyNetworkCaptureAdapter(),
cookieState: new ProxyCookieStateAdapter(),
commandLog: new ProxyCommandLogAdapter(),
}),
config,
middleware: defaultMiddleware,
@@ -0,0 +1,30 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ProxyCommandLogAdapter } from '../../../lib/adapters/proxy-command-log'
import { sendToDriver } from '../../../lib/adapters/send-to-driver'
vi.mock('../../../lib/adapters/send-to-driver', () => {
return {
sendToDriver: vi.fn(),
}
})
describe('ProxyCommandLogAdapter', () => {
beforeEach(() => {
vi.mocked(sendToDriver).mockReset()
})
it('delegates notifyIncomingRequest to sendToDriver helper', () => {
const adapter = new ProxyCommandLogAdapter()
const ctx = { req: { browserPreRequest: { requestId: '1' } } }
adapter.notifyIncomingRequest(ctx)
expect(sendToDriver).toHaveBeenCalledWith(ctx)
})
it('returns undefined from logInterception on the server', () => {
const adapter = new ProxyCommandLogAdapter()
expect(adapter.logInterception({ interception: {}, route: {} })).toBeUndefined()
})
})
@@ -0,0 +1,45 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ProxyCookieStateAdapter } from '../../../lib/adapters/proxy-cookie-state'
import { attachCrossOriginCookies } from '../../../lib/adapters/attach-cross-origin-cookies'
import { copyCookiesFromResponse } from '../../../lib/adapters/copy-cookies-from-response'
vi.mock('../../../lib/adapters/attach-cross-origin-cookies', () => {
return {
attachCrossOriginCookies: vi.fn(),
}
})
vi.mock('../../../lib/adapters/copy-cookies-from-response', () => {
return {
copyCookiesFromResponse: vi.fn(),
}
})
describe('ProxyCookieStateAdapter', () => {
beforeEach(() => {
vi.mocked(attachCrossOriginCookies).mockReset()
vi.mocked(copyCookiesFromResponse).mockReset()
})
it('delegates attachCrossOriginCookies to helper', async () => {
const adapter = new ProxyCookieStateAdapter()
const ctx = { req: {} }
vi.mocked(attachCrossOriginCookies).mockResolvedValue(undefined)
await adapter.attachCrossOriginCookies(ctx)
expect(attachCrossOriginCookies).toHaveBeenCalledWith(ctx)
})
it('delegates copyCookiesFromResponse to helper', async () => {
const adapter = new ProxyCookieStateAdapter()
const ctx = { req: {} }
vi.mocked(copyCookiesFromResponse).mockResolvedValue(undefined)
await adapter.copyCookiesFromResponse(ctx)
expect(copyCookiesFromResponse).toHaveBeenCalledWith(ctx)
})
})
@@ -0,0 +1,37 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ProxyNetworkCaptureAdapter } from '../../../lib/adapters/proxy-network-capture'
import { notifyResponseEndedWithEmptyBody, notifyResponseStreamReceived } from '../../../lib/adapters/network-capture'
vi.mock('../../../lib/adapters/network-capture', () => {
return {
notifyResponseStreamReceived: vi.fn(),
notifyResponseEndedWithEmptyBody: vi.fn(),
}
})
describe('ProxyNetworkCaptureAdapter', () => {
beforeEach(() => {
vi.mocked(notifyResponseStreamReceived).mockReset()
vi.mocked(notifyResponseEndedWithEmptyBody).mockReset()
})
it('delegates notifyResponseStreamReceived to helper', async () => {
const adapter = new ProxyNetworkCaptureAdapter()
const ctx = { req: { requestId: '1' } }
vi.mocked(notifyResponseStreamReceived).mockResolvedValue(undefined)
await adapter.notifyResponseStreamReceived(ctx)
expect(notifyResponseStreamReceived).toHaveBeenCalledWith(ctx)
})
it('delegates notifyResponseEndedWithEmptyBody to helper', () => {
const adapter = new ProxyNetworkCaptureAdapter()
const ctx = { req: { requestId: '1' } }
adapter.notifyResponseEndedWithEmptyBody(ctx, { isCached: true })
expect(notifyResponseEndedWithEmptyBody).toHaveBeenCalledWith(ctx, { isCached: true })
})
})
+6
View File
@@ -1,7 +1,10 @@
import { HttpMiddleware, HttpStages, _runStage } from '../../../lib/http'
import { NetworkPolicyCore } from '@packages/network-policy'
import {
ProxyCommandLogAdapter,
ProxyCookieStateAdapter,
ProxyDocumentPreparationAdapter,
ProxyNetworkCaptureAdapter,
ProxyRequestInterceptionAdapter,
ProxyResponseInterceptionAdapter,
} from '../../../lib/adapters'
@@ -11,6 +14,9 @@ export function createTestNetworkPolicyCore () {
requestInterception: new ProxyRequestInterceptionAdapter(),
responseInterception: new ProxyResponseInterceptionAdapter(),
documentPreparation: new ProxyDocumentPreparationAdapter(),
networkCapture: new ProxyNetworkCaptureAdapter(),
cookieState: new ProxyCookieStateAdapter(),
commandLog: new ProxyCommandLogAdapter(),
})
}
+6
View File
@@ -1,7 +1,10 @@
import type EventEmitter from 'events'
import { NetworkProxy, BrowserPreRequest } from '@packages/proxy'
import {
ProxyCommandLogAdapter,
ProxyCookieStateAdapter,
ProxyDocumentPreparationAdapter,
ProxyNetworkCaptureAdapter,
ProxyRequestInterceptionAdapter,
ProxyResponseInterceptionAdapter,
} from '@packages/proxy/lib/adapters'
@@ -47,6 +50,9 @@ export function createProxyRuntime (deps: CreateProxyRuntimeDeps): ProxyNetworkR
requestInterception: new ProxyRequestInterceptionAdapter(),
responseInterception: new ProxyResponseInterceptionAdapter(),
documentPreparation: new ProxyDocumentPreparationAdapter(),
networkCapture: new ProxyNetworkCaptureAdapter(),
cookieState: new ProxyCookieStateAdapter(),
commandLog: new ProxyCommandLogAdapter(),
})
registerDefaultNetworkPolicies(networkPolicyRegistration, deps.config)
@@ -51,6 +51,9 @@ describe('lib/network-runtime', () => {
expect(runtime.networkPolicyCore.requestInterception).to.exist
expect(runtime.networkPolicyCore.responseInterception).to.exist
expect(runtime.networkPolicyCore.documentPreparation).to.exist
expect(runtime.networkPolicyCore.networkCapture).to.exist
expect(runtime.networkPolicyCore.cookieState).to.exist
expect(runtime.networkPolicyCore.commandLog).to.exist
})
it('registers configurator CSP and document rewrite policies at startup', () => {