From 2ce9a4dd2df2eb0c37870a3232a72c99f10caa7c Mon Sep 17 00:00:00 2001 From: Bill Glesias Date: Wed, 7 Sep 2022 17:09:31 -0400 Subject: [PATCH] feat: add attaching cookies to response logic w/ tests --- .../proxy/lib/http/response-middleware.ts | 46 +- packages/proxy/lib/http/util/cookies.ts | 24 +- .../unit/http/response-middleware.spec.ts | 691 +++++++++++++++++- 3 files changed, 719 insertions(+), 42 deletions(-) diff --git a/packages/proxy/lib/http/response-middleware.ts b/packages/proxy/lib/http/response-middleware.ts index 3c7bf247a9a3..83754ccb6b4b 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, HttpMiddlewareThis } from '.' +import type { HttpMiddleware } from '.' import iconv from 'iconv-lite' import type { IncomingMessage, IncomingHttpHeaders } from 'http' import { InterceptResponse } from '@packages/net-stubbing' @@ -13,6 +13,7 @@ import * as rewriter from './util/rewriter' import zlib from 'zlib' import { URL } from 'url' import { CookiesHelper } from './util/cookies' +import { doesTopNeedToBeSimulated } from './util/top-simulation' interface ResponseMiddlewareProps { /** @@ -397,48 +398,33 @@ const MaybePreventCaching: ResponseMiddleware = function () { this.next() } -const checkIfNeedsCrossOriginHandling = (ctx: HttpMiddlewareThis) => { - const currentAUTUrl = ctx.getAUTUrl() - - // A cookie needs cross origin handling if the request itself is - // cross-origin or the origins between requests don't match, - // since the browser won't set them in that case and if it's - // secondary-origin -> primary-origin, we don't recognize the request as cross-origin - return ( - ctx.config.experimentalSessionAndOrigin - && ( - (currentAUTUrl && !cors.urlOriginsMatch(currentAUTUrl, ctx.req.proxiedUrl)) - || !ctx.remoteStates.isPrimaryOrigin(ctx.req.proxiedUrl) - ) - ) -} - -const CopyCookiesFromIncomingRes: ResponseMiddleware = async function () { +const MaybeCopyCookiesFromIncomingRes: ResponseMiddleware = async function () { const cookies: string | string[] | undefined = this.incomingRes.headers['set-cookie'] if (!cookies || !cookies.length) { return this.next() } - // Cross-origin Cookie Handling + // Simulated Top Cookie Handling // --------------------------- // - We capture cookies sent by responses and add them to our own server-side // tough-cookie cookie jar. All request cookies are captured, since any - // future request could be cross-origin even if the response that sets them + // future request could be cross-origin in the context of top, even if the response that sets them // is not. // - If we sent the cookie header, it may fail to be set by the browser // (in most cases). However, we cannot determine all the cases in which Set-Cookie - // will currently fail, and currently is best to set optimistically until #23551 is addressed. + // will currently fail. We try to address this in our tough cookie jar + // by only setting cookies that would otherwise work in the browser if the AUT url was top // - We also set the cookies through automation so they are available in the // browser via document.cookie and via Cypress cookie APIs - // (e.g. cy.getCookie). This is only done for cross-origin responses, since - // non-cross-origin responses will be successfully set in the browser - // automatically. + // (e.g. cy.getCookie). This is only done when the AUT url and top do not match responses, + // since AUT and Top being same origin will be successfully set in the browser + // automatically as expected. // - In the request middleware, we retrieve the cookies for a given URL // and attach them to the request, like the browser normally would. // tough-cookie handles retrieving the correct cookies based on domain, // path, etc. It also removes cookies from the cookie jar if they've expired. - const needsCrossOriginHandling = checkIfNeedsCrossOriginHandling(this) + const doesTopNeedSimulating = doesTopNeedToBeSimulated(this) const appendCookie = (cookie: string) => { // always call 'Set-Cookie' in the browser as cross origin or same site requests @@ -452,7 +438,7 @@ const CopyCookiesFromIncomingRes: ResponseMiddleware = async function () { } } - if (!this.config.experimentalSessionAndOrigin) { + if (!this.config.experimentalSessionAndOrigin || !doesTopNeedSimulating) { ([] as string[]).concat(cookies).forEach((cookie) => { appendCookie(cookie) }) @@ -467,7 +453,9 @@ const CopyCookiesFromIncomingRes: ResponseMiddleware = async function () { request: { url: this.req.proxiedUrl, isAUTFrame: this.req.isAUTFrame, - needsCrossOriginHandling, + doesTopNeedSimulating, + resourceType: this.req.requestedWith, + credentialLevel: this.req.credentialsLevel, }, }) @@ -481,7 +469,7 @@ const CopyCookiesFromIncomingRes: ResponseMiddleware = async function () { const addedCookies = await cookiesHelper.getAddedCookies() - if (!needsCrossOriginHandling || !addedCookies.length) { + if (!addedCookies.length) { return this.next() } @@ -625,7 +613,7 @@ export default { OmitProblematicHeaders, MaybePreventCaching, MaybeStripDocumentDomainFeaturePolicy, - CopyCookiesFromIncomingRes, + MaybeCopyCookiesFromIncomingRes, MaybeSendRedirectToClient, CopyResponseStatusCode, ClearCyInitialCookie, diff --git a/packages/proxy/lib/http/util/cookies.ts b/packages/proxy/lib/http/util/cookies.ts index 9e5ca3fca07b..83e29b0b307e 100644 --- a/packages/proxy/lib/http/util/cookies.ts +++ b/packages/proxy/lib/http/util/cookies.ts @@ -9,7 +9,7 @@ import { calculateSiteContext } from './top-simulation' interface RequestDetails { url: string isAUTFrame: boolean - needsCrossOriginHandling: boolean + doesTopNeedSimulating: boolean resourceType?: RequestResourceType credentialLevel?: RequestCredentialLevel } @@ -125,6 +125,7 @@ export class CookiesHelper { debug: Debug.Debugger defaultDomain: string sameSiteContext: 'strict' | 'lax' | 'none' + siteContext: 'same-origin' | 'same-site' | 'cross-site' | 'none' previousCookies: Cookie[] = [] constructor ({ cookieJar, currentAUTUrl, request, debug }) { @@ -133,6 +134,7 @@ export class CookiesHelper { this.request = request this.debug = debug this.sameSiteContext = getSameSiteContext(currentAUTUrl, request.url, request.isAUTFrame) + this.siteContext = calculateSiteContext(this.request.url, this.currentAUTUrl || '') const parsedRequestUrl = new URL(request.url) @@ -143,7 +145,7 @@ export class CookiesHelper { // this plays a part in adding cross-origin cookies to the browser via // automation. if the request doesn't need cross-origin handling, this // is a noop - if (!this.request.needsCrossOriginHandling) return + if (!this.request.doesTopNeedSimulating) return this.previousCookies = this.cookieJar.getAllCookies() } @@ -152,7 +154,7 @@ export class CookiesHelper { // this plays a part in adding cross-origin cookies to the browser via // automation. if the request doesn't need cross-origin handling, this // is a noop - if (!this.request.needsCrossOriginHandling) return [] + if (!this.request.doesTopNeedSimulating) return [] const afterCookies = this.cookieJar.getAllCookies() @@ -171,10 +173,26 @@ export class CookiesHelper { // because Secure is required for SameSite=None. not all browsers currently // currently enforce this, but they're heading in that direction since // it's now the standard, so this is more future-proof + // TODO: in the future we may want to check for https, which might be tricky since localhost is considered a secure context if (!toughCookie || (toughCookie.sameSite === 'none' && !toughCookie.secure)) { return } + // don't set the cookie in our own cookie jar if the cookie would otherwise fail being set in the browser if the AUT Url + // was actually top. This prevents cookies from being applied to our cookie jar when they shouldn't, preventing possible security implications. + if (!shouldAttachAndSetCookies(this.request.url, this.currentAUTUrl, this.request.resourceType, this.request.credentialLevel)) { + this.debug(`not setting cookie for ${this.request.url} with simulated top ${ this.currentAUTUrl} for ${ this.request.resourceType}:${this.request.credentialLevel}, cookie: ${toughCookie}`) + + return + } + + // cross site cookies cannot set lax/strict cookies in the browser for xhr/fetch requests (but ok with navigation/document requests) + if (this.request.resourceType && this.siteContext === 'cross-site' && toughCookie.sameSite !== 'none') { + this.debug(`cannot set cookie with SameSite=${toughCookie.sameSite} when site context is ${this.siteContext}`) + + return + } + try { this.cookieJar.setCookie(toughCookie, this.request.url, this.sameSiteContext) } catch (err) { diff --git a/packages/proxy/test/unit/http/response-middleware.spec.ts b/packages/proxy/test/unit/http/response-middleware.spec.ts index 39568b879c26..80b802e4cba9 100644 --- a/packages/proxy/test/unit/http/response-middleware.spec.ts +++ b/packages/proxy/test/unit/http/response-middleware.spec.ts @@ -21,7 +21,7 @@ describe('http/response-middleware', function () { 'OmitProblematicHeaders', 'MaybePreventCaching', 'MaybeStripDocumentDomainFeaturePolicy', - 'CopyCookiesFromIncomingRes', + 'MaybeCopyCookiesFromIncomingRes', 'MaybeSendRedirectToClient', 'CopyResponseStatusCode', 'ClearCyInitialCookie', @@ -770,8 +770,8 @@ describe('http/response-middleware', function () { } }) - describe('CopyCookiesFromIncomingRes', function () { - const { CopyCookiesFromIncomingRes } = ResponseMiddleware + describe('MaybeCopyCookiesFromIncomingRes', function () { + const { MaybeCopyCookiesFromIncomingRes } = ResponseMiddleware it('appends cookies on the response when an array', async function () { const { appendStub, ctx } = prepareSameOriginContext({ @@ -782,7 +782,7 @@ describe('http/response-middleware', function () { }, }) - await testMiddleware([CopyCookiesFromIncomingRes], ctx) + await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) expect(appendStub).to.be.calledTwice expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie1=value1') @@ -792,7 +792,7 @@ describe('http/response-middleware', function () { it('appends cookies on the response when a string', async function () { const { appendStub, ctx } = prepareSameOriginContext() - await testMiddleware([CopyCookiesFromIncomingRes], ctx) + await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) expect(appendStub).to.be.calledOnce expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie=value') @@ -806,15 +806,686 @@ describe('http/response-middleware', function () { }, }) - await testMiddleware([CopyCookiesFromIncomingRes], ctx) + await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) expect(appendStub).not.to.be.called }) - it('does not send cross:origin:automation:cookies if request does not need cross-origin handling', async () => { + it('is a noop in the cookie jar when top does NOT need simulating', async function () { + const appendStub = sinon.stub() + + const cookieJar = { + getAllCookies: () => [{ key: 'cookie', value: 'value' }], + setCookie: sinon.stub(), + } + + const ctx = prepareContext({ + cookieJar, + res: { + append: appendStub, + }, + incomingRes: { + headers: { + 'set-cookie': 'cookie=value', + }, + }, + }) + + ctx.getAUTUrl = () => 'http://www.foobar.com/index.html' + // set the primaryOrigin to true to signal we do NOT need to simulate top + ctx.remoteStates.isPrimaryOrigin = () => true + + await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) + + expect(cookieJar.setCookie).not.to.have.been.called + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie=value') + }) + + it('is a noop in the cookie jar when experimentalSessionAndOrigin is false', async function () { + const appendStub = sinon.stub() + + const cookieJar = { + getAllCookies: () => [{ key: 'cookie', value: 'value' }], + setCookie: sinon.stub(), + } + + const ctx = prepareContext({ + cookieJar, + res: { + append: appendStub, + }, + incomingRes: { + headers: { + 'set-cookie': 'cookie=value', + }, + }, + }) + + ctx.config.experimentalSessionAndOrigin = false + + // a case where top would need to be simulated, but the experimental flag is off + ctx.getAUTUrl = () => 'http://www.foobar.com/index.html' + ctx.remoteStates.isPrimaryOrigin = () => false + + await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) + + expect(cookieJar.setCookie).not.to.have.been.called + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie=value') + }) + + 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 ctx = prepareContext({ + cookieJar, + res: { + append: appendStub, + }, + req: { + // a same-site request that has the ability to set first-party cookies in the browser + requestedWith: 'fetch', + credentialsLevel: credentialLevel, + proxiedUrl: 'https://www.foobar.com/test-request', + }, + incomingRes: { + headers: { + 'set-cookie': ['cookie1=value1; SameSite=Strict', 'cookie2=value2; SameSite=Lax', 'cookie3=value3; SameSite=None; Secure'], + }, + }, + }) + + // a case where top would need to be simulated + ctx.getAUTUrl = () => 'https://www.foobar.com/index.html' + ctx.remoteStates.isPrimaryOrigin = () => false + + await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) + + // should work as this would be set in the browser if the AUT url was top + expect(cookieJar.setCookie).to.have.been.calledWith(sinon.match({ + key: 'cookie1', + value: 'value1', + sameSite: 'strict', + }), 'https://www.foobar.com/test-request', 'strict') + + // should work as this would be set in the browser if the AUT url was top + expect(cookieJar.setCookie).to.have.been.calledWith(sinon.match({ + key: 'cookie2', + value: 'value2', + sameSite: 'lax', + }), 'https://www.foobar.com/test-request', 'strict') + + // should work as this would be set in the browser if the AUT url was top, just sets a third party cookie + expect(cookieJar.setCookie).to.have.been.calledWith(sinon.match({ + key: 'cookie3', + value: 'value3', + sameSite: 'none', + }), 'https://www.foobar.com/test-request', 'strict') + + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie1=value1; SameSite=Strict') + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie2=value2; SameSite=Lax') + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie3=value3; SameSite=None; Secure') + }) + }) + + ;[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 ctx = prepareContext({ + cookieJar, + res: { + append: appendStub, + }, + req: { + // a same-site request that has the ability to set first-party cookies in the browser + requestedWith: 'xhr', + credentialsLevel: credentialLevel, + proxiedUrl: 'https://www.foobar.com/test-request', + }, + incomingRes: { + headers: { + 'set-cookie': ['cookie1=value1; SameSite=Strict', 'cookie2=value2; SameSite=Lax', 'cookie3=value3; SameSite=None; Secure'], + }, + }, + }) + + // a case where top would need to be simulated + ctx.getAUTUrl = () => 'https://www.foobar.com/index.html' + ctx.remoteStates.isPrimaryOrigin = () => false + + await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) + + // should work as this would be set in the browser if the AUT url was top + expect(cookieJar.setCookie).to.have.been.calledWith(sinon.match({ + key: 'cookie1', + value: 'value1', + sameSite: 'strict', + }), 'https://www.foobar.com/test-request', 'strict') + + // should work as this would be set in the browser if the AUT url was top + expect(cookieJar.setCookie).to.have.been.calledWith(sinon.match({ + key: 'cookie2', + value: 'value2', + sameSite: 'lax', + }), 'https://www.foobar.com/test-request', 'strict') + + // should work as this would be set in the browser if the AUT url was top, just sets a third party cookie + expect(cookieJar.setCookie).to.have.been.calledWith(sinon.match({ + key: 'cookie3', + value: 'value3', + sameSite: 'none', + }), 'https://www.foobar.com/test-request', 'strict') + + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie1=value1; SameSite=Strict') + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie2=value2; SameSite=Lax') + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie3=value3; SameSite=None; Secure') + }) + }) + + 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 ctx = prepareContext({ + cookieJar, + res: { + append: appendStub, + }, + req: { + // a same-site request that has the ability to set first-party cookies in the browser + requestedWith: 'fetch', + credentialsLevel: 'omit', + proxiedUrl: 'https://www.foobar.com/test-request', + }, + incomingRes: { + headers: { + 'set-cookie': ['cookie1=value1; SameSite=Strict', 'cookie2=value2; SameSite=Lax', 'cookie3=value3; SameSite=None; Secure'], + }, + }, + }) + + // a case where top would need to be simulated + ctx.getAUTUrl = () => 'https://www.foobar.com/index.html' + ctx.remoteStates.isPrimaryOrigin = () => false + + await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) + + // should not work as this wouldn't be set in the browser if the AUT url was top + expect(cookieJar.setCookie).not.to.have.been.calledWith(sinon.match({ + key: 'cookie1', + value: 'value1', + sameSite: 'strict', + }), 'https://www.foobar.com/test-request', 'strict') + + // should not work as this wouldn't be set in the browser if the AUT url was top + expect(cookieJar.setCookie).not.to.have.been.calledWith(sinon.match({ + key: 'cookie2', + value: 'value2', + sameSite: 'lax', + }), 'https://www.foobar.com/test-request', 'strict') + + // should not work as this wouldn't be set in the browser if the AUT url was top + expect(cookieJar.setCookie).not.to.have.been.calledWith(sinon.match({ + key: 'cookie3', + value: 'value3', + sameSite: 'none', + }), 'https://www.foobar.com/test-request', 'strict') + + // return these to the browser, even though they are likely to fail setting anyway + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie1=value1; SameSite=Strict') + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie2=value2; SameSite=Lax') + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie3=value3; SameSite=None; Secure') + }) + }) + + 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 ctx = prepareContext({ + cookieJar, + res: { + append: appendStub, + }, + req: { + // a same-site request that has the ability to set first-party cookies in the browser + requestedWith: 'fetch', + credentialsLevel: 'include', + proxiedUrl: 'https://app.foobar.com/test-request', + }, + incomingRes: { + headers: { + 'set-cookie': ['cookie1=value1; SameSite=Strict', 'cookie2=value2; SameSite=Lax', 'cookie3=value3; SameSite=None; Secure'], + }, + }, + }) + + // a case where top would need to be simulated + ctx.getAUTUrl = () => 'https://www.foobar.com/index.html' + ctx.remoteStates.isPrimaryOrigin = () => false + + await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) + + // should work as this would be set in the browser if the AUT url was top + expect(cookieJar.setCookie).to.have.been.calledWith(sinon.match({ + key: 'cookie1', + value: 'value1', + sameSite: 'strict', + }), 'https://app.foobar.com/test-request', 'strict') + + // should work as this would be set in the browser if the AUT url was top + expect(cookieJar.setCookie).to.have.been.calledWith(sinon.match({ + key: 'cookie2', + value: 'value2', + sameSite: 'lax', + }), 'https://app.foobar.com/test-request', 'strict') + + // should work as this would be set in the browser if the AUT url was top, just sets a third party cookie + expect(cookieJar.setCookie).to.have.been.calledWith(sinon.match({ + key: 'cookie3', + value: 'value3', + sameSite: 'none', + }), 'https://app.foobar.com/test-request', 'strict') + + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie1=value1; SameSite=Strict') + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie2=value2; SameSite=Lax') + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie3=value3; SameSite=None; Secure') + }) + + 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 ctx = prepareContext({ + cookieJar, + res: { + append: appendStub, + }, + req: { + // a same-site request that has the ability to set first-party cookies in the browser + requestedWith: 'xhr', + credentialsLevel: true, + proxiedUrl: 'https://app.foobar.com/test-request', + }, + incomingRes: { + headers: { + 'set-cookie': ['cookie1=value1; SameSite=Strict', 'cookie2=value2; SameSite=Lax', 'cookie3=value3; SameSite=None; Secure'], + }, + }, + }) + + // a case where top would need to be simulated + ctx.getAUTUrl = () => 'https://www.foobar.com/index.html' + ctx.remoteStates.isPrimaryOrigin = () => false + + await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) + + // should work as this would be set in the browser if the AUT url was top + expect(cookieJar.setCookie).to.have.been.calledWith(sinon.match({ + key: 'cookie1', + value: 'value1', + sameSite: 'strict', + }), 'https://app.foobar.com/test-request', 'strict') + + // should work as this would be set in the browser if the AUT url was top + expect(cookieJar.setCookie).to.have.been.calledWith(sinon.match({ + key: 'cookie2', + value: 'value2', + sameSite: 'lax', + }), 'https://app.foobar.com/test-request', 'strict') + + // should work as this would be set in the browser if the AUT url was top, just sets a third party cookie + expect(cookieJar.setCookie).to.have.been.calledWith(sinon.match({ + key: 'cookie3', + value: 'value3', + sameSite: 'none', + }), 'https://app.foobar.com/test-request', 'strict') + + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie1=value1; SameSite=Strict') + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie2=value2; SameSite=Lax') + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie3=value3; SameSite=None; Secure') + }) + + ;['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 ctx = prepareContext({ + cookieJar, + res: { + append: appendStub, + }, + req: { + // a same-site request that has the ability to set first-party cookies in the browser + requestedWith: 'fetch', + credentialsLevel: credentialLevel, + proxiedUrl: 'https://app.foobar.com/test-request', + }, + incomingRes: { + headers: { + 'set-cookie': ['cookie1=value1; SameSite=Strict', 'cookie2=value2; SameSite=Lax', 'cookie3=value3; SameSite=None; Secure'], + }, + }, + }) + + // a case where top would need to be simulated + ctx.getAUTUrl = () => 'https://www.foobar.com/index.html' + ctx.remoteStates.isPrimaryOrigin = () => false + + await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) + + // should not work as this wouldn't be set in the browser if the AUT url was top + expect(cookieJar.setCookie).not.to.have.been.called + + // return these to the browser, even though they are likely to fail setting anyway + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie1=value1; SameSite=Strict') + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie2=value2; SameSite=Lax') + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie3=value3; SameSite=None; Secure') + }) + }) + }) + + 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 ctx = prepareContext({ + cookieJar, + res: { + append: appendStub, + }, + req: { + // a cross-site request that has the ability to set cookies in the browser + requestedWith: 'fetch', + credentialsLevel: 'include', + proxiedUrl: 'https://www.barbaz.com/test-request', + }, + incomingRes: { + headers: { + 'set-cookie': ['cookie1=value1; SameSite=Strict', 'cookie2=value2; SameSite=Lax', 'cookie3=value3; SameSite=None; Secure'], + }, + }, + }) + + // a case where top would need to be simulated + ctx.getAUTUrl = () => 'https://www.foobar.com/index.html' + ctx.remoteStates.isPrimaryOrigin = () => false + + await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) + + // should not work as this wouldn't be set in the browser if the AUT url was top anyway + expect(cookieJar.setCookie).not.to.have.been.calledWith(sinon.match({ + key: 'cookie1', + value: 'value1', + sameSite: 'strict', + }), 'https://www.barbaz.com/test-request', 'none') + + // should not work as this wouldn't be set in the browser if the AUT url was top anyway + expect(cookieJar.setCookie).not.to.have.been.calledWith(sinon.match({ + key: 'cookie2', + value: 'value2', + sameSite: 'lax', + }), 'https://www.barbaz.com/test-request', 'none') + + expect(cookieJar.setCookie).to.have.been.calledWith(sinon.match({ + key: 'cookie3', + value: 'value3', + sameSite: 'none', + }), 'https://www.barbaz.com/test-request', 'none') + + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie3=value3; SameSite=None; Secure') + }) + + ;['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 ctx = prepareContext({ + cookieJar, + res: { + append: appendStub, + }, + req: { + // a cross-site request that has the ability to set cookies in the browser + requestedWith: 'fetch', + credentialsLevel: credentialLevel, + proxiedUrl: 'https://www.barbaz.com/test-request', + }, + incomingRes: { + headers: { + 'set-cookie': ['cookie1=value1; SameSite=Strict', 'cookie2=value2; SameSite=Lax', 'cookie3=value3; SameSite=None; Secure'], + }, + }, + }) + + // a case where top would need to be simulated + ctx.getAUTUrl = () => 'https://www.foobar.com/index.html' + ctx.remoteStates.isPrimaryOrigin = () => false + + await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) + + expect(cookieJar.setCookie).not.to.have.been.called + + // send to browser anyway even though these will likely fail to be set + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie3=value3; SameSite=None; Secure') + }) + }) + + 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 ctx = prepareContext({ + cookieJar, + res: { + append: appendStub, + }, + req: { + // a cross-site request that has the ability to set cookies in the browser + requestedWith: 'xhr', + credentialsLevel: true, + proxiedUrl: 'https://www.barbaz.com/test-request', + }, + incomingRes: { + headers: { + 'set-cookie': ['cookie1=value1; SameSite=Strict', 'cookie2=value2; SameSite=Lax', 'cookie3=value3; SameSite=None; Secure'], + }, + }, + }) + + // a case where top would need to be simulated + ctx.getAUTUrl = () => 'https://www.foobar.com/index.html' + ctx.remoteStates.isPrimaryOrigin = () => false + + await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) + + // should not work as this wouldn't be set in the browser if the AUT url was top anyway + expect(cookieJar.setCookie).not.to.have.been.calledWith(sinon.match({ + key: 'cookie1', + value: 'value1', + sameSite: 'strict', + }), 'https://www.barbaz.com/test-request', 'none') + + // should not work as this wouldn't be set in the browser if the AUT url was top anyway + expect(cookieJar.setCookie).not.to.have.been.calledWith(sinon.match({ + key: 'cookie2', + value: 'value2', + sameSite: 'lax', + }), 'https://www.barbaz.com/test-request', 'none') + + expect(cookieJar.setCookie).to.have.been.calledWith(sinon.match({ + key: 'cookie3', + value: 'value3', + sameSite: 'none', + }), 'https://www.barbaz.com/test-request', 'none') + + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie3=value3; SameSite=None; Secure') + }) + + 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 ctx = prepareContext({ + cookieJar, + res: { + append: appendStub, + }, + req: { + // a cross-site request that has the ability to set cookies in the browser + requestedWith: 'xhr', + credentialsLevel: false, + proxiedUrl: 'https://www.barbaz.com/test-request', + }, + incomingRes: { + headers: { + 'set-cookie': ['cookie1=value1; SameSite=Strict', 'cookie2=value2; SameSite=Lax', 'cookie3=value3; SameSite=None; Secure'], + }, + }, + }) + + // a case where top would need to be simulated + ctx.getAUTUrl = () => 'http://www.foobar.com/index.html' + ctx.remoteStates.isPrimaryOrigin = () => false + + await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) + + expect(cookieJar.setCookie).not.to.have.been.called + + // send to the browser, even though the browser will NOT set this cookie + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie3=value3; SameSite=None; Secure') + }) + }) + + 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 ctx = prepareContext({ + cookieJar, + res: { + append: appendStub, + }, + req: { + // a cross-site request that has the ability to set cookies in the browser + requestedWith: 'xhr', + credentialsLevel: true, + proxiedUrl: 'https://www.barbaz.com/test-request', + }, + incomingRes: { + headers: { + 'set-cookie': ['cookie3=value3; SameSite=None'], + }, + }, + }) + + // a case where top would need to be simulated + ctx.getAUTUrl = () => 'https://www.foobar.com/index.html' + ctx.remoteStates.isPrimaryOrigin = () => false + + await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) + + expect(cookieJar.setCookie).not.to.have.been.called + + // send to browser anyway even though these will likely fail to be set + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie3=value3; SameSite=None') + }) + + it(`allows setting cookies if request type cannot be determined (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 ctx = prepareContext({ + cookieJar, + res: { + append: appendStub, + }, + req: { + proxiedUrl: 'https://www.barbaz.com/index.html', + }, + incomingRes: { + headers: { + 'set-cookie': ['cookie=value'], + }, + }, + }) + + // a case where top would need to be simulated + ctx.getAUTUrl = () => 'https://www.foobar.com/index.html' + ctx.remoteStates.isPrimaryOrigin = () => false + + await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) + + expect(cookieJar.setCookie).to.have.been.calledWith(sinon.match({ + key: 'cookie', + value: 'value', + sameSite: 'lax', + }), 'https://www.barbaz.com/index.html', 'none') + + // send to browser anyway even though these will likely fail to be set + 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 () => { const { ctx } = prepareSameOriginContext() - await testMiddleware([CopyCookiesFromIncomingRes], ctx) + await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) expect(ctx.serverBus.emit).not.to.be.called }) @@ -833,7 +1504,7 @@ describe('http/response-middleware', function () { }, }) - await testMiddleware([CopyCookiesFromIncomingRes], ctx) + await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) expect(ctx.serverBus.emit).not.to.be.called }) @@ -859,7 +1530,7 @@ describe('http/response-middleware', function () { // that we move on once receiving this event ctx.serverBus.once.withArgs('cross:origin:automation:cookies:received').yields() - await testMiddleware([CopyCookiesFromIncomingRes], ctx) + await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) expect(ctx.serverBus.emit).to.be.calledWith('cross:origin:automation:cookies')