diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index 427a9c9b10b4..e2f84f11780e 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -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 diff --git a/packages/driver/cypress/e2e/e2e/origin/cookie_login.cy.ts b/packages/driver/cypress/e2e/e2e/origin/cookie_login.cy.ts index aa6fe91bc2f3..73c1e3cae4ea 100644 --- a/packages/driver/cypress/e2e/e2e/origin/cookie_login.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/cookie_login.cy.ts @@ -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}`) + }) + }) }) }) diff --git a/packages/driver/cypress/fixtures/auth/document-cookie.html b/packages/driver/cypress/fixtures/auth/document-cookie.html index c36fa837157c..69b48c122ceb 100644 --- a/packages/driver/cypress/fixtures/auth/document-cookie.html +++ b/packages/driver/cypress/fixtures/auth/document-cookie.html @@ -3,11 +3,14 @@ -

should be replaced

+

+ document.cookie: + should be replaced +

diff --git a/packages/driver/cypress/fixtures/primary-origin.html b/packages/driver/cypress/fixtures/primary-origin.html index 7d98511e9a10..457c42d5c9f5 100644 --- a/packages/driver/cypress/fixtures/primary-origin.html +++ b/packages/driver/cypress/fixtures/primary-origin.html @@ -16,15 +16,16 @@
  • http://www.foobar.com:3500/fixtures/xhr-fetch-requests.html onLoad
  • http://www.foobar.com:3500/fixtures/xhr-fetch-requests.html
  • http://www.foobar.com:3500/fixtures/scripts-with-integrity.html
  • +
  • Visit foobar.com over http
  • +
  • Visit foobar.com over https
  • +
  • http://www.foobar.com:3500/fixtures/auth/document-cookie.html
  • Login with Social
  • Login with Social (https)
  • Login with Social (subdomain)
  • Login with Social (aliased localhost)
  • Login with Social (cookie override)
  • Login with Social (lands on idp)
  • -
  • Visit foobar.com over http
  • -
  • Visit foobar.com over https
  • -
  • http://www.foobar.com:3500/fixtures/auth/document-cookie.html
  • +
  • Login with Social (lands on document.cookie)
  • diff --git a/packages/driver/src/cross-origin/cypress.ts b/packages/driver/src/cross-origin/cypress.ts index 69bb62fa33ea..772b280d0e35 100644 --- a/packages/driver/src/cross-origin/cypress.ts +++ b/packages/driver/src/cross-origin/cypress.ts @@ -61,7 +61,7 @@ const createCypress = () => { const autWindow = findWindow() - if (autWindow) { + if (autWindow && !autWindow.Cypress) { attachToWindow(autWindow) } }) @@ -165,6 +165,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) @@ -172,14 +177,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) diff --git a/packages/driver/src/cross-origin/events/cookies.ts b/packages/driver/src/cross-origin/events/cookies.ts new file mode 100644 index 000000000000..ed8303a65bdc --- /dev/null +++ b/packages/driver/src/cross-origin/events/cookies.ts @@ -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 + }) + }) + }) +} diff --git a/packages/driver/src/cy/commands/cookies.ts b/packages/driver/src/cy/commands/cookies.ts index 55ff633ab986..3ec512cef14b 100644 --- a/packages/driver/src/cy/commands/cookies.ts +++ b/packages/driver/src/cy/commands/cookies.ts @@ -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) => { diff --git a/packages/driver/src/cy/commands/origin/index.ts b/packages/driver/src/cy/commands/origin/index.ts index 990659f32fae..e1519579d58f 100644 --- a/packages/driver/src/cy/commands/origin/index.ts +++ b/packages/driver/src/cy/commands/origin/index.ts @@ -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()), diff --git a/packages/driver/src/cy/stability.ts b/packages/driver/src/cy/stability.ts index 94335bd90ad7..8e213adb3c82 100644 --- a/packages/driver/src/cy/stability.ts +++ b/packages/driver/src/cy/stability.ts @@ -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() } }) diff --git a/packages/driver/src/cypress.ts b/packages/driver/src/cypress.ts index 6ad2a6279112..c7e6cac9fd7b 100644 --- a/packages/driver/src/cypress.ts +++ b/packages/driver/src/cypress.ts @@ -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) diff --git a/packages/driver/src/cypress/cookies.ts b/packages/driver/src/cypress/cookies.ts index 49d9bc3cee06..e13f6dcb4297 100644 --- a/packages/driver/src/cypress/cookies.ts +++ b/packages/driver/src/cypress/cookies.ts @@ -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 diff --git a/packages/driver/src/cypress/cy.ts b/packages/driver/src/cypress/cy.ts index a33ab0e9958b..f2bf158c71f3 100644 --- a/packages/driver/src/cypress/cy.ts +++ b/packages/driver/src/cypress/cy.ts @@ -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) { diff --git a/packages/driver/types/internal-types.d.ts b/packages/driver/types/internal-types.d.ts index b0a39b188503..afe3b2d638b9 100644 --- a/packages/driver/types/internal-types.d.ts +++ b/packages/driver/types/internal-types.d.ts @@ -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 /// + 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 + } } type AliasedRequest = { diff --git a/packages/proxy/lib/http/index.ts b/packages/proxy/lib/http/index.ts index 8c40d84f0640..234b6220c3e2 100644 --- a/packages/proxy/lib/http/index.ts +++ b/packages/proxy/lib/http/index.ts @@ -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 = { 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 diff --git a/packages/proxy/lib/http/response-middleware.ts b/packages/proxy/lib/http/response-middleware.ts index ea3613dc86c3..73303e1048ab 100644 --- a/packages/proxy/lib/http/response-middleware.ts +++ b/packages/proxy/lib/http/response-middleware.ts @@ -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) => { + 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) diff --git a/packages/proxy/lib/http/util/inject.ts b/packages/proxy/lib/http/util/inject.ts index 33aef2b2c2cf..f6c20bfe64b9 100644 --- a/packages/proxy/lib/http/util/inject.ts +++ b/packages/proxy/lib/http/util/inject.ts @@ -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` - - ` - }) + }(${JSON.stringify(options)})); + + ` } diff --git a/packages/proxy/lib/http/util/rewriter.ts b/packages/proxy/lib/http/util/rewriter.ts index c041cdbdc8e0..6e206320eb16 100644 --- a/packages/proxy/lib/http/util/rewriter.ts +++ b/packages/proxy/lib/http/util/rewriter.ts @@ -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 = /()/i -const bodyRe = /()/i -const htmlRe = /()/i +const doctypeRe = /<\!doctype.*?>/i +const headRe = //i +const bodyRe = //i +const htmlRe = //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, ` ${htmlToInject} $1`) + const headMatch = html.match(headRe) - case !htmlRe.test(html): - return replace(htmlRe, `$1 ${htmlToInject} `) + if (headMatch) { + return insertAfter(html, headMatch, htmlToInject) + } - case !doctypeRe.test(html): - // if only content, inject after doctype - return `${html} ${htmlToInject} ` + const bodyMatch = html.match(bodyRe) - default: - return ` ${htmlToInject} ${html}` + if (bodyMatch) { + return insertBefore(html, bodyMatch, ` ${htmlToInject} `) + } + + const htmlMatch = html.match(htmlRe) + + if (htmlMatch) { + return insertAfter(html, htmlMatch, ` ${htmlToInject} `) + } + + // if only content, inject after doctype + if (doctypeRe.test(html)) { + return `${html} ${htmlToInject} ` } + + return ` ${htmlToInject} ${html}` } export function security (opts: SecurityOpts) { diff --git a/packages/proxy/test/integration/net-stubbing.spec.ts b/packages/proxy/test/integration/net-stubbing.spec.ts index fd5bfe27957d..fa6923178c2c 100644 --- a/packages/proxy/test/integration/net-stubbing.spec.ts +++ b/packages/proxy/test/integration/net-stubbing.spec.ts @@ -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) => { diff --git a/packages/proxy/test/unit/http/response-middleware.spec.ts b/packages/proxy/test/unit/http/response-middleware.spec.ts index 43ee7b0936c5..d239632b2427 100644 --- a/packages/proxy/test/unit/http/response-middleware.spec.ts +++ b/packages/proxy/test/unit/http/response-middleware.spec.ts @@ -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': [], }) }) }) diff --git a/packages/runner/injection/cross-origin.js b/packages/runner/injection/cross-origin.js index fe8c062133cf..9b44a3420dcf 100644 --- a/packages/runner/injection/cross-origin.js +++ b/packages/runner/injection/cross-origin.js @@ -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) } diff --git a/packages/runner/injection/patches/cookies.js b/packages/runner/injection/patches/cookies.js deleted file mode 100644 index 2ef82a88c165..000000000000 --- a/packages/runner/injection/patches/cookies.js +++ /dev/null @@ -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) -} diff --git a/packages/runner/injection/patches/cookies.ts b/packages/runner/injection/patches/cookies.ts new file mode 100644 index 000000000000..83f720d21bb1 --- /dev/null +++ b/packages/runner/injection/patches/cookies.ts @@ -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, + } +} diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index aa1b453c066f..3b41ed790b16 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -162,7 +162,6 @@ export class ProjectBase 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, diff --git a/packages/server/lib/server-base.ts b/packages/server/lib/server-base.ts index 527a23d18996..1b67c70b06ee 100644 --- a/packages/server/lib/server-base.ts +++ b/packages/server/lib/server-base.ts @@ -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 { } 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 { open (config: Cfg, { getSpec, - getAutomation, getCurrentBrowser, onError, onWarning, @@ -225,7 +222,7 @@ export abstract class ServerBase { clientCertificates.loadClientCertificateConfig(config) this.createNetworkProxy({ - config, getAutomation, + config, remoteStates: this._remoteStates, resourceTypeAndCredentialManager: this.resourceTypeAndCredentialManager, shouldCorrelatePreRequests, @@ -321,7 +318,7 @@ export abstract class ServerBase { 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 { this._networkProxy = new NetworkProxy({ config, shouldCorrelatePreRequests, - getAutomation, remoteStates, getFileServerToken, getCookieJar: () => cookieJar, diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index a3a647b4ece1..157007ae2815 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -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: diff --git a/packages/server/lib/util/cookies.ts b/packages/server/lib/util/cookies.ts index 0dce02e47838..650c8c7b9277 100644 --- a/packages/server/lib/util/cookies.ts +++ b/packages/server/lib/util/cookies.ts @@ -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) {