fix: Make cross-origin document.cookie work (#22594)

This commit is contained in:
Chris Breiding
2022-06-30 14:03:21 -04:00
committed by GitHub
parent 2b3ab9ac71
commit 5573fe50b0
13 changed files with 329 additions and 73 deletions

View File

@@ -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()
})
})
})

View File

@@ -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}`)
})
})
})
})

View File

@@ -18,14 +18,28 @@
<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="welcome" href="http://localhost:3500/welcome">Go to Welcome</a></li>
<li><a data-cy="cookie-login-land-on-idp">Login with Social (lands on idp)</a></li>
</ul>
<script>
document.querySelector('[data-cy=cookie-login]').href = `http://localhost:3500/prelogin?redirect=${encodeURIComponent('http://www.foobar.com:3500/fixtures/auth/cookie-login.html?redirect=http://localhost:3500/login')}`
document.querySelector('[data-cy=cookie-login-https]').href = `https://localhost:3502/prelogin?redirect=${encodeURIComponent('https://www.foobar.com:3502/fixtures/auth/cookie-login.html?redirect=https://localhost:3502/login')}`
document.querySelector('[data-cy=cookie-login-subdomain]').href = `http://localhost:3500/prelogin?redirect=${encodeURIComponent('http://www.foobar.com:3500/fixtures/auth/cookie-login.html?redirect=http://localhost:3500/login&subdomain=true')}`
document.querySelector('[data-cy=cookie-login-alias]').href = `http://localhost:3500/prelogin?redirect=${encodeURIComponent('http://www.foobar.com:3500/fixtures/auth/cookie-login.html?redirect=http://localhost:3500/login&alias=true')}`
document.querySelector('[data-cy=cookie-login-override]').href = `https://localhost:3502/prelogin?override=true&redirect=${encodeURIComponent('https://www.foobar.com:3502/fixtures/auth/cookie-login.html?redirect=https://localhost:3502/login')}`
function setHref (dataCy, redirect2, primaryQueryAddition = '') {
const isHttps = redirect2.startsWith('https')
const url = isHttps ? 'https://localhost:3502' : 'http://localhost:3500'
const redirect1Path = '/fixtures/auth/cookie-login.html'
const redirect = encodeURIComponent(isHttps
? `https://www.foobar.com:3502${redirect1Path}?redirect=${redirect2}`
: `http://www.foobar.com:3500${redirect1Path}?redirect=${redirect2}`)
document.querySelector(`[data-cy="${dataCy}"]`).href = (
`${url}/prelogin?redirect=${redirect}${primaryQueryAddition}`
)
}
setHref('cookie-login', 'http://localhost:3500/login')
setHref('cookie-login-https', 'https://localhost:3502/login')
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')
</script>
</body>
</html>

View File

@@ -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

View File

@@ -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 () {

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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 () {