fix: Improve document.cookie patch (#23643)

This commit is contained in:
Chris Breiding
2022-10-18 17:38:56 -04:00
committed by GitHub
parent 1b29ce74aa
commit f9272bbd22
26 changed files with 545 additions and 378 deletions

View File

@@ -3,7 +3,6 @@ import { EventEmitter } from 'events'
import type { MobxRunnerStore } from '@packages/app/src/store/mobx-runner-store'
import type MobX from 'mobx'
import type { LocalBusEmitsMap, LocalBusEventMap, DriverToLocalBus, SocketToDriverMap } from './event-manager-types'
import type { RunState, CachedTestState, AutomationElementId, FileDetails, ReporterStartInfo, ReporterRunState } from '@packages/types'
import { logger } from './logger'
@@ -40,7 +39,7 @@ const driverToSocketEvents = 'backend:request automation:request mocha recorder:
const driverTestEvents = 'test:before:run:async test:after:run'.split(' ')
const driverToLocalEvents = 'viewport:changed config stop url:changed page:loading visit:failed visit:blank cypress:in:cypress:runner:event'.split(' ')
const socketRerunEvents = 'runner:restart watched:file:changed'.split(' ')
const socketToDriverEvents = 'net:stubbing:event request:event script:error cross:origin:automation:cookies'.split(' ')
const socketToDriverEvents = 'net:stubbing:event request:event script:error cross:origin:cookies'.split(' ')
const localToReporterEvents = 'reporter:log:add reporter:log:state:changed reporter:log:remove'.split(' ')
/**
@@ -698,32 +697,18 @@ export class EventManager {
log?.set(attrs)
})
// This message comes from the AUT, not the spec bridge.
// This is called in the event that cookies are set in a cross origin AUT prior to attaching a spec bridge.
Cypress.primaryOriginCommunicator.on('aut:set:cookie', ({ cookie, href }, _origin, source) => {
const { superDomain } = Cypress.Location.create(href)
const automationCookie = Cypress.Cookies.toughCookieToAutomationCookie(Cypress.Cookies.parse(cookie), superDomain)
Cypress.automation('set:cookie', automationCookie).then(() => {
// It's possible the source has already unloaded before this event has been processed.
source?.postMessage({ event: 'cross:origin:aut:set:cookie' }, '*')
})
.catch(() => {
// unlikely there will be errors, but ignore them in any case, since
// they're not user-actionable
})
})
// This message comes from the AUT, not the spec bridge.
// This is called in the event that cookies are retrieved in a cross origin AUT prior to attaching a spec bridge.
Cypress.primaryOriginCommunicator.on('aut:get:cookie', async ({ href }, _origin, source) => {
const { superDomain } = Cypress.Location.create(href)
const cookies = await Cypress.automation('get:cookies', { superDomain })
// It's possible the source has already unloaded before this event has been processed.
source?.postMessage({ event: 'cross:origin:aut:get:cookie', cookies }, '*')
})
// This message comes from the AUT, not the spec bridge. This is called in
// the event that cookies are set via document.cookie in a cross origin
// AUT prior to attaching a spec bridge.
Cypress.primaryOriginCommunicator.on(
'aut:set:cookie',
(options: { cookie, url: string, sameSiteContext: string }) => {
// unlikely there will be errors, but ignore them in any case, since
// they're not user-actionable
Cypress.automation('set:cookie', options.cookie).catch(() => {})
Cypress.backend('cross:origin:set:cookie', options).catch(() => {})
},
)
// The window.top should not change between test reloads, and we only need to bind the message event when Cypress is recreated
// Forward all message events to the current instance of the multi-origin communicator

View File

@@ -453,12 +453,13 @@ describe('cy.origin - cookie login', { browser: '!webkit' }, () => {
const expires = (new Date()).toUTCString()
cy.get('[data-cy="username"]').type(username)
cy.get('[data-cy="localhostCookieProps"]').type(`Expires=${expires}`)
cy.get('[data-cy="cookieProps"]').type(`Expires=${expires}`)
cy.get('[data-cy="login"]').click()
})
cy.origin('http://www.idp.com:3500', () => {
cy.clearCookie('user')
cy.origin('http://www.idp.com:3501', () => {
cy.wait(1000) // give cookie time to expire
cy.reload()
cy.document().its('cookie').should('not.include', 'user=')
})
})
@@ -502,15 +503,18 @@ describe('cy.origin - cookie login', { browser: '!webkit' }, () => {
cy.getCookie('user').should('be.null')
})
it('past max-age -> not accessible via document.cookie', () => {
// expiring cookies set by automation don't seem to get unset appropriately
// in Firefox. this issue doesn't seem to be specific to cross-origin tests,
// as it happens even using cy.setCookie()
it('past max-age -> not accessible via document.cookie', { browser: '!firefox' }, () => {
cy.get('[data-cy="cookie-login-land-on-idp"]').click()
cy.origin('http://www.foobar.com:3500', { args: { username } }, ({ username }) => {
cy.get('[data-cy="username"]').type(username)
cy.get('[data-cy="localhostCookieProps"]').type('Max-Age=1')
cy.get('[data-cy="cookieProps"]').type('Max-Age=1')
cy.get('[data-cy="login"]').click()
})
cy.origin('http://www.idp.com:3500', () => {
cy.origin('http://www.idp.com:3501', () => {
cy.wait(1500) // give cookie time to expire
cy.reload()
cy.document().its('cookie').should('not.include', 'user=')
@@ -664,14 +668,15 @@ describe('cy.origin - cookie login', { browser: '!webkit' }, () => {
})
it('gets cookie set by http request', () => {
cy.get('[data-cy="cookie-login-land-on-idp"]').click()
cy.get('[data-cy="cookie-login-land-on-document-cookie"]').click()
cy.origin('http://www.foobar.com:3500', { args: { username } }, ({ username }) => {
cy.get('[data-cy="username"]').type(username)
cy.get('[data-cy="login"]').click()
})
cy.origin('http://www.idp.com:3500', { args: { username } }, ({ username }) => {
cy.document().its('cookie').should('include', `user=${username}`)
cy.origin('http://www.idp.com:3501', { args: { username } }, ({ username }) => {
cy.get('[data-cy="doc-cookie"]').invoke('text')
.should('include', `user=${username}`)
})
})
@@ -698,20 +703,20 @@ describe('cy.origin - cookie login', { browser: '!webkit' }, () => {
})
it('cookie properties are preserved when set via automation', () => {
cy.get('[data-cy="cross-origin-secondary-link"]').click()
cy.origin('http://www.foobar.com:3500', () => {
cy.get('[data-cy="cookie-https"]').click()
cy.origin('https://www.foobar.com:3502', () => {
cy.document().then((doc) => {
doc.cookie = 'key=value; SameSite=Strict; Path=/foo'
doc.cookie = 'key=value; SameSite=Strict; Secure; Path=/fixtures'
})
cy.getCookie('key').then((cookie) => {
expect(Cypress._.omit(cookie, 'expiry')).to.deep.equal({
domain: '.foobar.com',
domain: '.www.foobar.com',
httpOnly: false,
name: 'key',
path: '/foo',
path: '/fixtures',
sameSite: 'strict',
secure: false,
secure: true,
value: 'value',
})
})
@@ -743,7 +748,7 @@ describe('cy.origin - cookie login', { browser: '!webkit' }, () => {
doc.cookie = 'key2=value2'
})
cy.document().its('cookie').should('equal', 'key2=value2; key1=value1')
cy.document().its('cookie').should('equal', 'key1=value1; key2=value2')
})
})
@@ -754,10 +759,29 @@ describe('cy.origin - cookie login', { browser: '!webkit' }, () => {
doc.cookie = 'key=value'
})
// it can take a small amount of time for the cookie to make it to
// automation, but it's unlikely a user will encounter this issue
// since they'd pretty much have to write this exact test. making it
// wait a second is probably overkill, but purposefully keeping the
// wait long to avoid this test becoming flaky
cy.wait(1000)
cy.getCookie('key').its('value').should('equal', 'value')
})
})
it('returns cookie set by cy.setCookie()', () => {
cy.get('[data-cy="cookie-login-land-on-idp"]').click()
cy.origin('http://www.foobar.com:3500', { args: { username } }, ({ username }) => {
cy.get('[data-cy="username"]').type(username)
cy.get('[data-cy="login"]').click()
})
cy.origin('http://www.idp.com:3501', () => {
cy.setCookie('foo', 'bar')
cy.document().its('cookie').should('include', 'foo=bar')
})
})
it('no longer returns cookie after cy.clearCookie()', () => {
cy.get('[data-cy="cookie-login-land-on-idp"]').click()
cy.origin('http://www.foobar.com:3500', { args: { username } }, ({ username }) => {
@@ -765,20 +789,20 @@ describe('cy.origin - cookie login', { browser: '!webkit' }, () => {
cy.get('[data-cy="login"]').click()
})
cy.origin('http://www.idp.com:3500', () => {
cy.origin('http://www.idp.com:3501', () => {
cy.clearCookie('user')
cy.document().its('cookie').should('equal', '')
})
})
it('no longer returns cookie after cy.clearCookies()', () => {
it('no longer returns cookies after cy.clearCookies()', () => {
cy.get('[data-cy="cookie-login-land-on-idp"]').click()
cy.origin('http://www.foobar.com:3500', { args: { username } }, ({ username }) => {
cy.get('[data-cy="username"]').type(username)
cy.get('[data-cy="login"]').click()
})
cy.origin('http://www.idp.com:3500', () => {
cy.origin('http://www.idp.com:3501', () => {
cy.clearCookies()
cy.document().its('cookie').should('equal', '')
})
@@ -791,7 +815,7 @@ describe('cy.origin - cookie login', { browser: '!webkit' }, () => {
cy.get('[data-cy="login"]').click()
})
cy.origin('http://www.idp.com:3500', { args: { username } }, ({ username }) => {
cy.origin('http://www.idp.com:3501', { args: { username } }, ({ username }) => {
cy.document().then((doc) => {
doc.cookie = 'key=value'
})
@@ -810,12 +834,12 @@ describe('cy.origin - cookie login', { browser: '!webkit' }, () => {
})
cy.get('[data-cy="document-cookie"]').click()
cy.origin('http://www.foobar.com:3500', { args: { username } }, ({ username }) => {
cy.origin('http://www.foobar.com:3500', () => {
cy.document().its('cookie').should('include', 'name=value')
cy.get('[data-cy="doc-cookie"]').invoke('text').should('equal', 'name=value')
cy.getCookie('name').then((cookie) => {
expect(Cypress._.omit(cookie, 'expiry')).to.deep.equal({
domain: '.foobar.com',
domain: '.www.foobar.com',
httpOnly: false,
name: 'name',
path: '/',
@@ -826,5 +850,59 @@ describe('cy.origin - cookie login', { browser: '!webkit' }, () => {
})
})
})
it('preserves duplicate cookie keys', () => {
cy.get('[data-cy="cookie-login-land-on-document-cookie"]').click()
cy.origin('http://www.foobar.com:3500', { args: { username } }, ({ username }) => {
cy.get('[data-cy="username"]').type(username)
cy.get('[data-cy="login"]').click()
})
cy.origin('http://www.idp.com:3501', () => {
// ensure we've redirected to the right page
cy.url().should('not.include', 'http://www.idp.com:3501/verify-cookie-login')
cy.document().then((doc) => {
doc.cookie = 'key=value1; domain=www.idp.com'
doc.cookie = 'key=value2; domain=idp.com'
})
// order of the cookies differs depending on browser, so just
// ensure that each one is there
cy.document().its('cookie').should('include', 'key=value1')
cy.document().its('cookie').should('include', 'key=value2')
})
})
it('setting cookie preserves cookies on subsequent page loads', () => {
cy.get('[data-cy="cross-origin-secondary-link"]').click()
cy.origin('http://www.foobar.com:3500', () => {
cy.document().then((doc) => {
doc.cookie = 'key=value'
})
cy.document().its('cookie').should('equal', 'key=value')
cy.wait(500)
cy.reload()
cy.document().its('cookie').should('equal', 'key=value')
})
})
// the spec bridge will likely already exist in this spec when running
// all the tests together, but this ensures the behavior in case it's run
// alone or if we implement spec bridge removal in the future
it('works when spec bridge is set up prior to page load', () => {
cy.origin('http://www.idp.com:3501', () => {})
cy.get('[data-cy="cookie-login-land-on-document-cookie"]').click()
cy.origin('http://www.foobar.com:3500', { args: { username } }, ({ username }) => {
cy.get('[data-cy="username"]').type(username)
cy.get('[data-cy="login"]').click()
})
cy.origin('http://www.idp.com:3501', { args: { username } }, ({ username }) => {
cy.get('[data-cy="doc-cookie"]').invoke('text')
.should('include', `user=${username}`)
})
})
})
})

View File

@@ -3,11 +3,14 @@
<head>
</head>
<body>
<p id='test' data-cy="doc-cookie">should be replaced</p>
<p>
<strong>document.cookie</strong>:
<span data-cy="doc-cookie">should be replaced</span>
</p>
<script>
document.cookie = 'name=value'
document.getElementById('test').innerText = document.cookie
document.querySelector('[data-cy="doc-cookie"]').innerText = document.cookie
</script>
</body>
</html>

View File

@@ -16,15 +16,16 @@
<li><a data-cy="xhr-fetch-requests-onload" href="http://www.foobar.com:3500/fixtures/xhr-fetch-requests.html?fireOnload=true">http://www.foobar.com:3500/fixtures/xhr-fetch-requests.html onLoad</a></li>
<li><a data-cy="xhr-fetch-requests" href="http://www.foobar.com:3500/fixtures/xhr-fetch-requests.html">http://www.foobar.com:3500/fixtures/xhr-fetch-requests.html</a></li>
<li><a data-cy="integrity-link" href="http://www.foobar.com:3500/fixtures/scripts-with-integrity.html">http://www.foobar.com:3500/fixtures/scripts-with-integrity.html</a></li>
<li><a data-cy="cookie-http" href="http://www.foobar.com:3500/fixtures/secondary-origin.html">Visit foobar.com over http</a></li>
<li><a data-cy="cookie-https" href="https://www.foobar.com:3502/fixtures/secondary-origin.html">Visit foobar.com over https</a></li>
<li><a data-cy="document-cookie" href="http://www.foobar.com:3500/fixtures/auth/document-cookie.html">http://www.foobar.com:3500/fixtures/auth/document-cookie.html</a></li>
<li><a data-cy="cookie-login">Login with Social</a></li>
<li><a data-cy="cookie-login-https">Login with Social (https)</a></li>
<li><a data-cy="cookie-login-subdomain">Login with Social (subdomain)</a></li>
<li><a data-cy="cookie-login-alias">Login with Social (aliased localhost)</a></li>
<li><a data-cy="cookie-login-override">Login with Social (cookie override)</a></li>
<li><a data-cy="cookie-login-land-on-idp">Login with Social (lands on idp)</a></li>
<li><a data-cy="cookie-http" href="http://www.foobar.com:3500/fixtures/secondary-origin.html">Visit foobar.com over http</a></li>
<li><a data-cy="cookie-https" href="https://www.foobar.com:3502/fixtures/secondary-origin.html">Visit foobar.com over https</a></li>
<li><a data-cy="document-cookie" href="http://www.foobar.com:3500/fixtures/auth/document-cookie.html">http://www.foobar.com:3500/fixtures/auth/document-cookie.html</a></li>
<li><a data-cy="cookie-login-land-on-document-cookie">Login with Social (lands on document.cookie)</a></li>
</ul>
<script>
function setHref (dataCy, redirect2, primaryQueryAddition = '') {
@@ -45,7 +46,8 @@
setHref('cookie-login-subdomain', 'http://localhost:3500/login&subdomain=true')
setHref('cookie-login-alias', 'http://localhost:3500/login&alias=true')
setHref('cookie-login-override', 'https://localhost:3502/login', '&override=true')
setHref('cookie-login-land-on-idp', 'http://www.idp.com:3500/welcome')
setHref('cookie-login-land-on-idp', 'http://www.idp.com:3501/welcome')
setHref('cookie-login-land-on-document-cookie', 'http://www.idp.com:3501/fixtures/auth/document-cookie.html')
</script>
</body>
</html>

View File

@@ -60,7 +60,7 @@ const createCypress = () => {
const autWindow = findWindow()
if (autWindow) {
if (autWindow && !autWindow.Cypress) {
attachToWindow(autWindow)
}
})
@@ -155,6 +155,11 @@ const attachToWindow = (autWindow: Window) => {
const cy = Cypress.cy
// this communicates to the injection code that Cypress is now available so
// it can safely subscribe to Cypress events, etc
// @ts-ignore
autWindow.__attachToCypress(Cypress)
Cypress.state('window', autWindow)
Cypress.state('document', autWindow.document)
@@ -162,14 +167,6 @@ const attachToWindow = (autWindow: Window) => {
patchFormElementSubmit(autWindow)
}
Cypress.removeAllListeners('app:timers:reset')
Cypress.removeAllListeners('app:timers:pause')
// @ts-expect-error - the injected code adds the cypressTimersReset function to window
Cypress.on('app:timers:reset', autWindow.cypressTimersReset)
// @ts-ignore - the injected code adds the cypressTimersPause function to window
Cypress.on('app:timers:pause', autWindow.cypressTimersPause)
cy.urlNavigationEvent('before:load')
cy.overrides.wrapNativeMethods(autWindow)

View File

@@ -0,0 +1,42 @@
import type { AutomationCookie } from '@packages/server/lib/automation/cookies'
import type { ICypress } from '../../cypress'
// cross-origin cookies collected by the the proxy are sent down to the driver
// via this event, so that they can be set via automation once the page has
// loaded. it's necessary to wait until page load because Firefox with the
// extension will hang the page load if we attempt to set the cookies via
// automation before the page loads
export const handleCrossOriginCookies = (Cypress: ICypress) => {
// multiple requests could set cookies while the page is loading, so we
// collect all cookies and only send set them via automation once after
// the page has loaded
let cookiesToSend: AutomationCookie[] = []
let waitingToSend = false
Cypress.on('cross:origin:cookies', (cookies: AutomationCookie[]) => {
cookiesToSend = cookiesToSend.concat(cookies)
Cypress.backend('cross:origin:cookies:received')
if (waitingToSend) return
waitingToSend = true
// this event allows running a handler before stability is released.
// this prevents subsequent commands from running until the cookies
// are set via automation
// @ts-ignore
Cypress.once('before:stability:release', () => {
const cookies = cookiesToSend
cookiesToSend = []
waitingToSend = false
// this will be awaited before any stability-reliant actions
return Cypress.automation('add:cookies', cookies)
.catch(() => {
// errors here can be ignored as they're not user-actionable
})
})
})
}

View File

@@ -355,6 +355,8 @@ export default function (Commands, Cypress, cy, state, config) {
$errUtils.throwErrByPath('setCookie.host_prefix', { onFail })
}
Cypress.emit('set:cookie', cookie)
return cy.retryIfCommandAUTOriginMismatch(() => {
return automateCookies('set:cookie', cookie, options._log, responseTimeout)
.then(pickCookieProps)
@@ -404,6 +406,8 @@ export default function (Commands, Cypress, cy, state, config) {
$errUtils.throwErrByPath('clearCookie.invalid_argument', { onFail })
}
Cypress.emit('clear:cookie', name)
// TODO: prevent clearing a cypress namespace
return cy.retryIfCommandAUTOriginMismatch(() => {
return automateCookies('clear:cookie', { name }, options._log, responseTimeout)
@@ -449,6 +453,8 @@ export default function (Commands, Cypress, cy, state, config) {
})
}
Cypress.emit('clear:cookies')
return cy.retryIfCommandAUTOriginMismatch(() => {
return getAndClear(options._log, responseTimeout, { domain: options.domain })
.then((resp) => {

View File

@@ -200,6 +200,7 @@ export default (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy, state: State
originCommandBaseUrl: location.href,
isStable: Cypress.state('isStable'),
autLocation: Cypress.state('autLocation')?.href,
crossOriginCookies: Cypress.state('crossOriginCookies'),
},
config: preprocessConfig(Cypress.config()),
env: preprocessEnv(Cypress.env()),

View File

@@ -15,11 +15,15 @@ export const create = (Cypress: ICypress, state: StateFunc) => ({
// show the 'loading spinner' during an app page loading transition event
Cypress.action('cy:stability:changed', stable, event)
Cypress.action('cy:before:stability:release', stable)
if (!stable) {
return
}
Cypress.action('cy:before:stability:release')
.then(() => {
const whenStable = state('whenStable')
if (stable && whenStable) {
if (whenStable) {
whenStable()
}
})

View File

@@ -645,7 +645,7 @@ class $Cypress {
return this.emit('snapshot', ...args)
case 'cy:before:stability:release':
return this.emitThen('before:stability:release', ...args)
return this.emitThen('before:stability:release')
case 'app:uncaught:exception':
return this.emitMap('uncaught:exception', ...args)

View File

@@ -1,6 +1,6 @@
import _ from 'lodash'
import Cookies from 'js-cookie'
import { CookieJar, toughCookieToAutomationCookie } from '@packages/server/lib/util/cookies'
import { CookieJar } from '@packages/server/lib/util/cookies'
import $errUtils from './error_utils'
@@ -153,8 +153,6 @@ export const $Cookies = (namespace, domain) => {
parse (cookieString: string) {
return CookieJar.parse(cookieString)
},
toughCookieToAutomationCookie,
}
return API

View File

@@ -33,11 +33,11 @@ import { TestConfigOverride } from '../cy/testConfigOverrides'
import { create as createOverrides, IOverrides } from '../cy/overrides'
import { historyNavigationTriggeredHashChange } from '../cy/navigation'
import { EventEmitter2 } from 'eventemitter2'
import { handleCrossOriginCookies } from '../cross-origin/events/cookies'
import type { ICypress } from '../cypress'
import type { ICookies } from './cookies'
import type { StateFunc } from './state'
import type { AutomationCookie } from '@packages/server/lib/automation/cookies'
const debugErrors = debugFn('cypress:driver:errors')
@@ -375,29 +375,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert
this.enqueue(attrs)
})
Cypress.on('cross:origin:automation:cookies', (cookies: AutomationCookie[]) => {
const existingCookies: AutomationCookie[] = state('cross:origin:automation:cookies') || []
this.state('cross:origin:automation:cookies', existingCookies.concat(cookies))
Cypress.backend('cross:origin:automation:cookies:received')
})
Cypress.on('before:stability:release', (stable: boolean) => {
const cookies: AutomationCookie[] = state('cross:origin:automation:cookies') || []
if (!stable || !cookies.length) return
// reset the state cookies before setting them via automation in case
// any more get set in the interim
state('cross:origin:automation:cookies', [])
// this will be awaited before any stability-reliant actions
return Cypress.automation('add:cookies', cookies)
.catch(() => {
// errors here can be ignored as they're not user-actionable
})
})
handleCrossOriginCookies(Cypress)
}
isCy (val) {

View File

@@ -1,6 +1,7 @@
// NOTE: this is for internal Cypress types that we don't want exposed in the public API but want for development
// TODO: find a better place for this
/// <reference path="./internal-types-lite.d.ts" />
interface InternalWindowLoadDetails {
type: 'same:origin' | 'cross:origin' | 'cross:origin:failure'
error?: Error
@@ -30,6 +31,9 @@ declare namespace Cypress {
isCrossOriginSpecBridge: boolean
originalConfig: Cypress.ObjectLike
cy: $Cy
Location: {
create: (url: string) => ({ domain: string, superDomain: string })
}
}
interface CypressUtils {
@@ -47,6 +51,18 @@ declare namespace Cypress {
document: Document
projectRoot?: string
}
interface Actions {
(action: 'set:cookie', fn: (cookie: AutomationCookie) => void)
(action: 'clear:cookie', fn: (name: string) => void)
(action: 'clear:cookies', fn: () => void)
(action: 'cross:origin:cookies', fn: (cookies: AutomationCookie[]) => void)
(action: 'before:stability:release', fn: () => void)
}
interface Backend {
(task: 'cross:origin:cookies:received'): Promise<void>
}
}
type AliasedRequest = {

View File

@@ -22,7 +22,7 @@ import { DeferredSourceMapCache } from '@packages/rewriter'
import type { RemoteStates } from '@packages/server/lib/remote_states'
import type { CookieJar } from '@packages/server/lib/util/cookies'
import type { ResourceTypeAndCredentialManager } from '@packages/server/lib/util/resourceTypeAndCredentialManager'
import type { Automation } from '@packages/server/lib/automation/automation'
import type { AutomationCookie } from '@packages/server/lib/automation/cookies'
function getRandomColorFn () {
return chalk.hex(`#${Number(
@@ -55,10 +55,10 @@ type HttpMiddlewareCtx<T> = {
middleware: HttpMiddlewareStacks
getCookieJar: () => CookieJar
deferSourceMapRewrite: (opts: { js: string, url: string }) => string
getAutomation: () => Automation
getPreRequest: (cb: GetPreRequestCb) => void
getAUTUrl: Http['getAUTUrl']
setAUTUrl: Http['setAUTUrl']
simulatedCookies: AutomationCookie[]
} & T
export const defaultMiddleware = {
@@ -70,7 +70,6 @@ export const defaultMiddleware = {
export type ServerCtx = Readonly<{
config: CyServer.Config & Cypress.Config
shouldCorrelatePreRequests?: () => boolean
getAutomation: () => Automation
getFileServerToken: () => string
getCookieJar: () => CookieJar
remoteStates: RemoteStates
@@ -215,7 +214,6 @@ export class Http {
config: CyServer.Config
shouldCorrelatePreRequests: () => boolean
deferredSourceMapCache: DeferredSourceMapCache
getAutomation: () => Automation
getFileServerToken: () => string
remoteStates: RemoteStates
middleware: HttpMiddlewareStacks
@@ -235,7 +233,6 @@ export class Http {
this.config = opts.config
this.shouldCorrelatePreRequests = opts.shouldCorrelatePreRequests || (() => false)
this.getAutomation = opts.getAutomation
this.getFileServerToken = opts.getFileServerToken
this.remoteStates = opts.remoteStates
this.middleware = opts.middleware
@@ -263,7 +260,6 @@ export class Http {
buffers: this.buffers,
config: this.config,
shouldCorrelatePreRequests: this.shouldCorrelatePreRequests,
getAutomation: this.getAutomation,
getFileServerToken: this.getFileServerToken,
remoteStates: this.remoteStates,
request: this.request,
@@ -273,6 +269,7 @@ export class Http {
serverBus: this.serverBus,
resourceTypeAndCredentialManager: this.resourceTypeAndCredentialManager,
getCookieJar: this.getCookieJar,
simulatedCookies: [],
debug: (formatter, ...args) => {
if (!debugVerbose.enabled) return

View File

@@ -4,7 +4,7 @@ import type Debug from 'debug'
import type { CookieOptions } from 'express'
import { cors, concatStream, httpUtils } from '@packages/network'
import type { CypressIncomingRequest, CypressOutgoingResponse } from '@packages/proxy'
import type { HttpMiddleware } from '.'
import type { HttpMiddleware, HttpMiddlewareThis } from '.'
import iconv from 'iconv-lite'
import type { IncomingMessage, IncomingHttpHeaders } from 'http'
import { InterceptResponse } from '@packages/net-stubbing'
@@ -14,6 +14,7 @@ import zlib from 'zlib'
import { URL } from 'url'
import { CookiesHelper } from './util/cookies'
import { doesTopNeedToBeSimulated } from './util/top-simulation'
import { toughCookieToAutomationCookie } from '@packages/server/lib/util/cookies'
interface ResponseMiddlewareProps {
/**
@@ -372,10 +373,23 @@ 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 cookies: string | string[] | undefined = this.incomingRes.headers['set-cookie']
if (!cookies || !cookies.length) {
setSimulatedCookies(this)
return this.next()
}
@@ -441,17 +455,25 @@ const MaybeCopyCookiesFromIncomingRes: ResponseMiddleware = async function () {
appendCookie(cookie)
})
setSimulatedCookies(this)
const addedCookies = await cookiesHelper.getAddedCookies()
if (!addedCookies.length) {
return this.next()
}
this.serverBus.once('cross:origin:automation:cookies:received', () => {
// 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', () => {
this.next()
})
this.serverBus.emit('cross:origin:automation:cookies', addedCookies)
this.serverBus.emit('cross:origin:cookies', addedCookies)
}
const REDIRECT_STATUS_CODES: any[] = [301, 302, 303, 307, 308]
@@ -524,6 +546,7 @@ const MaybeInjectHtml: ResponseMiddleware = function () {
modifyObstructiveCode: this.config.modifyObstructiveCode,
url: this.req.proxiedUrl,
deferSourceMapRewrite: this.deferSourceMapRewrite,
simulatedCookies: this.simulatedCookies,
})
const encodedBody = iconv.encode(injectedBody, nodeCharset)

View File

@@ -1,5 +1,12 @@
import { oneLine } from 'common-tags'
import { getRunnerInjectionContents, getRunnerCrossOriginInjectionContents } from '@packages/resolve-dist'
import type { AutomationCookie } from '@packages/server/lib/automation/cookies'
interface FullCrossOriginOpts {
modifyObstructiveThirdPartyCode: boolean
modifyObstructiveCode: boolean
simulatedCookies: AutomationCookie[]
}
export function partial (domain) {
return oneLine`
@@ -21,15 +28,16 @@ export function full (domain) {
})
}
export function fullCrossOrigin (domain, { modifyObstructiveThirdPartyCode, modifyObstructiveCode }) {
return getRunnerCrossOriginInjectionContents().then((contents) => {
return oneLine`
<script type='text/javascript'>
document.domain = '${domain}';
const cypressConfig = { modifyObstructiveThirdPartyCode: ${modifyObstructiveThirdPartyCode}, modifyObstructiveCode: ${modifyObstructiveCode} };
export async function fullCrossOrigin (domain, options: FullCrossOriginOpts) {
const contents = await getRunnerCrossOriginInjectionContents()
return oneLine`
<script type='text/javascript'>
document.domain = '${domain}';
(function (cypressConfig) {
${contents}
</script>
`
})
}(${JSON.stringify(options)}));
</script>
`
}

View File

@@ -2,6 +2,7 @@ import * as inject from './inject'
import * as astRewriter from './ast-rewriter'
import * as regexRewriter from './regex-rewriter'
import type { CypressWantsInjection } from '../../types'
import type { AutomationCookie } from '@packages/server/lib/automation/cookies'
export type SecurityOpts = {
isHtml?: boolean
@@ -16,23 +17,36 @@ export type InjectionOpts = {
domainName: string
wantsInjection: CypressWantsInjection
wantsSecurityRemoved: any
simulatedCookies: AutomationCookie[]
}
const doctypeRe = /(<\!doctype.*?>)/i
const headRe = /(<head(?!er).*?>)/i
const bodyRe = /(<body.*?>)/i
const htmlRe = /(<html.*?>)/i
const doctypeRe = /<\!doctype.*?>/i
const headRe = /<head(?!er).*?>/i
const bodyRe = /<body.*?>/i
const htmlRe = /<html.*?>/i
function getRewriter (useAstSourceRewriting: boolean) {
return useAstSourceRewriting ? astRewriter : regexRewriter
}
function getHtmlToInject ({ domainName, wantsInjection, modifyObstructiveThirdPartyCode, modifyObstructiveCode }: InjectionOpts & SecurityOpts) {
function getHtmlToInject (opts: InjectionOpts & SecurityOpts) {
const {
domainName,
wantsInjection,
modifyObstructiveThirdPartyCode,
modifyObstructiveCode,
simulatedCookies,
} = opts
switch (wantsInjection) {
case 'full':
return inject.full(domainName)
case 'fullCrossOrigin':
return inject.fullCrossOrigin(domainName, { modifyObstructiveThirdPartyCode, modifyObstructiveCode })
return inject.fullCrossOrigin(domainName, {
modifyObstructiveThirdPartyCode,
modifyObstructiveCode,
simulatedCookies,
})
case 'partial':
return inject.partial(domainName)
default:
@@ -40,11 +54,19 @@ function getHtmlToInject ({ domainName, wantsInjection, modifyObstructiveThirdPa
}
}
export async function html (html: string, opts: SecurityOpts & InjectionOpts) {
const replace = (re, str) => {
return html.replace(re, str)
}
const insertBefore = (originalString, match, stringToInsert) => {
const index = match.index || 0
return `${originalString.slice(0, index)}${stringToInsert} ${originalString.slice(index)}`
}
const insertAfter = (originalString, match, stringToInsert) => {
const index = (match.index || 0) + match[0].length
return `${originalString.slice(0, index)} ${stringToInsert}${originalString.slice(index)}`
}
export async function html (html: string, opts: SecurityOpts & InjectionOpts) {
const htmlToInject = await Promise.resolve(getHtmlToInject(opts))
// strip clickjacking and framebusting
@@ -58,23 +80,31 @@ export async function html (html: string, opts: SecurityOpts & InjectionOpts) {
}
// TODO: move this into regex-rewriting and have ast-rewriting handle this in its own way
switch (false) {
case !headRe.test(html):
return replace(headRe, `$1 ${htmlToInject}`)
case !bodyRe.test(html):
return replace(bodyRe, `<head> ${htmlToInject} </head> $1`)
const headMatch = html.match(headRe)
case !htmlRe.test(html):
return replace(htmlRe, `$1 <head> ${htmlToInject} </head>`)
case !doctypeRe.test(html):
// if only <!DOCTYPE> content, inject <head> after doctype
return `${html}<head> ${htmlToInject} </head>`
default:
return `<head> ${htmlToInject} </head>${html}`
if (headMatch) {
return insertAfter(html, headMatch, htmlToInject)
}
const bodyMatch = html.match(bodyRe)
if (bodyMatch) {
return insertBefore(html, bodyMatch, `<head> ${htmlToInject} </head>`)
}
const htmlMatch = html.match(htmlRe)
if (htmlMatch) {
return insertAfter(html, htmlMatch, `<head> ${htmlToInject} </head>`)
}
// if only <!DOCTYPE> content, inject <head> after doctype
if (doctypeRe.test(html)) {
return `${html}<head> ${htmlToInject} </head>`
}
return `<head> ${htmlToInject} </head>${html}`
}
export function security (opts: SecurityOpts) {

View File

@@ -1,4 +1,4 @@
import { NetworkProxy } from '../../'
import { NetworkProxy, RequestResourceType } from '../../'
import {
netStubbingState as _netStubbingState,
NetStubbingState,
@@ -12,6 +12,7 @@ import supertest from 'supertest'
import { allowDestroy } from '@packages/network'
import { EventEmitter } from 'events'
import { RemoteStates } from '@packages/server/lib/remote_states'
import { CookieJar } from '@packages/server/lib/util/cookies'
const Request = require('@packages/server/lib/request')
const getFixture = async () => {}
@@ -39,12 +40,22 @@ context('network stubbing', () => {
netStubbingState,
config,
middleware: defaultMiddleware,
getCurrentBrowser: () => ({ family: 'chromium' }),
getCookieJar: () => new CookieJar(),
remoteStates,
getFileServerToken: () => 'fake-token',
request: new Request(),
getRenderedHTMLOrigins: () => ({}),
serverBus: new EventEmitter(),
resourceTypeAndCredentialManager: {
get (url: string, optionalResourceType?: RequestResourceType) {
return {
resourceType: 'xhr',
credentialStatus: 'same-origin',
}
},
set () {},
clear () {},
},
})
app.use((req, res, next) => {

View File

@@ -687,16 +687,19 @@ describe('http/response-middleware', function () {
expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie=value')
})
const getCookieJarStub = () => {
return {
getAllCookies: sinon.stub().returns([{ key: 'cookie', value: 'value' }]),
getCookies: sinon.stub().returns([]),
setCookie: sinon.stub(),
}
}
describe('same-origin', () => {
['same-origin', 'include'].forEach((credentialLevel) => {
it(`sets first-party cookie context in the jar when simulating top if credentials included with fetch with credential ${credentialLevel}`, async function () {
const appendStub = sinon.stub()
const cookieJar = {
getAllCookies: () => [{ key: 'cookie', value: 'value' }],
setCookie: sinon.stub(),
}
const cookieJar = getCookieJarStub()
const ctx = prepareContext({
cookieJar,
res: {
@@ -751,12 +754,7 @@ describe('http/response-middleware', function () {
;[true, false].forEach((credentialLevel) => {
it(`sets first-party cookie context in the jar when simulating top if withCredentials ${credentialLevel} with xhr`, async function () {
const appendStub = sinon.stub()
const cookieJar = {
getAllCookies: () => [{ key: 'cookie', value: 'value' }],
setCookie: sinon.stub(),
}
const cookieJar = getCookieJarStub()
const ctx = prepareContext({
cookieJar,
res: {
@@ -810,12 +808,7 @@ describe('http/response-middleware', function () {
it(`sets no cookies if fetch level is omit`, async function () {
const appendStub = sinon.stub()
const cookieJar = {
getAllCookies: () => [{ key: 'cookie', value: 'value' }],
setCookie: sinon.stub(),
}
const cookieJar = getCookieJarStub()
const ctx = prepareContext({
cookieJar,
res: {
@@ -871,12 +864,7 @@ describe('http/response-middleware', function () {
describe('same-site', () => {
it('sets first-party cookie context in the jar when simulating top if credentials included with fetch via include', async function () {
const appendStub = sinon.stub()
const cookieJar = {
getAllCookies: () => [{ key: 'cookie', value: 'value' }],
setCookie: sinon.stub(),
}
const cookieJar = getCookieJarStub()
const ctx = prepareContext({
cookieJar,
res: {
@@ -929,12 +917,7 @@ describe('http/response-middleware', function () {
it('sets first-party cookie context in the jar when simulating top if credentials true with xhr', async function () {
const appendStub = sinon.stub()
const cookieJar = {
getAllCookies: () => [{ key: 'cookie', value: 'value' }],
setCookie: sinon.stub(),
}
const cookieJar = getCookieJarStub()
const ctx = prepareContext({
cookieJar,
res: {
@@ -988,12 +971,7 @@ describe('http/response-middleware', function () {
;['same-origin', 'omit'].forEach((credentialLevel) => {
it(`sets no cookies if fetch level is ${credentialLevel}`, async function () {
const appendStub = sinon.stub()
const cookieJar = {
getAllCookies: () => [{ key: 'cookie', value: 'value' }],
setCookie: sinon.stub(),
}
const cookieJar = getCookieJarStub()
const ctx = prepareContext({
cookieJar,
res: {
@@ -1032,12 +1010,7 @@ describe('http/response-middleware', function () {
describe('cross-site', () => {
it('sets third-party cookie context in the jar when simulating top if credentials included with fetch', async function () {
const appendStub = sinon.stub()
const cookieJar = {
getAllCookies: () => [{ key: 'cookie', value: 'value' }],
setCookie: sinon.stub(),
}
const cookieJar = getCookieJarStub()
const ctx = prepareContext({
cookieJar,
res: {
@@ -1088,12 +1061,7 @@ describe('http/response-middleware', function () {
;['same-origin', 'omit'].forEach((credentialLevel) => {
it(`does NOT set third-party cookie context in the jar when simulating top if credentials ${credentialLevel} with fetch`, async function () {
const appendStub = sinon.stub()
const cookieJar = {
getAllCookies: () => [{ key: 'cookie', value: 'value' }],
setCookie: sinon.stub(),
}
const cookieJar = getCookieJarStub()
const ctx = prepareContext({
cookieJar,
res: {
@@ -1127,12 +1095,7 @@ describe('http/response-middleware', function () {
it('sets third-party cookie context in the jar when simulating top if withCredentials true with xhr', async function () {
const appendStub = sinon.stub()
const cookieJar = {
getAllCookies: () => [{ key: 'cookie', value: 'value' }],
setCookie: sinon.stub(),
}
const cookieJar = getCookieJarStub()
const ctx = prepareContext({
cookieJar,
res: {
@@ -1182,12 +1145,7 @@ describe('http/response-middleware', function () {
it('does not set third-party cookie context in the jar when simulating top if withCredentials false with xhr', async function () {
const appendStub = sinon.stub()
const cookieJar = {
getAllCookies: () => [{ key: 'cookie', value: 'value' }],
setCookie: sinon.stub(),
}
const cookieJar = getCookieJarStub()
const ctx = prepareContext({
cookieJar,
res: {
@@ -1221,12 +1179,7 @@ describe('http/response-middleware', function () {
it(`does NOT set third-party cookie context in the jar if secure cookie is not enabled`, async function () {
const appendStub = sinon.stub()
const cookieJar = {
getAllCookies: () => [{ key: 'cookie', value: 'value' }],
setCookie: sinon.stub(),
}
const cookieJar = getCookieJarStub()
const ctx = prepareContext({
cookieJar,
res: {
@@ -1259,12 +1212,7 @@ describe('http/response-middleware', function () {
it(`allows setting cookies if request type cannot be determined, but comes from the AUT frame (likely in the case of documents or redirects)`, async function () {
const appendStub = sinon.stub()
const cookieJar = {
getAllCookies: () => [{ key: 'cookie', value: 'value' }],
setCookie: sinon.stub(),
}
const cookieJar = getCookieJarStub()
const ctx = prepareContext({
cookieJar,
res: {
@@ -1299,12 +1247,7 @@ describe('http/response-middleware', function () {
it(`otherwise, does not allow setting cookies if request type cannot be determined and is not from the AUT and is cross-origin`, async function () {
const appendStub = sinon.stub()
const cookieJar = {
getAllCookies: () => [{ key: 'cookie', value: 'value' }],
setCookie: sinon.stub(),
}
const cookieJar = getCookieJarStub()
const ctx = prepareContext({
cookieJar,
res: {
@@ -1332,7 +1275,7 @@ describe('http/response-middleware', function () {
expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie=value')
})
it('does not send cross:origin:automation:cookies if request does not need top simulation', async () => {
it('does not send cross:origin:cookies if request does not need top simulation', async () => {
const { ctx } = prepareSameOriginContext()
await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx)
@@ -1340,11 +1283,8 @@ describe('http/response-middleware', function () {
expect(ctx.serverBus.emit).not.to.be.called
})
it('does not send cross:origin:automation:cookies if there are no added cookies', async () => {
const cookieJar = {
getAllCookies: () => [{ key: 'cookie', value: 'value' }],
}
it('does not send cross:origin:cookies if there are no added cookies', async () => {
const cookieJar = getCookieJarStub()
const ctx = prepareContext({
cookieJar,
incomingRes: {
@@ -1359,16 +1299,17 @@ describe('http/response-middleware', function () {
expect(ctx.serverBus.emit).not.to.be.called
})
it('sends cross:origin:automation:cookies if there are added cookies and resolves on cross:origin:automation:cookies:received', async () => {
const cookieJar = {
getAllCookies: sinon.stub(),
}
it('sends cross:origin:cookies with origin and cookies if there are added cookies and resolves on cross:origin:cookies:received', async () => {
const cookieJar = getCookieJarStub()
cookieJar.getAllCookies.onCall(0).returns([])
cookieJar.getAllCookies.onCall(1).returns([cookieStub({ key: 'cookie', value: 'value' })])
const ctx = prepareContext({
cookieJar,
req: {
isAUTFrame: true,
},
incomingRes: {
headers: {
'set-cookie': 'cookie=value',
@@ -1378,13 +1319,13 @@ describe('http/response-middleware', function () {
// test will hang if this.next() is not called, so this also tests
// that we move on once receiving this event
ctx.serverBus.once.withArgs('cross:origin:automation:cookies:received').yields()
ctx.serverBus.once.withArgs('cross:origin:cookies:received').yields()
await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx)
expect(ctx.serverBus.emit).to.be.calledWith('cross:origin:automation:cookies')
expect(ctx.serverBus.emit).to.be.calledWith('cross:origin:cookies')
const cookies = ctx.serverBus.emit.withArgs('cross:origin:automation:cookies').args[0][1]
const cookies = ctx.serverBus.emit.withArgs('cross:origin:cookies').args[0][1]
expect(cookies[0].name).to.equal('cookie')
expect(cookies[0].value).to.equal('value')
@@ -1405,6 +1346,7 @@ describe('http/response-middleware', function () {
const cookieJar = props.cookieJar || {
getAllCookies: () => [],
getCookies: () => [],
}
return {
@@ -1496,6 +1438,7 @@ describe('http/response-middleware', function () {
req: {
proxiedUrl: 'http://www.foobar.com:3501/primary-origin.html',
},
simulatedCookies: [],
})
return testMiddleware([MaybeInjectHtml], ctx)
@@ -1511,12 +1454,15 @@ describe('http/response-middleware', function () {
'useAstSourceRewriting': undefined,
'wantsInjection': 'full',
'wantsSecurityRemoved': true,
'simulatedCookies': [],
})
})
})
it('modifyObstructiveThirdPartyCode is false for primary requests', function () {
prepareContext({})
prepareContext({
simulatedCookies: [],
})
return testMiddleware([MaybeInjectHtml], ctx)
.then(() => {
@@ -1531,6 +1477,7 @@ describe('http/response-middleware', function () {
'useAstSourceRewriting': undefined,
'wantsInjection': 'full',
'wantsSecurityRemoved': true,
'simulatedCookies': [],
})
})
})
@@ -1544,6 +1491,7 @@ describe('http/response-middleware', function () {
modifyObstructiveCode: false,
experimentalModifyObstructiveThirdPartyCode: false,
},
simulatedCookies: [],
})
return testMiddleware([MaybeInjectHtml], ctx)
@@ -1559,6 +1507,7 @@ describe('http/response-middleware', function () {
'useAstSourceRewriting': undefined,
'wantsInjection': 'full',
'wantsSecurityRemoved': true,
'simulatedCookies': [],
})
})
})

View File

@@ -71,7 +71,9 @@ const handleErrorEvent = (event) => {
window.addEventListener('error', handleErrorEvent)
// Apply Patches
patchDocumentCookie(window)
const documentCookiePatch = patchDocumentCookie(cypressConfig.simulatedCookies)
const Cypress = findCypress()
// return null to trick contentWindow into thinking
// its not been iFramed if modifyObstructiveCode is true
@@ -92,13 +94,28 @@ const timers = createTimers()
timers.wrap()
const Cypress = findCypress()
const attachToCypress = (Cypress) => {
documentCookiePatch.onCypress(Cypress)
// Attach these to window so cypress can call them when it attaches.
window.cypressTimersReset = timers.reset
window.cypressTimersPause = timers.pause
Cypress.removeAllListeners('app:timers:reset')
Cypress.removeAllListeners('app:timers:pause')
Cypress.on('app:timers:reset', timers.reset)
Cypress.on('app:timers:pause', timers.pause)
}
// if the page loaded before creating a spec bridge for it, this method will
// be called, letting us know we can utilize window.Cypress. we can skip this
// if we already have access to window.Cypress
window.__attachToCypress = (asyncAttachedCypress) => {
if (!Cypress) {
attachToCypress(asyncAttachedCypress)
}
}
// Check for cy too to prevent a race condition for attaching.
if (Cypress && Cypress.cy) {
attachToCypress(Cypress)
Cypress.action('app:window:before:load', window)
}

View File

@@ -1,125 +0,0 @@
import { Cookie } from 'tough-cookie'
// document.cookie monkey-patching
// -------------------------------
// We monkey-patch document.cookie when in a cross-origin injection, because
// document.cookie runs into cross-origin restrictions when the AUT is on
// a different origin than top. The goal is to make it act like it would
// if the user's app was run in top.
//
// The general strategy is:
// - Keep the document.cookie value (`documentCookieValue`) available so
// the document.cookie getter can synchronously return it.
// - Optimistically update that value when document.cookie is set, so that
// subsequent synchronous calls to get the value will work.
// - On an interval, get the browser's cookies for the given domain, so that
// updates to the cookie jar (via http requests, cy.setCookie, etc) are
// reflected in the document.cookie value.
export const patchDocumentCookie = (win) => {
const getCookiesFromCypress = () => {
return new Promise((resolve, reject) => {
const handler = (event) => {
if (event.data.event === 'cross:origin:aut:get:cookie') {
window.removeEventListener('message', handler)
resolve(event.data.cookies)
}
}
setTimeout(() => {
window.removeEventListener('message', handler)
reject()
}, 1000)
window.addEventListener('message', handler)
window.top.postMessage({ event: 'cross:origin:aut:get:cookie', data: { href: window.location.href } }, '*')
})
}
// The interval value is arbitrary; it shouldn't be too often, but needs to
// be fairly frequent so that the local value is kept as up-to-date as
// possible. It's possible there could be a race condition where
// document.cookie returns an out-of-date value, but there's not really a
// way around that since it's a synchronous API and we can only get the
// browser's true cookie values asynchronously.
const syncCookieValues = () => {
return setInterval(async () => {
try {
// If Cypress is defined on the window, that means we have a spec bridge and we should use that to set cookies. If not we have to delegate to the primary cypress instance.
const cookies = window.Cypress ? await window.Cypress.automation('get:cookies', { domain: window.Cypress.Location.create(win.location.href).domain }) : await getCookiesFromCypress()
const cookiesString = (cookies || []).map((c) => `${c.name}=${c.value}`).join('; ')
documentCookieValue = cookiesString
} catch (err) {
// unlikely there will be errors, but ignore them in any case, since
// they're not user-actionable
}
}, 250)
}
let cookieSyncIntervalId = syncCookieValues()
const setAutomationCookie = (cookie) => {
// If Cypress is defined on the window, that means we have a spec bridge and we should use that to set cookies. If not we have to delegate to the primary cypress instance.
if (window.Cypress) {
const { superDomain } = window.Cypress.Location.create(win.location.href)
const automationCookie = window.Cypress.Cookies.toughCookieToAutomationCookie(window.Cypress.Cookies.parse(cookie), superDomain)
window.Cypress.automation('set:cookie', automationCookie)
.then(() => {
// Resume syncing once we've gotten confirmation that cookies have been set.
cookieSyncIntervalId = syncCookieValues()
})
.catch(() => {
// unlikely there will be errors, but ignore them in any case, since
// they're not user-actionable
})
} else {
const handler = (event) => {
if (event.data.event === 'cross:origin:aut:set:cookie') {
window.removeEventListener('message', handler)
// Resume syncing once we've gotten confirmation that cookies have been set.
cookieSyncIntervalId = syncCookieValues()
}
}
window.addEventListener('message', handler)
window.top.postMessage({ event: 'cross:origin:aut:set:cookie', data: { cookie, href: window.location.href } }, '*')
}
}
let documentCookieValue = ''
Object.defineProperty(win.document, 'cookie', {
get () {
return documentCookieValue
},
set (newValue) {
const cookie = Cookie.parse(newValue)
// If cookie is undefined, it was invalid and couldn't be parsed
if (!cookie) return documentCookieValue
const cookieString = `${cookie.key}=${cookie.value}`
clearInterval(cookieSyncIntervalId)
// New cookies get prepended to existing cookies
documentCookieValue = documentCookieValue.length
? `${cookieString}; ${documentCookieValue}`
: cookieString
setAutomationCookie(newValue)
return documentCookieValue
},
})
const onUnload = () => {
win.removeEventListener('unload', onUnload)
clearInterval(cookieSyncIntervalId)
}
win.addEventListener('unload', onUnload)
}

View File

@@ -0,0 +1,127 @@
import {
CookieJar,
toughCookieToAutomationCookie,
automationCookieToToughCookie,
} from '@packages/server/lib/util/cookies'
import { Cookie as ToughCookie } from 'tough-cookie'
import type { AutomationCookie } from '@packages/server/lib/automation/cookies'
const parseDocumentCookieString = (documentCookieString: string): AutomationCookie[] => {
if (!documentCookieString || !documentCookieString.trim().length) return []
return documentCookieString.split(';').map((cookieString) => {
const [name, value] = cookieString.split('=')
return {
name: name.trim(),
value: value.trim(),
domain: location.hostname,
expiry: null,
httpOnly: false,
maxAge: null,
path: null,
sameSite: 'lax',
secure: false,
}
})
}
const sendCookieToServer = (cookie: AutomationCookie) => {
window.top!.postMessage({
event: 'cross:origin:aut:set:cookie',
data: {
cookie,
url: location.href,
// url will always match the cookie domain, so strict context tells
// tough-cookie to allow it to be set
sameSiteContext: 'strict',
},
}, '*')
}
// document.cookie monkey-patching
// -------------------------------
// We monkey-patch document.cookie when in a cross-origin injection, because
// document.cookie runs into cross-origin restrictions when the AUT is on
// a different origin than top. The goal is to make it act like it would
// if the user's app was run in top.
export const patchDocumentCookie = (requestCookies: AutomationCookie[]) => {
const url = location.href
const domain = location.hostname
const cookieJar = new CookieJar()
const existingCookies = parseDocumentCookieString(document.cookie)
const getDocumentCookieValue = () => {
return cookieJar.getCookies(url, undefined).map((cookie: ToughCookie) => {
return `${cookie.key}=${cookie.value}`
}).join('; ')
}
const addCookies = (cookies: AutomationCookie[]) => {
cookies.forEach((cookie) => {
cookieJar.setCookie(automationCookieToToughCookie(cookie, domain), url, undefined)
})
}
// requestCookies are ones included with the page request that's now being
// injected into. they're captured by the proxy and included statically in
// the injection so they can be added here and available before page load
addCookies(existingCookies.concat(requestCookies))
Object.defineProperty(window.document, 'cookie', {
get () {
return getDocumentCookieValue()
},
set (newValue: any) {
const stringValue = `${newValue}`
const parsedCookie = CookieJar.parse(stringValue)
// if result is undefined, it was invalid and couldn't be parsed
if (!parsedCookie) return getDocumentCookieValue()
// we should be able to pass in parsedCookie here instead of the string
// value, but tough-cookie doesn't recognize it using an instanceof
// check and throws an error. because we can't, we have to massage
// some of the properties below to be correct
const cookie = cookieJar.setCookie(stringValue, url, undefined)!
cookie.sameSite = parsedCookie.sameSite
if (!parsedCookie.path) {
cookie.path = '/'
}
// send the cookie to the server so it can be set in the browser via
// automation and in our server-side cookie jar so it's available
// to subsequent injections
sendCookieToServer(toughCookieToAutomationCookie(cookie, domain))
return getDocumentCookieValue()
},
})
const reset = () => {
cookieJar.removeAllCookies()
}
const bindCypressListeners = (Cypress: Cypress.Cypress) => {
Cypress.on('test:before:run', reset)
// the following listeners are called from Cypress cookie commands, so that
// the document.cookie value is updated optimistically
Cypress.on('set:cookie', (cookie: AutomationCookie) => {
cookieJar.setCookie(automationCookieToToughCookie(cookie, domain), url, undefined)
})
Cypress.on('clear:cookie', (name: string) => {
cookieJar.removeCookie({ name, domain })
})
Cypress.on('clear:cookies', reset)
}
return {
onCypress: bindCypressListeners,
}
}

View File

@@ -162,7 +162,6 @@ export class ProjectBase<TServer extends Server> extends EE {
const [port, warning] = await this._server.open(cfg, {
getCurrentBrowser: () => this.browser,
getAutomation: () => this.automation,
getSpec: () => this.spec,
exit: this.options.args?.exit,
onError: this.options.onError,

View File

@@ -33,7 +33,6 @@ import type { FoundSpec } from '@packages/types'
import type { Server as WebSocketServer } from 'ws'
import { RemoteStates } from './remote_states'
import { cookieJar } from './util/cookies'
import type { Automation } from './automation/automation'
import type { AutomationCookie } from './automation/cookies'
import { resourceTypeAndCredentialManager, ResourceTypeAndCredentialManager } from './util/resourceTypeAndCredentialManager'
@@ -103,7 +102,6 @@ export interface OpenServerOptions {
onWarning: any
exit?: boolean
getCurrentBrowser: () => Browser
getAutomation: () => Automation
getSpec: () => FoundSpec | null
shouldCorrelatePreRequests: () => boolean
}
@@ -178,12 +176,12 @@ export abstract class ServerBase<TSocket extends SocketE2E | SocketCt> {
}
setupCrossOriginRequestHandling () {
this._eventBus.on('cross:origin:automation:cookies', (cookies: AutomationCookie[]) => {
this.socket.localBus.once('cross:origin:automation:cookies:received', () => {
this._eventBus.emit('cross:origin:automation:cookies:received')
this._eventBus.on('cross:origin:cookies', (cookies: AutomationCookie[]) => {
this.socket.localBus.once('cross:origin:cookies:received', () => {
this._eventBus.emit('cross:origin:cookies:received')
})
this.socket.toDriver('cross:origin:automation:cookies', cookies)
this.socket.toDriver('cross:origin:cookies', cookies)
})
this.socket.localBus.on('request:sent:with:credentials', this.resourceTypeAndCredentialManager.set)
@@ -197,7 +195,6 @@ export abstract class ServerBase<TSocket extends SocketE2E | SocketCt> {
open (config: Cfg, {
getSpec,
getAutomation,
getCurrentBrowser,
onError,
onWarning,
@@ -225,7 +222,7 @@ export abstract class ServerBase<TSocket extends SocketE2E | SocketCt> {
clientCertificates.loadClientCertificateConfig(config)
this.createNetworkProxy({
config, getAutomation,
config,
remoteStates: this._remoteStates,
resourceTypeAndCredentialManager: this.resourceTypeAndCredentialManager,
shouldCorrelatePreRequests,
@@ -321,7 +318,7 @@ export abstract class ServerBase<TSocket extends SocketE2E | SocketCt> {
return e
}
createNetworkProxy ({ config, getAutomation, remoteStates, resourceTypeAndCredentialManager, shouldCorrelatePreRequests }) {
createNetworkProxy ({ config, remoteStates, resourceTypeAndCredentialManager, shouldCorrelatePreRequests }) {
const getFileServerToken = () => {
return this._fileServer.token
}
@@ -331,7 +328,6 @@ export abstract class ServerBase<TSocket extends SocketE2E | SocketCt> {
this._networkProxy = new NetworkProxy({
config,
shouldCorrelatePreRequests,
getAutomation,
remoteStates,
getFileServerToken,
getCookieJar: () => cookieJar,

View File

@@ -20,13 +20,14 @@ import { openFile, OpenFileDetails } from './util/file-opener'
import open from './util/open'
import type { DestroyableHttpServer } from './util/server_destroy'
import * as session from './session'
import { cookieJar } from './util/cookies'
import { AutomationCookie, cookieJar, SameSiteContext, automationCookieToToughCookie } from './util/cookies'
import runEvents from './plugins/run_events'
// eslint-disable-next-line no-duplicate-imports
import type { Socket } from '@packages/socket'
import type { RunState, CachedTestState } from '@packages/types'
import { cors } from '@packages/network'
type StartListeningCallbacks = {
onSocketConnection: (socket: any) => void
@@ -392,6 +393,12 @@ export class SocketBase {
})
})
const setCrossOriginCookie = ({ cookie, url, sameSiteContext }: { cookie: AutomationCookie, url: string, sameSiteContext: SameSiteContext }) => {
const domain = cors.getOrigin(url)
cookieJar.setCookie(automationCookieToToughCookie(cookie, domain), url, sameSiteContext)
}
socket.on('backend:request', (eventName: string, ...args) => {
// cb is always the last argument
const cb = args.pop()
@@ -459,8 +466,10 @@ export class SocketBase {
return options.getRenderedHTMLOrigins()
case 'reset:rendered:html:origins':
return resetRenderedHTMLOrigins()
case 'cross:origin:automation:cookies:received':
return this.localBus.emit('cross:origin:automation:cookies:received')
case 'cross:origin:cookies:received':
return this.localBus.emit('cross:origin:cookies:received')
case 'cross:origin:set:cookie':
return setCrossOriginCookie(args[0])
case 'request:sent:with:credentials':
return this.localBus.emit('request:sent:with:credentials', args[0])
default:

View File

@@ -9,6 +9,8 @@ interface CookieData {
path?: string
}
export type SameSiteContext = 'strict' | 'lax' | 'none' | undefined
export const toughCookieToAutomationCookie = (toughCookie: Cookie, defaultDomain: string): AutomationCookie => {
const expiry = toughCookie.expiryTime()
@@ -25,6 +27,20 @@ export const toughCookieToAutomationCookie = (toughCookie: Cookie, defaultDomain
}
}
export const automationCookieToToughCookie = (automationCookie: AutomationCookie, defaultDomain: string): Cookie => {
return new Cookie({
domain: automationCookie.domain || defaultDomain,
expires: automationCookie.expiry != null && isFinite(automationCookie.expiry) ? new Date(automationCookie.expiry * 1000) : undefined,
httpOnly: automationCookie.httpOnly,
maxAge: automationCookie.maxAge || 'Infinity',
key: automationCookie.name,
path: automationCookie.path || undefined,
sameSite: automationCookie.sameSite === 'no_restriction' ? 'none' : automationCookie.sameSite,
secure: automationCookie.secure,
value: automationCookie.value,
})
}
const sameSiteNoneRe = /; +samesite=(?:'none'|"none"|none)/i
/**
@@ -57,7 +73,7 @@ export class CookieJar {
this._cookieJar = new ToughCookieJar(undefined, { allowSpecialUseDomain: true })
}
getCookies (url, sameSiteContext) {
getCookies (url: string, sameSiteContext: SameSiteContext = undefined) {
// @ts-ignore
return this._cookieJar.getCookiesSync(url, { sameSiteContext })
}
@@ -75,9 +91,9 @@ export class CookieJar {
return cookies
}
setCookie (cookie: string | Cookie, url: string, sameSiteContext: 'strict' | 'lax' | 'none') {
setCookie (cookie: string | Cookie, url: string, sameSiteContext: SameSiteContext) {
// @ts-ignore
this._cookieJar.setCookieSync(cookie, url, { sameSiteContext })
return this._cookieJar.setCookieSync(cookie, url, { sameSiteContext })
}
removeCookie (cookieData: CookieData) {