From d5a35b49c14cd0641526f6e01a281c9a7fad79a8 Mon Sep 17 00:00:00 2001 From: Bill Glesias Date: Wed, 15 Feb 2023 16:11:14 -0500 Subject: [PATCH 1/2] chore: improve types for automation cookies --- .../driver/src/cross-origin/events/cookies.ts | 6 +- packages/driver/types/internal-types.d.ts | 4 +- packages/proxy/lib/http/index.ts | 5 +- packages/proxy/lib/http/util/cookies.ts | 4 +- packages/proxy/lib/http/util/inject.ts | 4 +- packages/proxy/lib/http/util/rewriter.ts | 4 +- packages/runner/injection/patches/cookies.ts | 12 +-- packages/server/lib/automation/automation.ts | 8 +- packages/server/lib/automation/cookies.ts | 98 +++++++++++-------- packages/server/lib/server-base.ts | 5 +- packages/server/lib/socket-base.ts | 4 +- packages/server/lib/util/cookies.ts | 20 +++- 12 files changed, 100 insertions(+), 74 deletions(-) diff --git a/packages/driver/src/cross-origin/events/cookies.ts b/packages/driver/src/cross-origin/events/cookies.ts index ed8303a65bdc..a4198143dd67 100644 --- a/packages/driver/src/cross-origin/events/cookies.ts +++ b/packages/driver/src/cross-origin/events/cookies.ts @@ -1,4 +1,4 @@ -import type { AutomationCookie } from '@packages/server/lib/automation/cookies' +import type { SerializableAutomationCookie } from '@packages/server/lib/util/cookies' import type { ICypress } from '../../cypress' // cross-origin cookies collected by the the proxy are sent down to the driver @@ -10,10 +10,10 @@ 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 cookiesToSend: SerializableAutomationCookie[] = [] let waitingToSend = false - Cypress.on('cross:origin:cookies', (cookies: AutomationCookie[]) => { + Cypress.on('cross:origin:cookies', (cookies: SerializableAutomationCookie[]) => { cookiesToSend = cookiesToSend.concat(cookies) Cypress.backend('cross:origin:cookies:received') diff --git a/packages/driver/types/internal-types.d.ts b/packages/driver/types/internal-types.d.ts index e5cc171c1706..7ebafc77c597 100644 --- a/packages/driver/types/internal-types.d.ts +++ b/packages/driver/types/internal-types.d.ts @@ -53,10 +53,10 @@ declare namespace Cypress { } interface Actions { - (action: 'set:cookie', fn: (cookie: AutomationCookie) => void) + (action: 'set:cookie', fn: (cookie: SerializableAutomationCookie) => void) (action: 'clear:cookie', fn: (name: string) => void) (action: 'clear:cookies', fn: () => void) - (action: 'cross:origin:cookies', fn: (cookies: AutomationCookie[]) => void) + (action: 'cross:origin:cookies', fn: (cookies: SerializableAutomationCookie[]) => void) (action: 'before:stability:release', fn: () => void) (action: 'paused', fn: (nextCommandName: string) => void) } diff --git a/packages/proxy/lib/http/index.ts b/packages/proxy/lib/http/index.ts index 87448976c8f6..f4c50493494e 100644 --- a/packages/proxy/lib/http/index.ts +++ b/packages/proxy/lib/http/index.ts @@ -20,9 +20,8 @@ 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/util/cookies' +import type { CookieJar, SerializableAutomationCookie } from '@packages/server/lib/util/cookies' import type { RequestedWithAndCredentialManager } from '@packages/server/lib/util/requestedWithAndCredentialManager' -import type { AutomationCookie } from '@packages/server/lib/automation/cookies' import { errorUtils } from '@packages/errors' function getRandomColorFn () { @@ -59,7 +58,7 @@ type HttpMiddlewareCtx = { getPreRequest: (cb: GetPreRequestCb) => void getAUTUrl: Http['getAUTUrl'] setAUTUrl: Http['setAUTUrl'] - simulatedCookies: AutomationCookie[] + simulatedCookies: SerializableAutomationCookie[] } & T export const defaultMiddleware = { diff --git a/packages/proxy/lib/http/util/cookies.ts b/packages/proxy/lib/http/util/cookies.ts index a320a7b570ce..c51bb5ce41d3 100644 --- a/packages/proxy/lib/http/util/cookies.ts +++ b/packages/proxy/lib/http/util/cookies.ts @@ -3,7 +3,7 @@ import type Debug from 'debug' import { URL } from 'url' import { cors } from '@packages/network' import { urlOriginsMatch, urlSameSiteMatch } from '@packages/network/lib/cors' -import { AutomationCookie, Cookie, CookieJar, toughCookieToAutomationCookie } from '@packages/server/lib/util/cookies' +import { SerializableAutomationCookie, Cookie, CookieJar, toughCookieToAutomationCookie } from '@packages/server/lib/util/cookies' import type { RequestCredentialLevel, RequestedWithHeader } from '../../types' type SiteContext = 'same-origin' | 'same-site' | 'cross-site' @@ -197,7 +197,7 @@ export class CookiesHelper { const afterCookies = this.cookieJar.getAllCookies() - return afterCookies.reduce((memo, afterCookie) => { + return afterCookies.reduce((memo, afterCookie) => { if (matchesPreviousCookie(this.previousCookies, afterCookie)) return memo return memo.concat(toughCookieToAutomationCookie(afterCookie, this.defaultDomain)) diff --git a/packages/proxy/lib/http/util/inject.ts b/packages/proxy/lib/http/util/inject.ts index 830143173c6f..936cf34eb379 100644 --- a/packages/proxy/lib/http/util/inject.ts +++ b/packages/proxy/lib/http/util/inject.ts @@ -1,6 +1,6 @@ import { oneLine } from 'common-tags' import { getRunnerInjectionContents, getRunnerCrossOriginInjectionContents } from '@packages/resolve-dist' -import type { AutomationCookie } from '@packages/server/lib/automation/cookies' +import type { SerializableAutomationCookie } from '@packages/server/lib/util/cookies' interface InjectionOpts { shouldInjectDocumentDomain: boolean @@ -8,7 +8,7 @@ interface InjectionOpts { interface FullCrossOriginOpts { modifyObstructiveThirdPartyCode: boolean modifyObstructiveCode: boolean - simulatedCookies: AutomationCookie[] + simulatedCookies: SerializableAutomationCookie[] } export function partial (domain, options: InjectionOpts) { diff --git a/packages/proxy/lib/http/util/rewriter.ts b/packages/proxy/lib/http/util/rewriter.ts index df6ab269b562..26067bce9e10 100644 --- a/packages/proxy/lib/http/util/rewriter.ts +++ b/packages/proxy/lib/http/util/rewriter.ts @@ -2,7 +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' +import type { SerializableAutomationCookie } from '@packages/server/lib/util/cookies' export type SecurityOpts = { isNotJavascript?: boolean @@ -17,7 +17,7 @@ export type InjectionOpts = { domainName: string wantsInjection: CypressWantsInjection wantsSecurityRemoved: any - simulatedCookies: AutomationCookie[] + simulatedCookies: SerializableAutomationCookie[] shouldInjectDocumentDomain: boolean } diff --git a/packages/runner/injection/patches/cookies.ts b/packages/runner/injection/patches/cookies.ts index 586191311ded..d4efd50260f7 100644 --- a/packages/runner/injection/patches/cookies.ts +++ b/packages/runner/injection/patches/cookies.ts @@ -2,15 +2,15 @@ import { CookieJar, toughCookieToAutomationCookie, automationCookieToToughCookie, + SerializableAutomationCookie, } from '@packages/server/lib/util/cookies' import { Cookie as ToughCookie } from 'tough-cookie' -import type { AutomationCookie } from '@packages/server/lib/automation/cookies' function isHostOnlyCookie (domain) { return domain[0] !== '.' } -const parseDocumentCookieString = (documentCookieString: string): AutomationCookie[] => { +const parseDocumentCookieString = (documentCookieString: string): SerializableAutomationCookie[] => { if (!documentCookieString || !documentCookieString.trim().length) return [] return documentCookieString.split(';').map((cookieString) => { @@ -33,7 +33,7 @@ const parseDocumentCookieString = (documentCookieString: string): AutomationCook }) } -const sendCookieToServer = (cookie: AutomationCookie) => { +const sendCookieToServer = (cookie: SerializableAutomationCookie) => { window.top!.postMessage({ event: 'cross:origin:aut:set:cookie', data: { @@ -52,7 +52,7 @@ const sendCookieToServer = (cookie: AutomationCookie) => { // 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[]) => { +export const patchDocumentCookie = (requestCookies: SerializableAutomationCookie[]) => { const url = location.href const domain = location.hostname const cookieJar = new CookieJar() @@ -64,7 +64,7 @@ export const patchDocumentCookie = (requestCookies: AutomationCookie[]) => { }).join('; ') } - const addCookies = (cookies: AutomationCookie[]) => { + const addCookies = (cookies: SerializableAutomationCookie[]) => { cookies.forEach((cookie) => { cookieJar.setCookie(automationCookieToToughCookie(cookie, domain), url, undefined) }) @@ -154,7 +154,7 @@ export const patchDocumentCookie = (requestCookies: AutomationCookie[]) => { // the following listeners are called from Cypress cookie commands, so that // the document.cookie value is updated optimistically - Cypress.on('set:cookie', (cookie: AutomationCookie) => { + Cypress.on('set:cookie', (cookie: SerializableAutomationCookie) => { setCookie(automationCookieToToughCookie(cookie, domain)) }) diff --git a/packages/server/lib/automation/automation.ts b/packages/server/lib/automation/automation.ts index 7651a2b9965b..4033fe93ad20 100644 --- a/packages/server/lib/automation/automation.ts +++ b/packages/server/lib/automation/automation.ts @@ -38,8 +38,8 @@ export class Automation { this.middleware = this.initializeMiddleware() } - automationValve (message, fn) { - return (msg, data) => { + automationValve (message: string, fn: (...args: any) => any) { + return (msg: string, data: any) => { // enable us to omit message // argument if (!data) { @@ -60,7 +60,7 @@ export class Automation { } } - requestAutomationResponse (message, data, fn) { + requestAutomationResponse (message: string, data: any, fn: (...args: any) => any) { return new Bluebird((resolve, reject) => { const id = uuidv4() @@ -97,7 +97,7 @@ export class Automation { }) } - normalize (message, data, automate?) { + normalize (message: string, data: any, automate?) { return Bluebird.try(() => { switch (message) { case 'take:screenshot': diff --git a/packages/server/lib/automation/cookies.ts b/packages/server/lib/automation/cookies.ts index a1211d89d6cb..be469a843954 100644 --- a/packages/server/lib/automation/cookies.ts +++ b/packages/server/lib/automation/cookies.ts @@ -2,13 +2,18 @@ import _ from 'lodash' import Debug from 'debug' import extension from '@packages/extension' import { isHostOnlyCookie } from '../browsers/cdp_automation' +import type { SerializableAutomationCookie } from '../util/cookies' + +type AutomationFn = (data: V) => Bluebird.Promise + +type AutomationMessageFn = (message: string, data: V) => Bluebird.Promise export interface AutomationCookie { domain: string - expiry: 'Infinity' | '-Infinity' | number | null + expirationDate?: number + expiry: number | null httpOnly: boolean - hostOnly: boolean - maxAge: 'Infinity' | '-Infinity' | number | null + hostOnly?: boolean name: string path: string | null sameSite: string @@ -19,49 +24,44 @@ export interface AutomationCookie { // match the w3c webdriver spec on return cookies // https://w3c.github.io/webdriver/webdriver-spec.html#cookies -const COOKIE_PROPERTIES = 'name value path domain secure httpOnly expiry hostOnly sameSite'.split(' ') +const COOKIE_PROPERTIES = 'domain expiry httpOnly hostOnly name path sameSite secure value'.split(' ') const debug = Debug('cypress:server:automation:cookies') -const normalizeCookies = (cookies) => { - return _.map(cookies, normalizeCookieProps) +const normalizeCookies = (cookies: (SerializableAutomationCookie | AutomationCookie)[]): AutomationCookie[] => { + return _.map(cookies, normalizeCookieProps) as AutomationCookie[] } -const normalizeCookieProps = function (props) { - if (!props) { - return props +const normalizeCookieProps = function (automationCookie: SerializableAutomationCookie | AutomationCookie | null) { + if (!automationCookie) { + return automationCookie } - // if the cookie is stored inside the server side cookie jar, - // we want to make the automation client aware so the domain property - // isn't mutated to prevent duplicate setting of cookies from different contexts. - // This should be handled by the hostOnly property - - const cookie = _.pick(props, COOKIE_PROPERTIES) + const cookie = _.pick(automationCookie, COOKIE_PROPERTIES) - if (props.expiry === '-Infinity') { + if (automationCookie.expiry === '-Infinity') { cookie.expiry = -Infinity // set the cookie to expired so when set, the cookie is removed cookie.expirationDate = 0 - } else if (props.expiry === 'Infinity') { + } else if (automationCookie.expiry === 'Infinity') { cookie.expiry = null - } else if (props.expiry != null) { + } else if (automationCookie.expiry != null) { // when sending cookie props we need to convert // expiry to expirationDate delete cookie.expiry - cookie.expirationDate = props.expiry - } else if (props.expirationDate != null) { + cookie.expirationDate = automationCookie.expiry + } else if (automationCookie.expirationDate != null) { // and when receiving cookie props we need to convert // expirationDate to expiry and always remove url delete cookie.expirationDate delete cookie.url - cookie.expiry = props.expirationDate + cookie.expiry = automationCookie.expirationDate } - return cookie + return cookie as AutomationCookie } -export const normalizeGetCookies = (cookies) => { +export const normalizeGetCookies = (cookies: (AutomationCookie | null)[]): (AutomationCookie | null)[] => { return _.chain(cookies) .map(normalizeGetCookieProps) // sort in order of expiration date, ascending @@ -69,7 +69,7 @@ export const normalizeGetCookies = (cookies) => { .value() } -export const normalizeGetCookieProps = (props) => { +export const normalizeGetCookieProps = (props: AutomationCookie | null) => { if (!props) { return props } @@ -91,7 +91,7 @@ export class Cookies { constructor (private cyNamespace, private cookieNamespace) {} - isNamespaced = (cookie) => { + isNamespaced = (cookie: AutomationCookie | null) => { const name = cookie && cookie.name // if the cookie has no name, return false @@ -108,13 +108,15 @@ export class Cookies { } } - getCookies (data, automate) { + getCookies (data: { + domain?: string + }, automate: AutomationMessageFn) { debug('getting:cookies %o', data) return automate('get:cookies', data) .then((cookies) => { cookies = normalizeGetCookies(cookies) - cookies = _.reject(cookies, (cookie) => this.isNamespaced(cookie)) + cookies = _.reject(cookies, (cookie) => this.isNamespaced(cookie)) as AutomationCookie[] debug('received get:cookies %o', cookies) @@ -122,7 +124,13 @@ export class Cookies { }) } - getCookie (data, automate) { + getCookie (data: { + domain: string + name: string + }, automate: AutomationFn<{ + domain: string + name: string + }, AutomationCookie | null>) { debug('getting:cookie %o', data) return automate(data) @@ -139,9 +147,9 @@ export class Cookies { }) } - setCookie (data, automate) { + setCookie (data: SerializableAutomationCookie, automate: AutomationFn) { this.throwIfNamespaced(data) - const cookie = normalizeCookieProps(data) + const cookie = normalizeCookieProps(data) as AutomationCookie // lets construct the url ourselves right now // unless we already have a URL @@ -160,13 +168,13 @@ export class Cookies { } setCookies ( - cookies: AutomationCookie[], - automate: (eventName: string, cookies: AutomationCookie[]) => Bluebird.Promise, + cookies: SerializableAutomationCookie[] | AutomationCookie[], + automate: AutomationMessageFn, eventName: 'set:cookies' | 'add:cookies' = 'set:cookies', ) { cookies = cookies.map((data) => { this.throwIfNamespaced(data) - const cookie = normalizeCookieProps(data) + const cookie = normalizeCookieProps(data) as AutomationCookie // lets construct the url ourselves right now // unless we already have a URL @@ -177,7 +185,7 @@ export class Cookies { debug(`${eventName} %o`, cookies) - return automate(eventName, cookies) + return automate(eventName, cookies as AutomationCookie[]) .return(cookies) } @@ -185,13 +193,19 @@ export class Cookies { // same as set:cookies in Firefox, but will only add cookies and not clear // them in Chrome, etc. addCookies ( - cookies: AutomationCookie[], - automate: (eventName: string, cookies: AutomationCookie[]) => Bluebird.Promise, + cookies: SerializableAutomationCookie[], + automate: AutomationMessageFn, ) { return this.setCookies(cookies, automate, 'add:cookies') } - clearCookie (data, automate) { + clearCookie (data: { + domain: string + name: string + }, automate: AutomationFn<{ + domain: string + name: string + }, AutomationCookie | null>) { this.throwIfNamespaced(data) debug('clear:cookie %o', data) @@ -205,7 +219,7 @@ export class Cookies { }) } - async clearCookies (data, automate) { + async clearCookies (data: AutomationCookie[], automate: AutomationMessageFn) { const cookiesToClear = data const cookies = _.reject(normalizeCookies(cookiesToClear), this.isNamespaced) @@ -216,8 +230,12 @@ export class Cookies { .mapSeries(normalizeCookieProps) } - changeCookie (data) { - const c = normalizeCookieProps(data.cookie) + changeCookie (data: { + cause: string + cookie: SerializableAutomationCookie + removed: boolean + }) { + const c = normalizeCookieProps(data.cookie) as AutomationCookie if (this.isNamespaced(c)) { return diff --git a/packages/server/lib/server-base.ts b/packages/server/lib/server-base.ts index 89d37c01174a..8676881d9527 100644 --- a/packages/server/lib/server-base.ts +++ b/packages/server/lib/server-base.ts @@ -32,8 +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 './util/cookies' -import type { AutomationCookie } from './automation/cookies' +import { cookieJar, SerializableAutomationCookie } from './util/cookies' import { requestedWithAndCredentialManager, RequestedWithAndCredentialManager } from './util/requestedWithAndCredentialManager' const debug = Debug('cypress:server:server-base') @@ -182,7 +181,7 @@ export abstract class ServerBase { } setupCrossOriginRequestHandling () { - this._eventBus.on('cross:origin:cookies', (cookies: AutomationCookie[]) => { + this._eventBus.on('cross:origin:cookies', (cookies: SerializableAutomationCookie[]) => { this.socket.localBus.once('cross:origin:cookies:received', () => { this._eventBus.emit('cross:origin:cookies:received') }) diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index 0759bf788e69..0d82385e3605 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -20,7 +20,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 { AutomationCookie, cookieJar, SameSiteContext, automationCookieToToughCookie } from './util/cookies' +import { cookieJar, SameSiteContext, automationCookieToToughCookie, SerializableAutomationCookie } from './util/cookies' import runEvents from './plugins/run_events' // eslint-disable-next-line no-duplicate-imports @@ -390,7 +390,7 @@ export class SocketBase { }) }) - const setCrossOriginCookie = ({ cookie, url, sameSiteContext }: { cookie: AutomationCookie, url: string, sameSiteContext: SameSiteContext }) => { + const setCrossOriginCookie = ({ cookie, url, sameSiteContext }: { cookie: SerializableAutomationCookie, url: string, sameSiteContext: SameSiteContext }) => { const domain = cors.getOrigin(url) cookieJar.setCookie(automationCookieToToughCookie(cookie, domain), url, sameSiteContext) diff --git a/packages/server/lib/util/cookies.ts b/packages/server/lib/util/cookies.ts index f0f46c17138a..8c31b1198af1 100644 --- a/packages/server/lib/util/cookies.ts +++ b/packages/server/lib/util/cookies.ts @@ -1,7 +1,12 @@ import { Cookie, CookieJar as ToughCookieJar } from 'tough-cookie' import type { AutomationCookie } from '../automation/cookies' -export { AutomationCookie, Cookie } +interface SerializableAutomationCookie extends Omit { + expiry: 'Infinity' | '-Infinity' | number | null + maxAge: 'Infinity' | '-Infinity' | number | null +} + +export { SerializableAutomationCookie, Cookie } interface CookieData { name: string @@ -11,12 +16,15 @@ interface CookieData { export type SameSiteContext = 'strict' | 'lax' | 'none' | undefined -export const toughCookieToAutomationCookie = (toughCookie: Cookie, defaultDomain: string): AutomationCookie => { +export const toughCookieToAutomationCookie = (toughCookie: Cookie, defaultDomain: string): SerializableAutomationCookie => { + // tough-cookie is smart enough to determine the expiryTime based on maxAge and expiry + // meaning the expiry property should be a catch all for determining expiry time const expiry = toughCookie.expiryTime() return { domain: toughCookie.domain || defaultDomain, - // if expiry is Infinity or -Infinity, this operation is a no-op + // cast Infinity/-Infinity to a string to make sure the data is serialized through the automation client. + // cookie normalization in the automation client will cast this back to Infinity/-Infinity expiry: (expiry === Infinity || expiry === -Infinity) ? expiry.toString() as '-Infinity' | 'Infinity' : expiry / 1000, httpOnly: toughCookie.httpOnly, // we want to make sure the hostOnly property is respected when syncing with CDP/extension to prevent duplicates @@ -30,16 +38,18 @@ export const toughCookieToAutomationCookie = (toughCookie: Cookie, defaultDomain } } -export const automationCookieToToughCookie = (automationCookie: AutomationCookie, defaultDomain: string): Cookie => { +export const automationCookieToToughCookie = (automationCookie: SerializableAutomationCookie, defaultDomain: string): Cookie => { let expiry: Date | undefined = undefined if (automationCookie.expiry != null) { if (isFinite(automationCookie.expiry as number)) { expiry = new Date(automationCookie.expiry as number * 1000) - } else if (automationCookie.expiry === '-Infinity') { + } else if (automationCookie.expiry === '-Infinity' || automationCookie.expiry === -Infinity) { // if negative Infinity, the cookie is Date(0), has expired and is slated to be removed expiry = new Date(0) } + // if Infinity is set on the automation client, the expiry doesn't get set, meaning the no-op + // accomplishes an Infinite expire time } return new Cookie({ From 55697ccc52866f11aad93fb177b82951254ed84b Mon Sep 17 00:00:00 2001 From: Bill Glesias Date: Thu, 16 Feb 2023 14:20:41 -0500 Subject: [PATCH 2/2] [run ci]