Skip to content
10 changes: 5 additions & 5 deletions packages/server/lib/adapters/internal-routes.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
type InternalRouteConfig = {
clientRoute: string
namespace: string
port: number | null
export type InternalRouteConfig = {
clientRoute?: string
namespace?: string
port?: number | null
proxyUrl?: string
socketIoRoute: string
socketIoRoute?: string
// CT Vite/webpack assets live under this prefix (default /__cypress/src).
// They must not be treated as Express-owned internal routes.
devServerPublicPathRoute?: string
Expand Down
13 changes: 7 additions & 6 deletions packages/server/lib/adapters/serve-internal-routes.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import type { HttpHeaders, HttpRequest, InterceptMiddleware } from '@packages/network-interception'
import type CyServer from '../../index.d.ts'
import type { Request as ServerRequest } from '../request'
import { CYPRESS_INTERNAL_LOOPBACK_HEADER, isInternalCypressRoute, resolveProxyUrlBase } from './internal-routes'
import type { InternalRouteConfig } from './internal-routes'

type ServeInternalRoutesConfig = Pick<
CyServer.Config & Cypress.Config,
'clientRoute' | 'devServerPublicPathRoute' | 'namespace' | 'port' | 'proxyUrl' | 'socketIoRoute'
>
export type ServeInternalRoutesConfig = InternalRouteConfig

type CreateServeInternalRoutesMiddlewareOptions = {
config: ServeInternalRoutesConfig
Expand Down Expand Up @@ -96,12 +93,16 @@ export function createServeInternalRoutesMiddleware ({
// a synthesized response for fulfillRequest. This skips later intercept
// layers (including CorrelateBrowserPreRequest in MITM mode); pending
// pre-requests for these internals are swept by the normal timeout path.
// The loopback request line is path-only, but Express consumers (e.g. the
// spec-bridge iframe controller) derive the request origin from
// req.proxiedUrl, so carry the browser's original absolute URL in the
// loopback header for setProxiedUrl to restore.
const response = await serverRequest.create({
url: toLoopbackUrl(request.url, config),
method: request.method ?? 'GET',
headers: {
...filterHeaders(request.headers),
[CYPRESS_INTERNAL_LOOPBACK_HEADER]: '1',
[CYPRESS_INTERNAL_LOOPBACK_HEADER]: url.href,
},
...(shouldSendBody(request) ? { body: request.body } : {}),
encoding: null,
Expand Down
3 changes: 2 additions & 1 deletion packages/server/lib/browsers/bidi_automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { bidiReloadFrame } from '../automation/commands/reload_frame'
import { bidiNavigateHistory } from '../automation/commands/navigate_history'
import { bidiGetFrameTitle } from '../automation/commands/get_frame_title'
import { bidiPerformUserGesture } from '../automation/commands/user_gesture'
import { AUT_FRAME_HEADER } from './constants'
import type { StorageCookieFilter, StoragePartialCookie as BidiStoragePartialCookie } from 'webdriver/build/bidi/remoteTypes'

const BIDI_DEBUG_NAMESPACE = 'cypress:server:browsers:bidi_automation'
Expand Down Expand Up @@ -257,7 +258,7 @@ export class BidiAutomation {
debug(`AUT request detected, adding X-Cypress-Is-AUT-Frame for request ID: ${params.request.request}`)

params.request.headers.push({
name: 'X-Cypress-Is-AUT-Frame',
name: AUT_FRAME_HEADER,
value: {
type: 'string',
value: 'true',
Expand Down
70 changes: 67 additions & 3 deletions packages/server/lib/browsers/cdp-protocol/cdp-fetch-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { ForHttpIntercept } from '@packages/network-interception'
import { HttpIntercept } from '@packages/network-interception'
import type { ICriClient } from './cri-client'
import { createCdpFetchCodec } from './cdp-fetch-codec'
import { AUT_FRAME_HEADER } from '../constants'

const debug = debugModule('cypress:server:browsers:cdp-fetch-transport')

Expand All @@ -14,6 +15,10 @@ type CdpFetchClient = Pick<ICriClient, 'send' | 'on' | 'off'>
type CdpFetchRequest = Protocol.Fetch.RequestPausedEvent['request']
const RESPONSE_PAUSE_TIMEOUT_MS = 30000

type CdpFetchTransportOptions = {
isAUTFrame?: (frameId: string) => Promise<boolean>
}

export interface CdpFetchTransportRequest extends CdpFetchRequest {
id: string
requestId?: string
Expand All @@ -37,6 +42,7 @@ export class CdpFetchTransport {
constructor (
private readonly client: CdpFetchClient,
private readonly httpIntercept: ForHttpIntercept<CdpFetchTransportRequest, CdpFetchTransportResponse> = new HttpIntercept(createCdpFetchCodec()),
private readonly options: CdpFetchTransportOptions = {},
) {}

/**
Expand Down Expand Up @@ -77,6 +83,14 @@ export class CdpFetchTransport {
}
}

/**
* Clears in-flight request correlation without disabling CDP Fetch.
* Used between tests so the next test still receives paused traffic.
*/
reset (): void {
this.rejectAll(new Error('CDP Fetch transport reset'))
}

async stop (): Promise<void> {
if (!this.isStarted) {
return
Expand Down Expand Up @@ -109,6 +123,7 @@ export class CdpFetchTransport {
debug('continuing request pause without network id: %s', event.request.url)
await this.safeSend('Fetch.continueRequest', {
requestId: event.requestId,
...(await this.autFrameHeader(event)),
}, sessionId)

return
Expand All @@ -131,14 +146,14 @@ export class CdpFetchTransport {
this.inFlightRequests.set(networkId, deferred)

response = await this.httpIntercept.handle(request, async (outbound) => {
const headers = await this.continueRequestHeaders(event, outbound)

await this.client.send('Fetch.continueRequest', {
requestId: event.requestId,
...(outbound.url !== event.request.url ? { url: outbound.url } : {}),
...(outbound.method !== event.request.method ? { method: outbound.method } : {}),
...(outbound.postData !== event.request.postData ? { postData: outbound.postData } : {}),
...(this.headersChanged(outbound.headers ?? {}, event.request.headers)
? { headers: this.toContinueRequestHeaders(outbound.headers ?? {}) }
: {}),
...(headers ? { headers } : {}),
}, outbound.sessionId)

requestContinued = true
Expand Down Expand Up @@ -330,6 +345,55 @@ export class CdpFetchTransport {
}
}

private autFrameHeader = async (
event: Protocol.Fetch.RequestPausedEvent,
outbound: CdpFetchTransportRequest = {
...event.request,
id: event.networkId ?? event.requestId,
requestId: event.requestId,
},
): Promise<Pick<Protocol.Fetch.ContinueRequestRequest, 'headers'>> => {
const headers = await this.continueRequestHeaders(event, outbound)

return headers ? { headers } : {}
}

/**
* Builds continueRequest headers when the outbound headers changed or the
* request is an AUT-frame document. Only document requests are marked —
* parity with the other automation layers — so AUT subresource traffic
* (XHR/fetch/assets) never carries the header out to the origin server.
* Always preserves X-Cypress-Is-AUT-Frame alongside any mutated headers
* (a later headers overwrite must not drop it).
*/
private continueRequestHeaders = async (
event: Protocol.Fetch.RequestPausedEvent,
outbound: CdpFetchTransportRequest,
): Promise<Protocol.Fetch.HeaderEntry[] | undefined> => {
const markAsAUTFrame = event.resourceType === 'Document' && event.frameId && this.options.isAUTFrame
? await this.options.isAUTFrame(event.frameId)
: false
const headersChanged = this.headersChanged(outbound.headers ?? {}, event.request.headers)

if (!markAsAUTFrame && !headersChanged) {
return
}

// A redirect hop re-pauses with the previously injected header already in
// the request; strip it so the header is never sent upstream duplicated.
const headers = this.toContinueRequestHeaders(outbound.headers ?? {})
.filter(({ name }) => name.toLowerCase() !== AUT_FRAME_HEADER.toLowerCase())

if (markAsAUTFrame) {
headers.push({
name: AUT_FRAME_HEADER,
value: 'true',
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AUT header leaks upstream without proxy

Medium Severity

With the MITM proxy disabled, CDP Fetch adds X-Cypress-Is-AUT-Frame on AUT document Fetch.continueRequest calls. That traffic no longer passes through NetworkProxy request middleware, which previously removed the header before upstream requests. External AUT navigations can therefore send this internal Cypress marker to the application origin.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a880bfb. Configure here.

}

return headers
}

private cleanup (networkId: string, deferred?: pDefer.DeferredPromise<CdpFetchTransportResponse>): void {
if (deferred && this.inFlightRequests.get(networkId) !== deferred) {
return
Expand Down
7 changes: 6 additions & 1 deletion packages/server/lib/browsers/cdp-protocol/cdp_automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { cookieMatches, CyCookie, CyCookieFilter } from '../../automation/cookie
import { normalizeGetCookies, normalizeSetCookieProps } from '../../automation/cookie/converters/cdp'
import { DEFAULT_NETWORK_ENABLE_OPTIONS, CriClient } from './cri-client'
import { cdpKeyPress } from '../../automation/commands/key_press'
import { AUT_FRAME_HEADER } from '../constants'

import { toSupportedKey, AUT_FRAME_NAME_IDENTIFIER } from '@packages/types'

Expand Down Expand Up @@ -366,6 +367,10 @@ export class CdpAutomation implements CDPClient, AutomationMiddleware {
return false
}

isAUTFrame = (frameId: string) => {
return this._isAUTFrame(frameId)
}

private _getAutFrame = async () => {
try {
if (this.gettingFrameTree) {
Expand Down Expand Up @@ -422,7 +427,7 @@ export class CdpAutomation implements CDPClient, AutomationMiddleware {
debugVerbose('add X-Cypress-Is-AUT-Frame header to: %s', params.request.url)

return this._continueRequest(client, params, {
name: 'X-Cypress-Is-AUT-Frame',
name: AUT_FRAME_HEADER,
value: 'true',
})
}
Expand Down
13 changes: 10 additions & 3 deletions packages/server/lib/browsers/chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import memory from './memory'
import type { BrowserLaunchOpts, BrowserNewTabOpts, ProtocolManagerShape, CyPromptManagerShape, StudioManagerShape, RunModeVideoApi } from '@packages/types'
import type { CDPSocketServer } from '@packages/socket'
import { DEFAULT_CHROME_FLAGS } from '../util/chromium_flags'
import { isProxyDisabled } from '../util/is-proxy-disabled'

const debug = debugModule('cypress:server:browsers:chrome')

Expand Down Expand Up @@ -575,10 +576,16 @@ export = {
utils.initializeCDP(pageCriClient, automation),
])

await this._navigateUsingCRI(pageCriClient, url)
if (isProxyDisabled()) {
cdpAutomation._listenForFrameTreeChanges(pageCriClient)
await options.onPageCriClientReady?.(pageCriClient, cdpAutomation.isAUTFrame)

await cdpAutomation._handlePausedRequests(pageCriClient)
cdpAutomation._listenForFrameTreeChanges(pageCriClient)
await this._navigateUsingCRI(pageCriClient, url)
} else {
await this._navigateUsingCRI(pageCriClient, url)
await cdpAutomation._handlePausedRequests(pageCriClient)
cdpAutomation._listenForFrameTreeChanges(pageCriClient)
}

return cdpAutomation
},
Expand Down
5 changes: 5 additions & 0 deletions packages/server/lib/browsers/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Marks a document request as originating from the AUT frame so downstream
// consumers (e.g. proxy request-middleware) can identify it for injection.
// Every automation layer (CDP, BiDi, WebKit) injects this header; the proxy
// strips it before the request goes upstream.
export const AUT_FRAME_HEADER = 'X-Cypress-Is-AUT-Frame'
17 changes: 14 additions & 3 deletions packages/server/lib/browsers/electron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import memory from './memory'
import { BrowserCriClient } from './browser-cri-client'
import { getRemoteDebuggingPort } from '../util/electron-app'
import type { CriClient } from './cdp-protocol/cri-client'
import { isProxyDisabled } from '../util/is-proxy-disabled'

// TODO: unmix these two types
type ElectronOpts = Windows.WindowOptions & BrowserLaunchOpts
Expand Down Expand Up @@ -378,9 +379,19 @@ export = {

// Note that these calls have to happen before we load the page so that we don't miss out on any events that happen quickly
if (cdpAutomation) {
// These calls need to happen prior to loading the URL so we can be sure to get the frames as they come in
await cdpAutomation._handlePausedRequests(browserCriClient?.currentlyAttachedTarget)
cdpAutomation._listenForFrameTreeChanges(browserCriClient?.currentlyAttachedTarget)
const pageCriClient = browserCriClient?.currentlyAttachedTarget

if (!pageCriClient) throw new Error('Missing pageCriClient in _launch')

if (isProxyDisabled()) {
// These calls need to happen prior to loading the URL so we can be sure to get the frames as they come in
cdpAutomation._listenForFrameTreeChanges(pageCriClient)
await options.onPageCriClientReady?.(pageCriClient, cdpAutomation.isAUTFrame)
} else {
// These calls need to happen prior to loading the URL so we can be sure to get the frames as they come in
await cdpAutomation._handlePausedRequests(pageCriClient)
cdpAutomation._listenForFrameTreeChanges(pageCriClient)
}
}

await win.loadURL(url)
Expand Down
3 changes: 2 additions & 1 deletion packages/server/lib/browsers/webkit-automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { normalizeGetCookieProps, normalizeSetCookieProps } from '../automation/
import utils from './utils'
import type { CyCookie } from '../automation/cookie/util'
import { AUT_FRAME_NAME_IDENTIFIER } from '@packages/types'
import { AUT_FRAME_HEADER } from './constants'

const debug = Debug('cypress:server:browsers:webkit-automation')

Expand Down Expand Up @@ -154,7 +155,7 @@ export class WebKitAutomation {
return route.continue({
headers: {
...request.headers(),
'X-Cypress-Is-AUT-Frame': 'true',
[AUT_FRAME_HEADER]: 'true',
},
})
})
Expand Down
Loading
Loading