diff --git a/packages/driver/cypress/e2e/e2e/origin/commands/aliasing.cy.ts b/packages/driver/cypress/e2e/e2e/origin/commands/aliasing.cy.ts index 0b879b654f6b..e225b4270f97 100644 --- a/packages/driver/cypress/e2e/e2e/origin/commands/aliasing.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/commands/aliasing.cy.ts @@ -17,14 +17,14 @@ context('cy.origin aliasing', () => { it('fails for dom elements outside origin', (done) => { cy.on('fail', (err) => { - expect(err.message).to.equal('`cy.get()` could not find a registered alias for: `@welcome_button`.\nYou have not aliased anything yet.') + expect(err.message).to.equal('`cy.get()` could not find a registered alias for: `@link`.\nYou have not aliased anything yet.') done() }) - cy.get('[data-cy="welcome"]').as('welcome_button') + cy.get('[data-cy="cross-origin-secondary-link"]').as('link') cy.origin('http://foobar.com:3500', () => { - cy.get('@welcome_button').click() + cy.get('@link').click() }) }) }) 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 4910d7b4948b..f5c251013c42 100644 --- a/packages/driver/cypress/e2e/e2e/origin/cookie_login.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/cookie_login.cy.ts @@ -34,6 +34,11 @@ describe('cy.origin - cookie login', () => { cy.get('h1').invoke('text').should('equal', 'No user found') } + beforeEach(() => { + // makes it nice and readable even on a small screen with devtools open :) + cy.viewport(300, 400) + }) + /**************************************************************************** Cookie Login Flow - localhost/fixtures/primary-origin.html: @@ -107,17 +112,6 @@ describe('cy.origin - cookie login', () => { verifyLoggedIn(username) }) - it('makes cross-origin cookies readable via document.cookie', () => { - cy.visit('/fixtures/primary-origin.html') - cy.get('[data-cy="cookie-login"]').click() - cy.origin('http://foobar.com:3500', { args: { username } }, ({ username }) => { - cy.get('[data-cy="username"]').type(username) - cy.get('[data-cy="login"]').click() - }) - - cy.document().its('cookie').should('include', `user=${username}`) - }) - it('handles browser-sent cookies being overridden by server-kept cookies', () => { cy.visit('https://localhost:3502/fixtures/primary-origin.html') cy.get('[data-cy="cookie-login-override"]').click() @@ -421,10 +415,10 @@ describe('cy.origin - cookie login', () => { username = getUsername() cy.visit('/fixtures/primary-origin.html') - cy.get('[data-cy="cookie-login"]').click() }) it('expired -> not logged in', () => { + cy.get('[data-cy="cookie-login"]').click() cy.origin('http://foobar.com:3500', { args: { username } }, ({ username }) => { const expires = (new Date()).toUTCString() @@ -437,6 +431,7 @@ describe('cy.origin - cookie login', () => { }) it('expired -> not accessible via cy.getCookie()', () => { + cy.get('[data-cy="cookie-login"]').click() cy.origin('http://foobar.com:3500', { args: { username } }, ({ username }) => { const expires = (new Date()).toUTCString() @@ -449,6 +444,7 @@ describe('cy.origin - cookie login', () => { }) it('expired -> not accessible via document.cookie', () => { + cy.get('[data-cy="cookie-login-land-on-idp"]').click() cy.origin('http://foobar.com:3500', { args: { username } }, ({ username }) => { const expires = (new Date()).toUTCString() @@ -457,7 +453,10 @@ describe('cy.origin - cookie login', () => { cy.get('[data-cy="login"]').click() }) - cy.document().its('cookie').should('not.include', 'user=') + cy.origin('http://idp.com:3500', () => { + cy.clearCookie('user') + cy.document().its('cookie').should('not.include', 'user=') + }) }) }) @@ -468,10 +467,10 @@ describe('cy.origin - cookie login', () => { username = getUsername() cy.visit('/fixtures/primary-origin.html') - cy.get('[data-cy="cookie-login"]').click() }) it('past max-age -> not logged in', () => { + cy.get('[data-cy="cookie-login"]').click() cy.origin('http://foobar.com:3500', { args: { username } }, ({ username }) => { cy.get('[data-cy="username"]').type(username) cy.get('[data-cy="localhostCookieProps"]').type('Max-Age=1') @@ -487,6 +486,7 @@ describe('cy.origin - cookie login', () => { // 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 cy.getCookie()', { browser: '!firefox' }, () => { + cy.get('[data-cy="cookie-login"]').click() cy.origin('http://foobar.com:3500', { args: { username } }, ({ username }) => { cy.get('[data-cy="username"]').type(username) cy.get('[data-cy="localhostCookieProps"]').type('Max-Age=1') @@ -499,18 +499,25 @@ describe('cy.origin - cookie login', () => { }) it('past max-age -> not accessible via document.cookie', () => { + cy.get('[data-cy="cookie-login-land-on-idp"]').click() cy.origin('http://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="login"]').click() }) - cy.wait(1000) // give cookie time to expire - cy.reload() - cy.document().its('cookie').should('not.include', 'user=') + cy.origin('http://idp.com:3500', () => { + cy.wait(1000) // give cookie time to expire + cy.reload() + cy.document().its('cookie').should('not.include', 'user=') + }) }) describe('preference over Expires', () => { + beforeEach(() => { + cy.get('[data-cy="cookie-login"]').click() + }) + it('past Max-Age, before Expires -> not logged in', () => { const expires = dayjs().add(1, 'day').toDate().toUTCString() @@ -642,4 +649,152 @@ describe('cy.origin - cookie login', () => { verifyIdpNotLoggedIn({ isHttps: true, cookieKey: '__Secure-user' }) }) }) + + describe('document.cookie', () => { + let username + + beforeEach(() => { + username = getUsername() + + cy.visit('/fixtures/primary-origin.html') + }) + + it('gets cookie set by http request', () => { + cy.get('[data-cy="cookie-login-land-on-idp"]').click() + cy.origin('http://foobar.com:3500', { args: { username } }, ({ username }) => { + cy.get('[data-cy="username"]').type(username) + cy.get('[data-cy="login"]').click() + }) + + cy.origin('http://idp.com:3500', { args: { username } }, ({ username }) => { + cy.document().its('cookie').should('include', `user=${username}`) + }) + }) + + it('works when setting cookie', () => { + cy.get('[data-cy="cross-origin-secondary-link"]').click() + cy.origin('http://foobar.com:3500', () => { + cy.document().then((doc) => { + doc.cookie = 'key=value' + }) + + cy.document().its('cookie').should('equal', 'key=value') + }) + }) + + it('works when setting cookie with extra, benign parts', () => { + cy.get('[data-cy="cross-origin-secondary-link"]').click() + cy.origin('http://foobar.com:3500', () => { + cy.document().then((doc) => { + doc.cookie = 'key=value; wont=beset' + }) + + cy.document().its('cookie').should('equal', 'key=value') + }) + }) + + it('cookie properties are preserved when set via automation', () => { + cy.get('[data-cy="cross-origin-secondary-link"]').click() + cy.origin('http://foobar.com:3500', () => { + cy.document().then((doc) => { + doc.cookie = 'key=value; SameSite=Strict; Path=/foo' + }) + + cy.getCookie('key').then((cookie) => { + expect(Cypress._.omit(cookie, 'expiry')).to.deep.equal({ + domain: '.foobar.com', + httpOnly: false, + name: 'key', + path: '/foo', + sameSite: 'strict', + secure: false, + value: 'value', + }) + }) + }) + }) + + it('does not set cookie when invalid', () => { + cy.get('[data-cy="cross-origin-secondary-link"]').click() + cy.origin('http://foobar.com:3500', () => { + cy.document().then((doc) => { + doc.cookie = '=value' + }) + + cy.document().its('cookie').should('equal', '') + }) + }) + + it('works when setting subsequent cookies', () => { + cy.get('[data-cy="cross-origin-secondary-link"]').click() + cy.origin('http://foobar.com:3500', () => { + cy.document().then((doc) => { + doc.cookie = 'key1=value1' + }) + + cy.document().its('cookie').should('equal', 'key1=value1') + cy.document().then((doc) => { + doc.cookie = 'key2=value2' + }) + + cy.document().its('cookie').should('equal', 'key2=value2; key1=value1') + }) + }) + + it('makes cookie available to cy.getCookie()', () => { + cy.get('[data-cy="cross-origin-secondary-link"]').click() + cy.origin('http://foobar.com:3500', () => { + cy.document().then((doc) => { + doc.cookie = 'key=value' + }) + + cy.getCookie('key').its('value').should('equal', 'value') + }) + }) + + it('no longer returns cookie after cy.clearCookie()', () => { + cy.get('[data-cy="cookie-login-land-on-idp"]').click() + cy.origin('http://foobar.com:3500', { args: { username } }, ({ username }) => { + cy.get('[data-cy="username"]').type(username) + cy.get('[data-cy="login"]').click() + }) + + cy.origin('http://idp.com:3500', () => { + cy.clearCookie('user') + cy.document().its('cookie').should('equal', '') + }) + }) + + it('no longer returns cookie after cy.clearCookies()', () => { + cy.get('[data-cy="cookie-login-land-on-idp"]').click() + cy.origin('http://foobar.com:3500', { args: { username } }, ({ username }) => { + cy.get('[data-cy="username"]').type(username) + cy.get('[data-cy="login"]').click() + }) + + cy.origin('http://idp.com:3500', () => { + cy.clearCookies() + cy.document().its('cookie').should('equal', '') + }) + }) + + it('works when setting cookie in addition to cookie that already exists from http request', () => { + cy.get('[data-cy="cookie-login-land-on-idp"]').click() + cy.origin('http://foobar.com:3500', { args: { username } }, ({ username }) => { + cy.get('[data-cy="username"]').type(username) + cy.get('[data-cy="login"]').click() + }) + + cy.origin('http://idp.com:3500', { args: { username } }, ({ username }) => { + cy.document().then((doc) => { + doc.cookie = 'key=value' + }) + + // order of the cookies differs depending on browser, so just + // ensure that each one is there + cy.document().its('cookie').should('include', 'key=value') + cy.document().its('cookie').should('include', `user=${username}`) + }) + }) + }) }) diff --git a/packages/driver/cypress/fixtures/primary-origin.html b/packages/driver/cypress/fixtures/primary-origin.html index b6f06dc0f666..ba33a6f66d38 100644 --- a/packages/driver/cypress/fixtures/primary-origin.html +++ b/packages/driver/cypress/fixtures/primary-origin.html @@ -18,14 +18,28 @@
  • Login with Social (subdomain)
  • Login with Social (aliased localhost)
  • Login with Social (cookie override)
  • -
  • Go to Welcome
  • +
  • Login with Social (lands on idp)
  • diff --git a/packages/driver/src/cypress/cookies.ts b/packages/driver/src/cypress/cookies.ts index 61815680e06c..49d9bc3cee06 100644 --- a/packages/driver/src/cypress/cookies.ts +++ b/packages/driver/src/cypress/cookies.ts @@ -1,5 +1,6 @@ import _ from 'lodash' import Cookies from 'js-cookie' +import { CookieJar, toughCookieToAutomationCookie } from '@packages/server/lib/util/cookies' import $errUtils from './error_utils' @@ -149,6 +150,11 @@ export const $Cookies = (namespace, domain) => { return _.extend(defaults, obj) }, + parse (cookieString: string) { + return CookieJar.parse(cookieString) + }, + + toughCookieToAutomationCookie, } return API diff --git a/packages/proxy/lib/http/index.ts b/packages/proxy/lib/http/index.ts index 97b6add166c9..7263e2bba2bc 100644 --- a/packages/proxy/lib/http/index.ts +++ b/packages/proxy/lib/http/index.ts @@ -20,7 +20,7 @@ import RequestMiddleware from './request-middleware' import ResponseMiddleware from './response-middleware' import { DeferredSourceMapCache } from '@packages/rewriter' import type { RemoteStates } from '@packages/server/lib/remote_states' -import type { CookieJar } from '@packages/server/lib/cookie-jar' +import type { CookieJar } from '@packages/server/lib/util/cookies' import type { Automation } from '@packages/server/lib/automation/automation' function getRandomColorFn () { diff --git a/packages/proxy/lib/http/util/cookies.ts b/packages/proxy/lib/http/util/cookies.ts index 21fbf78c43dc..1567f5a2028e 100644 --- a/packages/proxy/lib/http/util/cookies.ts +++ b/packages/proxy/lib/http/util/cookies.ts @@ -2,8 +2,7 @@ import _ from 'lodash' import type Debug from 'debug' import { URL } from 'url' import { cors } from '@packages/network' -import { Cookie, CookieJar } from '@packages/server/lib/cookie-jar' -import type { AutomationCookie } from '@packages/server/lib/automation/cookies' +import { AutomationCookie, Cookie, CookieJar, toughCookieToAutomationCookie } from '@packages/server/lib/util/cookies' interface RequestDetails { url: string @@ -32,26 +31,6 @@ export const getSameSiteContext = (autUrl: string | undefined, requestUrl: strin return isAUTFrameRequest ? 'lax' : 'none' } -const sameSiteNoneRe = /; +samesite=(?:'none'|"none"|none)/i - -export const parseCookie = (cookie: string) => { - const toughCookie = CookieJar.parse(cookie) - - if (!toughCookie) return - - // fixes tough-cookie defaulting undefined/invalid SameSite to 'none' - // https://github.com/salesforce/tough-cookie/issues/191 - const hasUnspecifiedSameSite = toughCookie.sameSite === 'none' && !sameSiteNoneRe.test(cookie) - - // not all browsers currently default to lax, but they're heading in that - // direction since it's now the standard, so this is more future-proof - if (hasUnspecifiedSameSite) { - toughCookie.sameSite = 'lax' - } - - return toughCookie -} - const comparableCookieString = (toughCookie: Cookie): string => { return _(toughCookie) .pick('key', 'value', 'domain', 'path') @@ -71,22 +50,6 @@ const matchesPreviousCookie = (previousCookies: Cookie[], cookie: Cookie) => { }) } -const toughCookieToAutomationCookie = (toughCookie: Cookie, defaultDomain: string): AutomationCookie => { - const expiry = toughCookie.expiryTime() - - return { - domain: toughCookie.domain || defaultDomain, - expiry: isFinite(expiry) ? expiry / 1000 : null, - httpOnly: toughCookie.httpOnly, - maxAge: toughCookie.maxAge, - name: toughCookie.key, - path: toughCookie.path, - sameSite: toughCookie.sameSite === 'none' ? 'no_restriction' : toughCookie.sameSite, - secure: toughCookie.secure, - value: toughCookie.value, - } -} - /** * Utility for dealing with cross-origin cookies * - Tracks which cookies were added to our server-side cookie jar during @@ -140,7 +103,7 @@ export class CookiesHelper { } setCookie (cookie: string) { - const toughCookie = parseCookie(cookie) + const toughCookie = CookieJar.parse(cookie) // don't set the cookie in our own cookie jar if the parsed cookie is // undefined (meaning it's invalid) or if the browser would not set it diff --git a/packages/runner/injection/cookies.js b/packages/runner/injection/cookies.js new file mode 100644 index 000000000000..fcad09e0dc05 --- /dev/null +++ b/packages/runner/injection/cookies.js @@ -0,0 +1,82 @@ +/* global document */ + +// 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 = (Cypress) => { + const setAutomationCookie = (toughCookie) => { + const { superDomain } = Cypress.Location.create(window.location.href) + const automationCookie = Cypress.Cookies.toughCookieToAutomationCookie(toughCookie, superDomain) + + Cypress.automation('set:cookie', automationCookie) + .catch(() => { + // unlikely there will be errors, but ignore them in any case, since + // they're not user-actionable + }) + } + + let documentCookieValue = '' + + Object.defineProperty(document, 'cookie', { + get () { + return documentCookieValue + }, + + set (newValue) { + const cookie = Cypress.Cookies.parse(newValue) + + // If cookie is undefined, it was invalid and couldn't be parsed + if (!cookie) return documentCookieValue + + const cookieString = `${cookie.key}=${cookie.value}` + + // New cookies get prepended to existing cookies + documentCookieValue = documentCookieValue.length + ? `${cookieString}; ${documentCookieValue}` + : cookieString + + setAutomationCookie(cookie) + + return documentCookieValue + }, + }) + + // 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 intervalId = setInterval(async () => { + const { superDomain: domain } = Cypress.Location.create(window.location.href) + + try { + const cookies = await Cypress.automation('get:cookies', { domain }) + 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) + + const onUnload = () => { + window.removeEventListener('unload', onUnload) + clearInterval(intervalId) + } + + window.addEventListener('unload', onUnload) +} diff --git a/packages/runner/injection/cross-origin.js b/packages/runner/injection/cross-origin.js index 316752571a2f..9470e4ad20e4 100644 --- a/packages/runner/injection/cross-origin.js +++ b/packages/runner/injection/cross-origin.js @@ -8,6 +8,7 @@ */ import { createTimers } from './timers' +import { patchDocumentCookie } from './cookies' const findCypress = () => { for (let index = 0; index < window.parent.frames.length; index++) { @@ -40,6 +41,8 @@ const findCypress = () => { const Cypress = findCypress() +patchDocumentCookie(Cypress) + // the timers are wrapped in the injection code similar to the primary origin const timers = createTimers() diff --git a/packages/server/lib/automation/automation.ts b/packages/server/lib/automation/automation.ts index ac7317348e8e..1f22001d7c83 100644 --- a/packages/server/lib/automation/automation.ts +++ b/packages/server/lib/automation/automation.ts @@ -4,7 +4,7 @@ import { Cookies } from './cookies' import { Screenshot } from './screenshot' import type { BrowserPreRequest } from '@packages/proxy' import type { AutomationMiddleware, OnRequestEvent } from '@packages/types' -import { cookieJar } from '../cookie-jar' +import { cookieJar } from '../util/cookies' export type OnBrowserPreRequest = (browserPreRequest: BrowserPreRequest) => void diff --git a/packages/server/lib/open_project.ts b/packages/server/lib/open_project.ts index 1cd7bf3c564a..fb562e5ee97d 100644 --- a/packages/server/lib/open_project.ts +++ b/packages/server/lib/open_project.ts @@ -10,7 +10,7 @@ import * as errors from './errors' import preprocessor from './plugins/preprocessor' import runEvents from './plugins/run_events' import * as session from './session' -import { cookieJar } from './cookie-jar' +import { cookieJar } from './util/cookies' import { getSpecUrl } from './project_utils' import type { LaunchOpts, OpenProjectLaunchOptions, InitializeProjectOptions } from '@packages/types' import { DataContext, getCtx } from '@packages/data-context' diff --git a/packages/server/lib/server-base.ts b/packages/server/lib/server-base.ts index 4c2fa9ec49e4..2957f79e9858 100644 --- a/packages/server/lib/server-base.ts +++ b/packages/server/lib/server-base.ts @@ -32,7 +32,7 @@ import { createRoutesCT } from './routes-ct' import type { FoundSpec } from '@packages/types' import type { Server as WebSocketServer } from 'ws' import { RemoteStates } from './remote_states' -import { cookieJar } from './cookie-jar' +import { cookieJar } from './util/cookies' import type { Automation } from './automation/automation' import type { AutomationCookie } from './automation/cookies' diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index 259d8b579df0..c88e918c5eb0 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -16,7 +16,7 @@ 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 './cookie-jar' +import { cookieJar } from './util/cookies' // eslint-disable-next-line no-duplicate-imports import type { Socket } from '@packages/socket' import path from 'path' diff --git a/packages/server/lib/cookie-jar.ts b/packages/server/lib/util/cookies.ts similarity index 54% rename from packages/server/lib/cookie-jar.ts rename to packages/server/lib/util/cookies.ts index 70631035135a..0dce02e47838 100644 --- a/packages/server/lib/cookie-jar.ts +++ b/packages/server/lib/util/cookies.ts @@ -1,6 +1,7 @@ import { Cookie, CookieJar as ToughCookieJar } from 'tough-cookie' +import type { AutomationCookie } from '../automation/cookies' -export { Cookie } +export { AutomationCookie, Cookie } interface CookieData { name: string @@ -8,6 +9,24 @@ interface CookieData { path?: string } +export const toughCookieToAutomationCookie = (toughCookie: Cookie, defaultDomain: string): AutomationCookie => { + const expiry = toughCookie.expiryTime() + + return { + domain: toughCookie.domain || defaultDomain, + expiry: isFinite(expiry) ? expiry / 1000 : null, + httpOnly: toughCookie.httpOnly, + maxAge: toughCookie.maxAge, + name: toughCookie.key, + path: toughCookie.path, + sameSite: toughCookie.sameSite === 'none' ? 'no_restriction' : toughCookie.sameSite, + secure: toughCookie.secure, + value: toughCookie.value, + } +} + +const sameSiteNoneRe = /; +samesite=(?:'none'|"none"|none)/i + /** * An adapter for tough-cookie's CookieJar * Holds onto cookies captured via the proxy, so they can be applied to @@ -16,8 +35,22 @@ interface CookieData { export class CookieJar { _cookieJar: ToughCookieJar - static parse (cookie) { - return Cookie.parse(cookie) + static parse (cookie: string) { + const toughCookie = Cookie.parse(cookie) + + if (!toughCookie) return + + // fixes tough-cookie defaulting undefined/invalid SameSite to 'none' + // https://github.com/salesforce/tough-cookie/issues/191 + const hasUnspecifiedSameSite = toughCookie.sameSite === 'none' && !sameSiteNoneRe.test(cookie) + + // not all browsers currently default to lax, but they're heading in that + // direction since it's now the standard, so this is more future-proof + if (hasUnspecifiedSameSite) { + toughCookie.sameSite = 'lax' + } + + return toughCookie } constructor () {