diff --git a/packages/app/src/runner/aut-iframe.ts b/packages/app/src/runner/aut-iframe.ts index ae88b522f5d0..3b3a319eeff5 100644 --- a/packages/app/src/runner/aut-iframe.ts +++ b/packages/app/src/runner/aut-iframe.ts @@ -97,7 +97,7 @@ export class AutIframe { * Otherwise, if top and the AUT match origins, the method returns true. * If the AUT origin is "about://blank", that means the src attribute has been stripped off the iframe and is adhering to same origin policy */ - doesAUTMatchTopOriginPolicy = () => { + doesAUTMatchTopSuperDomainOrigin = () => { const Cypress = this.eventManager.getCypress() if (!Cypress) return true @@ -107,7 +107,7 @@ export class AutIframe { const locationTop = Cypress.Location.create(window.location.href) const locationAUT = Cypress.Location.create(currentHref) - return locationTop.originPolicy === locationAUT.originPolicy || locationAUT.originPolicy === 'about://blank' + return locationTop.superDomainOrigin === locationAUT.superDomainOrigin || locationAUT.superDomainOrigin === 'about://blank' } catch (err) { if (err.name === 'SecurityError') { return false @@ -151,7 +151,7 @@ export class AutIframe { } restoreDom = (snapshot) => { - if (!this.doesAUTMatchTopOriginPolicy()) { + if (!this.doesAUTMatchTopSuperDomainOrigin()) { /** * A load event fires here when the src is removed (as does an unload event). * This is equivalent to loading about:blank (see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-src). @@ -162,7 +162,8 @@ export class AutIframe { this.restoreDom(snapshot) }) - // The iframe is in a cross origin state. Remove the src attribute to adhere to same origin policy. NOTE: This should only be done ONCE. + // The iframe is in a cross origin state. + // Remove the src attribute to adhere to same super domain origin policy so we can interact with the frame. NOTE: This should only be done ONCE. this.removeSrcAttribute() return diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index a07e8dab98f3..90b8bff4a430 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -611,8 +611,8 @@ export class EventManager { }) // Reflect back to the requesting origin the status of the 'duringUserTestExecution' state - Cypress.primaryOriginCommunicator.on('sync:during:user:test:execution', ({ specBridgeResponseEvent }, originPolicy) => { - Cypress.primaryOriginCommunicator.toSpecBridge(originPolicy, specBridgeResponseEvent, cy.state('duringUserTestExecution')) + Cypress.primaryOriginCommunicator.on('sync:during:user:test:execution', ({ specBridgeResponseEvent }, superDomainOrigin) => { + Cypress.primaryOriginCommunicator.toSpecBridge(superDomainOrigin, specBridgeResponseEvent, cy.state('duringUserTestExecution')) }) Cypress.on('request:snapshot:from:spec:bridge', ({ log, name, options, specBridge, addSnapshot }: { @@ -653,22 +653,22 @@ export class EventManager { Cypress.primaryOriginCommunicator.toAllSpecBridges('before:unload', origin) }) - Cypress.primaryOriginCommunicator.on('expect:origin', (originPolicy) => { - this.localBus.emit('expect:origin', originPolicy) + Cypress.primaryOriginCommunicator.on('expect:origin', (superDomainOrigin) => { + this.localBus.emit('expect:origin', superDomainOrigin) }) - Cypress.primaryOriginCommunicator.on('viewport:changed', (viewport, originPolicy) => { + Cypress.primaryOriginCommunicator.on('viewport:changed', (viewport, superDomainOrigin) => { const callback = () => { - Cypress.primaryOriginCommunicator.toSpecBridge(originPolicy, 'viewport:changed:end') + Cypress.primaryOriginCommunicator.toSpecBridge(superDomainOrigin, 'viewport:changed:end') } Cypress.primaryOriginCommunicator.emit('sync:viewport', viewport) this.localBus.emit('viewport:changed', viewport, callback) }) - Cypress.primaryOriginCommunicator.on('before:screenshot', (config, originPolicy) => { + Cypress.primaryOriginCommunicator.on('before:screenshot', (config, superDomainOrigin) => { const callback = () => { - Cypress.primaryOriginCommunicator.toSpecBridge(originPolicy, 'before:screenshot:end') + Cypress.primaryOriginCommunicator.toSpecBridge(superDomainOrigin, 'before:screenshot:end') } handleBeforeScreenshot(config, callback) @@ -861,9 +861,9 @@ export class EventManager { this.ws.emit('spec:changed', specFile) } - notifyCrossOriginBridgeReady (originPolicy) { + notifyCrossOriginBridgeReady (superDomainOrigin) { // Any multi-origin event appends the origin as the third parameter and we do the same here for this short circuit - Cypress.primaryOriginCommunicator.emit('bridge:ready', undefined, originPolicy) + Cypress.primaryOriginCommunicator.emit('bridge:ready', undefined, superDomainOrigin) } snapshotUnpinned () { diff --git a/packages/app/src/runner/index.ts b/packages/app/src/runner/index.ts index fecc14344960..221de8f51195 100644 --- a/packages/app/src/runner/index.ts +++ b/packages/app/src/runner/index.ts @@ -98,7 +98,7 @@ function createIframeModel () { autIframe.detachDom, autIframe.restoreDom, autIframe.highlightEl, - autIframe.doesAUTMatchTopOriginPolicy, + autIframe.doesAUTMatchTopSuperDomainOrigin, getEventManager(), { selectorPlaygroundModel: getEventManager().selectorPlaygroundModel, @@ -194,11 +194,11 @@ export async function teardown () { * Add a cross origin iframe for cy.origin support */ export function addCrossOriginIframe (location) { - const id = `Spec Bridge: ${location.originPolicy}` + const id = `Spec Bridge: ${location.superDomainOrigin}` // if it already exists, don't add another one if (document.getElementById(id)) { - getEventManager().notifyCrossOriginBridgeReady(location.originPolicy) + getEventManager().notifyCrossOriginBridgeReady(location.superDomainOrigin) return } @@ -209,7 +209,7 @@ export function addCrossOriginIframe (location) { // container since it needs to match the size of the top window for screenshots $container: document.body, className: 'spec-bridge-iframe', - src: `${location.originPolicy}/${getRunnerConfigFromWindow().namespace}/spec-bridge-iframes`, + src: `${location.superDomainOrigin}/${getRunnerConfigFromWindow().namespace}/spec-bridge-iframes`, }) } diff --git a/packages/driver/cypress/e2e/commands/location.cy.js b/packages/driver/cypress/e2e/commands/location.cy.js index 9c92cbe6144f..a3885396240c 100644 --- a/packages/driver/cypress/e2e/commands/location.cy.js +++ b/packages/driver/cypress/e2e/commands/location.cy.js @@ -326,7 +326,7 @@ describe('src/cy/commands/location', () => { context('#location', () => { it('returns the location object', () => { cy.location().then((loc) => { - expect(loc).to.have.keys(['auth', 'authObj', 'hash', 'href', 'host', 'hostname', 'origin', 'pathname', 'port', 'protocol', 'search', 'originPolicy', 'superDomain', 'toString']) + expect(loc).to.have.keys(['auth', 'authObj', 'hash', 'href', 'host', 'hostname', 'pathname', 'port', 'protocol', 'search', 'origin', 'superDomainOrigin', 'superDomain', 'toString']) }) }) @@ -529,7 +529,7 @@ describe('src/cy/commands/location', () => { expect(_.keys(consoleProps)).to.deep.eq(['Command', 'Yielded']) expect(consoleProps.Command).to.eq('location') - expect(_.keys(consoleProps.Yielded)).to.deep.eq(['auth', 'authObj', 'hash', 'href', 'host', 'hostname', 'origin', 'pathname', 'port', 'protocol', 'search', 'originPolicy', 'superDomain', 'toString']) + expect(_.keys(consoleProps.Yielded)).to.deep.eq(['auth', 'authObj', 'hash', 'href', 'host', 'hostname', 'origin', 'pathname', 'port', 'protocol', 'search', 'superDomainOrigin', 'superDomain', 'toString']) }) }) }) diff --git a/packages/driver/cypress/e2e/commands/navigation.cy.js b/packages/driver/cypress/e2e/commands/navigation.cy.js index aeb12ad05931..073f37fe5a42 100644 --- a/packages/driver/cypress/e2e/commands/navigation.cy.js +++ b/packages/driver/cypress/e2e/commands/navigation.cy.js @@ -610,7 +610,7 @@ describe('src/cy/commands/navigation', () => { }) } - it('can visit pages on the same originPolicy', () => { + it('can visit pages on the same origin', () => { cy .visit('http://localhost:3500/fixtures/jquery.html') .visit('http://localhost:3500/fixtures/generic.html') @@ -690,7 +690,7 @@ describe('src/cy/commands/navigation', () => { }) }) - it('can visit relative pages on the same originPolicy', () => { + it('can visit relative pages on the same origin', () => { // as long as we are already on the localhost:3500 // domain this will work cy diff --git a/packages/driver/cypress/e2e/cypress/location.cy.js b/packages/driver/cypress/e2e/cypress/location.cy.js index f9d2a4525c7b..196c01299184 100644 --- a/packages/driver/cypress/e2e/cypress/location.cy.js +++ b/packages/driver/cypress/e2e/cypress/location.cy.js @@ -203,51 +203,101 @@ describe('src/cypress/location', () => { }) }) - context('#getOriginPolicy', () => { + context('#getOrigin', () => { + it('handles ip addresses', function () { + const str = this.setup('local').getOrigin() + + expect(str).to.eq('http://127.0.0.1:8080') + }) + + it('handles 1 part localhost', function () { + const str = this.setup('users').getOrigin() + + expect(str).to.eq('http://localhost:2020') + }) + + it('handles 2 parts stack', function () { + const str = this.setup('stack').getOrigin() + + expect(str).to.eq('https://stackoverflow.com') + }) + + it('handles subdomains google', function () { + const str = this.setup('google').getOrigin() + + expect(str).to.eq('https://www.google.com') + }) + + it('issue: #255 two domains in the url', function () { + const str = this.setup('email').getOrigin() + + expect(str).to.eq('http://localhost:3500') + }) + + it('handles private tlds in the public suffix', function () { + const str = this.setup('heroku').getOrigin() + + expect(str).to.eq('https://example.herokuapp.com') + }) + + it('handles subdomains of private tlds in the public suffix', function () { + const str = this.setup('herokuSub').getOrigin() + + expect(str).to.eq('https://foo.example.herokuapp.com') + }) + + it('falls back to dumb check when invalid tld', function () { + const str = this.setup('unknown').getOrigin() + + expect(str).to.eq('http://what.is.so.unknown') + }) + }) + + context('#getSuperDomainOrigin', () => { it('handles ip addresses', function () { - const str = this.setup('local').getOriginPolicy() + const str = this.setup('local').getSuperDomainOrigin() expect(str).to.eq('http://127.0.0.1:8080') }) it('handles 1 part localhost', function () { - const str = this.setup('users').getOriginPolicy() + const str = this.setup('users').getSuperDomainOrigin() expect(str).to.eq('http://localhost:2020') }) it('handles 2 parts stack', function () { - const str = this.setup('stack').getOriginPolicy() + const str = this.setup('stack').getSuperDomainOrigin() expect(str).to.eq('https://stackoverflow.com') }) it('handles subdomains google', function () { - const str = this.setup('google').getOriginPolicy() + const str = this.setup('google').getSuperDomainOrigin() expect(str).to.eq('https://google.com') }) it('issue: #255 two domains in the url', function () { - const str = this.setup('email').getOriginPolicy() + const str = this.setup('email').getSuperDomainOrigin() expect(str).to.eq('http://localhost:3500') }) it('handles private tlds in the public suffix', function () { - const str = this.setup('heroku').getOriginPolicy() + const str = this.setup('heroku').getSuperDomainOrigin() expect(str).to.eq('https://example.herokuapp.com') }) it('handles subdomains of private tlds in the public suffix', function () { - const str = this.setup('herokuSub').getOriginPolicy() + const str = this.setup('herokuSub').getSuperDomainOrigin() expect(str).to.eq('https://example.herokuapp.com') }) it('falls back to dumb check when invalid tld', function () { - const str = this.setup('unknown').getOriginPolicy() + const str = this.setup('unknown').getSuperDomainOrigin() expect(str).to.eq('http://so.unknown') }) @@ -256,7 +306,7 @@ describe('src/cypress/location', () => { context('.create', () => { it('returns an object literal', () => { const obj = Location.create(urls.cypress, urls.signin) - const keys = ['auth', 'authObj', 'hash', 'href', 'host', 'hostname', 'origin', 'pathname', 'port', 'protocol', 'search', 'toString', 'originPolicy', 'superDomain'] + const keys = ['auth', 'authObj', 'hash', 'href', 'host', 'hostname', 'pathname', 'port', 'protocol', 'search', 'toString', 'origin', 'superDomainOrigin', 'superDomain'] expect(obj).to.have.keys(keys) }) diff --git a/packages/driver/cypress/e2e/e2e/origin/commands/location.cy.ts b/packages/driver/cypress/e2e/e2e/origin/commands/location.cy.ts index 07dbc1f79ae0..0457b1a1282f 100644 --- a/packages/driver/cypress/e2e/e2e/origin/commands/location.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/commands/location.cy.ts @@ -67,7 +67,7 @@ context('cy.origin location', () => { expect(consoleProps.Yielded).to.have.property('hostname').that.is.a('string') expect(consoleProps.Yielded).to.have.property('href').that.is.a('string') expect(consoleProps.Yielded).to.have.property('origin').that.is.a('string') - expect(consoleProps.Yielded).to.have.property('originPolicy').that.is.a('string') + expect(consoleProps.Yielded).to.have.property('superDomainOrigin').that.is.a('string') expect(consoleProps.Yielded).to.have.property('pathname').that.is.a('string') expect(consoleProps.Yielded).to.have.property('port').that.is.a('string') expect(consoleProps.Yielded).to.have.property('protocol').that.is.a('string') diff --git a/packages/driver/cypress/e2e/e2e/origin/cookie_behavior.cy.ts b/packages/driver/cypress/e2e/e2e/origin/cookie_behavior.cy.ts index 4726fb353f17..d0e6c81554c7 100644 --- a/packages/driver/cypress/e2e/e2e/origin/cookie_behavior.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/cookie_behavior.cy.ts @@ -41,7 +41,6 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { } beforeEach(() => { - // FIXME: clearing cookies in the browser currently does not clear cookies in the server-side cookie jar cy.clearCookies() }) @@ -182,14 +181,10 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { }) }) - // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 it('does NOT attach same-site cookies to request if "omit" credentials option is specified', () => { cy.intercept(`${originUrl}/test-request`, (req) => { - // current expected assertion with server side cookie jar is set from previous test - expect(req['headers']['cookie']).to.equal('foo1=bar1') + expect(req['headers']['cookie']).to.equal(undefined) - // future expected assertion, regardless of server side cookie jar - // expect(req['headers']['cookie']).to.equal('') req.reply({ statusCode: 200, }) @@ -214,14 +209,10 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { }) }) - // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 it('does NOT set same-site cookies from request if "omit" credentials option is specified', () => { cy.intercept(`${originUrl}/test-request`, (req) => { - // current expected assertion with server side cookie jar is set from previous test - expect(req['headers']['cookie']).to.equal('foo1=bar1') + expect(req['headers']['cookie']).to.equal(undefined) - // future expected assertion, regardless of server side cookie jar - // expect(req['headers']['cookie']).to.equal('') req.reply({ statusCode: 200, }) @@ -250,10 +241,9 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { describe('same site / cross origin', () => { describe('XMLHttpRequest', () => { - // withCredentials option should have no effect on same-site requests, even though the request is cross-origin - it('sets and attaches same-site cookies to request, even though request is cross-origin', () => { + it('does NOT set and attach same-site cookies to request when the request is cross-origin', () => { cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request`, (req) => { - expect(req['headers']['cookie']).to.equal('foo1=bar1') + expect(req['headers']['cookie']).to.equal(undefined) req.reply({ statusCode: 200, @@ -283,17 +273,107 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { cy.wait('@cookieCheck') }) }) + + it('sets cookie on same-site request if withCredentials is true, but does not attach to same-site request if withCredentials is false', () => { + cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request`, (req) => { + expect(req['headers']['cookie']).to.equal(undefined) + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit('/fixtures/primary-origin.html') + cy.get(`a[data-cy="cookie-${scheme}"]`).click() + + // cookie jar should now mimic http://foobar.com:3500 / https://foobar.com:3502 as top + cy.origin(originUrl, { + args: { + scheme, + crossOriginPort, + }, + }, ({ scheme, crossOriginPort }) => { + cy.window().then((win) => { + // do NOT set the cookie in the browser + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/set-cookie-credentials?cookie=foo1=bar1; Domain=foobar.com`, 'xmlHttpRequest', true)) + }) + + // though request is cross origin, site should have access directly to cookie because it is same site + // assert cookie value is actually set in the browser + // current expected assertion. NOTE: This SHOULD be consistent + if (Cypress.isBrowser('firefox')) { + // firefox actually sets the cookie correctly + cy.getCookie('foo1').its('value').should('equal', 'bar1') + } else { + cy.getCookie('foo1').its('value').should('equal', null) + } + + // FIXME: Ideally, browser should have access to this cookie. Should be fixed in https://github.com/cypress-io/cypress/pull/23643. + // future expected assertion + // cy.getCookie('foo1').its('value').should('equal', 'bar1') + + cy.window().then((win) => { + // but send the cookies in the request + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/test-request`, 'xmlHttpRequest')) + }) + + cy.wait('@cookieCheck') + }) + }) + + it('sets cookie on same-site request if withCredentials is true, and attaches to same-site request if withCredentials is true', () => { + cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, (req) => { + expect(req['headers']['cookie']).to.equal('foo1=bar1') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit('/fixtures/primary-origin.html') + cy.get(`a[data-cy="cookie-${scheme}"]`).click() + + // cookie jar should now mimic http://foobar.com:3500 / https://foobar.com:3502 as top + cy.origin(originUrl, { + args: { + scheme, + crossOriginPort, + }, + }, ({ scheme, crossOriginPort }) => { + cy.window().then((win) => { + // do NOT set the cookie in the browser + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/set-cookie-credentials?cookie=foo1=bar1; Domain=foobar.com`, 'xmlHttpRequest', true)) + }) + + // though request is cross origin, site should have access directly to cookie because it is same site + // assert cookie value is actually set in the browser + // current expected assertion. NOTE: This SHOULD be consistent + if (Cypress.isBrowser('firefox')) { + // firefox actually sets the cookie correctly + cy.getCookie('foo1').its('value').should('equal', 'bar1') + } else { + cy.getCookie('foo1').its('value').should('equal', null) + } + + // FIXME: Ideally, browser should have access to this cookie. Should be fixed in https://github.com/cypress-io/cypress/pull/23643. + // future expected assertion + // cy.getCookie('foo1').its('value').should('equal', 'bar1') + + cy.window().then((win) => { + // but send the cookies in the request + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, 'xmlHttpRequest', true)) + }) + + cy.wait('@cookieCheck') + }) + }) }) describe('fetch', () => { - // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 it('does not set same-site cookies from request nor send same-site cookies by default (same-origin)', () => { cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, (req) => { - // current expected assertion - expect(req['headers']['cookie']).to.equal('foo1=bar1') + expect(req['headers']['cookie']).to.equal(undefined) - // future expected assertion - // expect(req['headers']['cookie']).to.equal('') req.reply({ statusCode: 200, }) @@ -345,7 +425,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { }) // assert cookie value is actually set in the browser - // current expected assertion. NOTE: This SHOULD be consistent + // current expected assertion. if (Cypress.isBrowser('firefox')) { // firefox actually sets the cookie correctly cy.getCookie('foo1').its('value').should('equal', 'bar1') @@ -353,7 +433,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { cy.getCookie('foo1').its('value').should('equal', null) } - // FIXME: ideally, browser should have access to this cookie + // FIXME: Ideally, browser should have access to this cookie. Should be fixed in https://github.com/cypress-io/cypress/pull/23643. // future expected assertion // cy.getCookie('foo1').its('value').should('equal', 'bar1') @@ -363,14 +443,10 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { }) }) - // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 it('sets same-site cookies if "include" credentials option is specified from request, but does not attach same-site cookies to request by default (same-origin)', () => { cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, (req) => { - // current expected assertion - expect(req['headers']['cookie']).to.equal('foo1=bar1') + expect(req['headers']['cookie']).to.equal(undefined) - // future expected assertion - // expect(req['headers']['cookie']).to.equal('') req.reply({ statusCode: 200, }) @@ -399,7 +475,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { cy.getCookie('foo1').its('value').should('equal', null) } - // FIXME: ideally, browser should have access to this cookie + // FIXME: Ideally, browser should have access to this cookie. Should be fixed in https://github.com/cypress-io/cypress/pull/23643. // future expected assertion // cy.getCookie('foo1').its('value').should('equal', 'bar1') @@ -411,15 +487,10 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { }) }) - // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 // this should have the same effect as same-origin option for same-site/cross-origin requests, but adding here incase our implementation is not consistent it('does not set or send same-site cookies if "omit" credentials option is specified', () => { cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, (req) => { - // current expected assertion - expect(req['headers']['cookie']).to.equal('foo1=bar1') - - // future expected assertion - // expect(req['headers']['cookie']).to.equal('') + expect(req['headers']['cookie']).to.equal(undefined) req.reply({ statusCode: 200, }) @@ -453,7 +524,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { describe('XMLHttpRequest', () => { it('does NOT set or send cookies with request by default', () => { cy.intercept(`${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, (req) => { - expect(req['headers']['cookie']).to.equal('') + expect(req['headers']['cookie']).to.equal(undefined) req.reply({ statusCode: 200, @@ -484,14 +555,9 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { // can only set third-party SameSite=None with Secure attribute, which is only possibly over https if (scheme === 'https') { - // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 it('does set cookie if withCredentials is true, but does not send cookie if withCredentials is false', () => { cy.intercept(`${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, (req) => { - // current expected assertion - expect(req['headers']['cookie']).to.equal('bar1=baz1') - - // future expected assertion - // expect(req['headers']['cookie']).to.equal('') + expect(req['headers']['cookie']).to.equal(undefined) req.reply({ statusCode: 200, @@ -514,7 +580,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { // assert cookie value is actually set in the browser if (scheme === 'https') { - // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser + // FIXME: cy.getCookie does not believe this cookie exists. Should be fixed in https://github.com/cypress-io/cypress/pull/23643. cy.getCookie('bar1').its('value').should('equal', null) // can only set third-party SameSite=None with Secure attribute, which is only possibly over https @@ -555,7 +621,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/set-cookie-credentials?cookie=bar1=baz1; Domain=barbaz.com; SameSite=None; Secure`, 'xmlHttpRequest', true)) }) - // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser + // FIXME: cy.getCookie does not believe this cookie exists. Should be fixed in https://github.com/cypress-io/cypress/pull/23643. cy.getCookie('bar1').its('value').should('equal', null) // can only set third-party SameSite=None with Secure attribute, which is only possibly over https @@ -574,9 +640,9 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { describe('fetch', () => { ['same-origin', 'omit'].forEach((credentialOption) => { - it(`does NOT set or send cookies with request by credentials is ${credentialOption}`, () => { + it(`does NOT set or send cookies with request if credentials is ${credentialOption}`, () => { cy.intercept(`${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, (req) => { - expect(req['headers']['cookie']).to.equal('') + expect(req['headers']['cookie']).to.equal(undefined) req.reply({ statusCode: 200, @@ -608,18 +674,9 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { }) }) - // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 it(`does set cookie if credentials is "include", but does not send cookie if credentials is ${credentialOption}`, () => { cy.intercept(`${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, (req) => { - // current expected assertion - if (scheme === 'https') { - expect(req['headers']['cookie']).to.equal('bar1=baz1') - } else { - expect(req['headers']['cookie']).to.equal('') - } - - // future expected assertion for both http / https - // expect(req['headers']['cookie']).to.equal('') + expect(req['headers']['cookie']).to.equal(undefined) req.reply({ statusCode: 200, @@ -643,7 +700,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { // assert cookie value is actually set in the browser if (scheme === 'https') { - // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser + // FIXME: cy.getCookie does not believe this cookie exists. Should be fixed in https://github.com/cypress-io/cypress/pull/23643. cy.getCookie('bar1').its('value').should('equal', null) // can only set third-party SameSite=None with Secure attribute, which is only possibly over https @@ -689,7 +746,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { // assert cookie value is actually set in the browser - // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser + // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser. Should be fixed in https://github.com/cypress-io/cypress/pull/23643. cy.getCookie('bar1').its('value').should('equal', null) // can only set third-party SameSite=None with Secure attribute, which is only possibly over https @@ -931,14 +988,10 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { cy.wait('@cookieCheck') }) - // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 it('does NOT attach same-site cookies to request if "omit" credentials option is specified', () => { cy.intercept('/test-request', (req) => { - // current expected assertion with server side cookie jar is set from previous test - expect(req['headers']['cookie']).to.equal('foo1=bar1') + expect(req['headers']['cookie']).to.equal(undefined) - // future expected assertion, regardless of server side cookie jar - // expect(req['headers']['cookie']).to.equal('') req.reply({ statusCode: 200, }) @@ -958,14 +1011,10 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { cy.wait('@cookieCheck') }) - // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 it('does NOT set same-site cookies from request if "omit" credentials option is specified', () => { cy.intercept('/test-request', (req) => { - // current expected assertion with server side cookie jar is set from previous test - expect(req['headers']['cookie']).to.equal('foo1=bar1') + expect(req['headers']['cookie']).to.equal(undefined) - // future expected assertion, regardless of server side cookie jar - // expect(req['headers']['cookie']).to.equal('') req.reply({ statusCode: 200, }) @@ -989,10 +1038,9 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { describe('same site / cross origin', () => { describe('XMLHttpRequest', () => { - // withCredentials option should have no effect on same-site requests, even though the request is cross-origin - it('sets and attaches same-site cookies to request, even though request is cross-origin', () => { + it('does NOT set and attach same-site cookies to request when the request is cross-origin', () => { cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request`, (req) => { - expect(req['headers']['cookie']).to.equal('foo1=bar1') + expect(req['headers']['cookie']).to.equal(undefined) req.reply({ statusCode: 200, @@ -1010,17 +1058,64 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { cy.wait('@cookieCheck') }) + + it('sets cookie on same-site request if withCredentials is true, but does not attach to same-site request if withCredentials is false', () => { + cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request`, (req) => { + expect(req['headers']['cookie']).to.equal(undefined) + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit(`${scheme}://www.foobar.com:${sameOriginPort}`) + cy.window().then((win) => { + // do NOT set the cookie in the browser + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/set-cookie-credentials?cookie=foo1=bar1; Domain=foobar.com`, 'xmlHttpRequest', true)) + }) + + // firefox actually sets the cookie correctly + cy.getCookie('foo1').its('value').should('equal', 'bar1') + + cy.window().then((win) => { + // but send the cookies in the request + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/test-request`, 'xmlHttpRequest')) + }) + + cy.wait('@cookieCheck') + }) + + it('sets cookie on same-site request if withCredentials is true, and attaches to same-site request if withCredentials is true', () => { + cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, (req) => { + expect(req['headers']['cookie']).to.equal('foo1=bar1') + + req.reply({ + statusCode: 200, + }) + }).as('cookieCheck') + + cy.visit(`${scheme}://www.foobar.com:${sameOriginPort}`) + cy.window().then((win) => { + // do NOT set the cookie in the browser + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/set-cookie-credentials?cookie=foo1=bar1; Domain=foobar.com`, 'xmlHttpRequest', true)) + }) + + cy.getCookie('foo1').its('value').should('equal', 'bar1') + + cy.window().then((win) => { + // but send the cookies in the request + return cy.wrap(makeRequest(win, `${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, 'xmlHttpRequest', true)) + }) + + cy.wait('@cookieCheck') + }) }) describe('fetch', () => { - // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 it('does not set same-site cookies from request nor send same-site cookies by default (same-origin)', () => { cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, (req) => { - // current expected assertion - expect(req['headers']['cookie']).to.equal('foo1=bar1') + expect(req['headers']['cookie']).to.equal(undefined) - // future expected assertion - // expect(req['headers']['cookie']).to.equal('') req.reply({ statusCode: 200, }) @@ -1061,14 +1156,10 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { cy.wait('@cookieCheck') }) - // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 it('sets same-site cookies if "include" credentials option is specified from request, but does not attach same-site cookies to request by default (same-origin)', () => { cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, (req) => { - // current expected assertion - expect(req['headers']['cookie']).to.equal('foo1=bar1') + expect(req['headers']['cookie']).to.equal(undefined) - // future expected assertion - // expect(req['headers']['cookie']).to.equal('') req.reply({ statusCode: 200, }) @@ -1088,15 +1179,11 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { cy.wait('@cookieCheck') }) - // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 // this should have the same effect as same-origin option for same-site/cross-origin requests, but adding here incase our implementation is not consistent it('does not set or send same-site cookies if "omit" credentials option is specified', () => { cy.intercept(`${scheme}://app.foobar.com:${crossOriginPort}/test-request-credentials`, (req) => { - // current expected assertion - expect(req['headers']['cookie']).to.equal('foo1=bar1') + expect(req['headers']['cookie']).to.equal(undefined) - // future expected assertion - // expect(req['headers']['cookie']).to.equal('') req.reply({ statusCode: 200, }) @@ -1120,7 +1207,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { describe('XMLHttpRequest', () => { it('does NOT set or send cookies with request by default', () => { cy.intercept(`${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, (req) => { - expect(req['headers']['cookie']).to.equal('') + expect(req['headers']['cookie']).to.equal(undefined) req.reply({ statusCode: 200, @@ -1141,14 +1228,9 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { // can only set third-party SameSite=None with Secure attribute, which is only possibly over https if (scheme === 'https') { - // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 it('does set cookie if withCredentials is true, but does not send cookie if withCredentials is false', () => { cy.intercept(`${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, (req) => { - // current expected assertion - expect(req['headers']['cookie']).to.equal('bar1=baz1') - - // future expected assertion - // expect(req['headers']['cookie']).to.equal('') + expect(req['headers']['cookie']).to.equal(undefined) req.reply({ statusCode: 200, @@ -1162,7 +1244,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { // assert cookie value is actually set in the browser if (scheme === 'https') { - // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser + // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser. Should be fixed in https://github.com/cypress-io/cypress/pull/23643. cy.getCookie('bar1').its('value').should('equal', null) // can only set third-party SameSite=None with Secure attribute, which is only possibly over https @@ -1193,7 +1275,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/set-cookie-credentials?cookie=bar1=baz1; Domain=barbaz.com; SameSite=None; Secure`, 'xmlHttpRequest', true)) }) - // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser + // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser. Should be fixed in https://github.com/cypress-io/cypress/pull/23643 cy.getCookie('bar1').its('value').should('equal', null) // can only set third-party SameSite=None with Secure attribute, which is only possibly over https @@ -1213,7 +1295,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { ['same-origin', 'omit'].forEach((credentialOption) => { it(`does NOT set or send cookies with request by credentials is ${credentialOption}`, () => { cy.intercept(`${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, (req) => { - expect(req['headers']['cookie']).to.equal('') + expect(req['headers']['cookie']).to.equal(undefined) req.reply({ statusCode: 200, @@ -1234,18 +1316,9 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { cy.wait('@cookieCheck') }) - // FIXME: @see https://github.com/cypress-io/cypress/issues/23551 it(`does set cookie if credentials is "include", but does not send cookie if credentials is ${credentialOption}`, () => { cy.intercept(`${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, (req) => { - // current expected assertion - if (scheme === 'https') { - expect(req['headers']['cookie']).to.equal('bar1=baz1') - } else { - expect(req['headers']['cookie']).to.equal('') - } - - // future expected assertion for both http / https - // expect(req['headers']['cookie']).to.equal('') + expect(req['headers']['cookie']).to.equal(undefined) req.reply({ statusCode: 200, @@ -1259,7 +1332,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { // assert cookie value is actually set in the browser if (scheme === 'https') { - // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser + // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser. Should be fixed in https://github.com/cypress-io/cypress/pull/23643 cy.getCookie('bar1').its('value').should('equal', null) // can only set third-party SameSite=None with Secure attribute, which is only possibly over https @@ -1295,7 +1368,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { // assert cookie value is actually set in the browser - // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser + // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser. Should be fixed in https://github.com/cypress-io/cypress/pull/23643 cy.getCookie('bar1').its('value').should('equal', null) // can only set third-party SameSite=None with Secure attribute, which is only possibly over https diff --git a/packages/driver/cypress/e2e/e2e/origin/patches.cy.ts b/packages/driver/cypress/e2e/e2e/origin/patches.cy.ts index 5111fcfdaeb7..d87511a5fedd 100644 --- a/packages/driver/cypress/e2e/e2e/origin/patches.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/patches.cy.ts @@ -1,10 +1,10 @@ describe('src/cross-origin/patches', () => { - beforeEach(() => { - cy.visit('/fixtures/primary-origin.html') - cy.get('a[data-cy="cross-origin-secondary-link"]').click() - }) - context('submit', () => { + beforeEach(() => { + cy.visit('/fixtures/primary-origin.html') + cy.get('a[data-cy="cross-origin-secondary-link"]').click() + }) + it('correctly submits a form when the target is _top for HTMLFormElement', () => { cy.origin('http://www.foobar.com:3500', () => { cy.get('form').then(($form) => { @@ -18,6 +18,11 @@ describe('src/cross-origin/patches', () => { }) context('setAttribute', () => { + beforeEach(() => { + cy.visit('/fixtures/primary-origin.html') + cy.get('a[data-cy="cross-origin-secondary-link"]').click() + }) + it('renames integrity to cypress-stripped-integrity for HTMLScriptElement', () => { cy.origin('http://www.foobar.com:3500', () => { cy.window().then((win: Window) => { @@ -52,4 +57,533 @@ describe('src/cross-origin/patches', () => { }) }) }) + + context('fetch', () => { + describe('from the AUT', () => { + beforeEach(() => { + cy.intercept('/test-request').as('testRequest') + cy.origin('http://www.foobar.com:3500', () => { + cy.stub(Cypress, 'backend').callThrough() + }) + + cy.visit('/fixtures/primary-origin.html') + cy.get('a[data-cy="xhr-fetch-requests"]').click() + }) + + describe('patches fetch in the AUT when going cross origin and sends credential status to server socket', () => { + [undefined, 'same-origin', 'omit', 'include'].forEach((credentialOption) => { + describe(`for credential option ${credentialOption || 'default'}`, () => { + const postfixedSelector = !credentialOption || credentialOption === 'same-origin' ? '' : `-${credentialOption}` + const assertCredentialStatus = credentialOption || 'same-origin' + + it('with a url string', () => { + cy.origin('http://www.foobar.com:3500', { + args: { + postfixedSelector, + assertCredentialStatus, + }, + }, + ({ postfixedSelector, assertCredentialStatus }) => { + cy.get(`[data-cy="trigger-fetch${postfixedSelector}"]`).click() + cy.wait('@testRequest') + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://www.foobar.com:3500/test-request', + resourceType: 'fetch', + credentialStatus: assertCredentialStatus, + }) + }) + }) + }) + + it('with a request object', () => { + cy.origin('http://www.foobar.com:3500', { + args: { + postfixedSelector, + assertCredentialStatus, + }, + }, + ({ postfixedSelector, assertCredentialStatus }) => { + cy.get(`[data-cy="trigger-fetch-with-request-object${postfixedSelector}"]`).click() + cy.wait('@testRequest') + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://www.foobar.com:3500/test-request', + resourceType: 'fetch', + credentialStatus: assertCredentialStatus, + }) + }) + }) + }) + + it('with a url object', () => { + cy.origin('http://www.foobar.com:3500', { + args: { + postfixedSelector, + assertCredentialStatus, + }, + }, + ({ postfixedSelector, assertCredentialStatus }) => { + cy.get(`[data-cy="trigger-fetch-with-url-object${postfixedSelector}"]`).click() + cy.wait('@testRequest') + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://www.foobar.com:3500/test-request', + resourceType: 'fetch', + credentialStatus: assertCredentialStatus, + }) + }) + }) + }) + }) + }) + + it('fails gracefully if fetch is called with Bad arguments and we don\'t single to the socket (must match the fetch api spec), but fetch request still proceeds', () => { + cy.origin('http://www.foobar.com:3500', + () => { + cy.on('uncaught:exception', (err) => { + expect(err.message).to.contain('404') + expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') + + return false + }) + + cy.get(`[data-cy="trigger-fetch-with-bad-options"]`).click() + }) + }) + + it('works as expected with requests that require preflight that ultimately fail and the request does not succeed', () => { + cy.origin('http://www.foobar.com:3500', + () => { + cy.on('uncaught:exception', (err) => { + expect(err.message).to.contain('CORS ERROR') + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://app.foobar.com:3500/test-request', + resourceType: 'fetch', + credentialStatus: 'include', + }) + + return false + }) + + cy.get(`[data-cy="trigger-fetch-with-preflight"]`).click() + }) + }) + }) + }) + + describe('from the spec bridge', () => { + beforeEach(() => { + cy.intercept('/test-request').as('testRequest') + cy.stub(Cypress, 'backend').callThrough() + cy.origin('http://www.foobar.com:3500', () => { + cy.stub(Cypress, 'backend').callThrough() + }) + + cy.visit('/fixtures/primary-origin.html') + cy.get('a[data-cy="xhr-fetch-requests"]').click() + }) + + describe('patches fetch in the AUT when going cross origin and sends credential status to server socket', () => { + [undefined, 'same-origin', 'omit', 'include'].forEach((credentialOption) => { + const assertCredentialStatus = credentialOption || 'same-origin' + + // NOTE: Even if the request fails, this should be popped off the queue in the proxy and not be an issue moving forward. + // only thing we should be concerned with is urls that go over the socket but somehow do NOT make a request. + // This MIGHT be an issue for preflight requests failing if the browser fails the request and the request doesn't actually make it through the proxy + describe(`for credential option ${credentialOption || 'default'}`, () => { + it('with a url string', () => { + cy.origin('http://www.foobar.com:3500', { + args: { + credentialOption, + assertCredentialStatus, + }, + }, ({ credentialOption, assertCredentialStatus }) => { + cy.then(() => { + if (credentialOption) { + return fetch('http://www.foobar.com:3500/test-request-credentials', { + credentials: credentialOption as RequestCredentials, + }) + } + + return fetch('http://www.foobar.com:3500/test-request-credentials') + }) + + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://www.foobar.com:3500/test-request-credentials', + resourceType: 'fetch', + credentialStatus: assertCredentialStatus, + }) + }) + }) + }) + + it('with a request object', () => { + cy.origin('http://www.foobar.com:3500', { + args: { + credentialOption, + assertCredentialStatus, + }, + }, ({ credentialOption, assertCredentialStatus }) => { + cy.then(() => { + let req + + if (credentialOption) { + req = new Request('http://www.foobar.com:3500/test-request-credentials', { + credentials: credentialOption as RequestCredentials, + }) + } else { + req = new Request('http://www.foobar.com:3500/test-request-credentials') + } + + return fetch(req) + }) + + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://www.foobar.com:3500/test-request-credentials', + resourceType: 'fetch', + credentialStatus: assertCredentialStatus, + }) + }) + }) + }) + + it('with a url object', () => { + cy.origin('http://www.foobar.com:3500', { + args: { + credentialOption, + assertCredentialStatus, + }, + }, ({ credentialOption, assertCredentialStatus }) => { + cy.then(() => { + let urlObj = new URL('/test-request-credentials', 'http://www.foobar.com:3500') + + if (credentialOption) { + return fetch(urlObj, { + credentials: credentialOption as RequestCredentials, + }) + } + + return fetch(urlObj) + }) + + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://www.foobar.com:3500/test-request-credentials', + resourceType: 'fetch', + credentialStatus: assertCredentialStatus, + }) + }) + }) + }) + }) + }) + + it('fails gracefully if fetch is called with Bad arguments and we don\'t single to the socket (must match the fetch api spec), but fetch request still proceeds', () => { + cy.origin('http://www.foobar.com:3500', + () => { + cy.on('uncaught:exception', (err) => { + expect(err.message).to.contain('404') + expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') + + return false + }) + + cy.get(`[data-cy="trigger-fetch-with-bad-options"]`).click() + }) + }) + }) + + it('works as expected with requests that require preflight that ultimately fail and the request does not succeed', () => { + cy.origin('http://www.foobar.com:3500', + () => { + cy.then(() => { + let url = new URL('/test-request', 'http://app.foobar.com:3500').toString() + + return new Promise((resolve, reject) => { + fetch(url, { + credentials: 'include', + headers: { + 'foo': 'bar', + }, + }).catch(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://app.foobar.com:3500/test-request', + resourceType: 'fetch', + credentialStatus: 'include', + }) + + resolve() + }).then(() => { + // if this fetch does not fail, fail the test + reject() + }) + }) + }) + }) + }) + }) + + it('does not patch fetch in the spec window or the AUT if the AUT is on the primary', () => { + cy.stub(Cypress, 'backend').callThrough() + cy.visit('fixtures/xhr-fetch-requests.html') + + cy.window().then((win) => { + win.location.href = 'http://www.foobar.com:3500/fixtures/secondary-origin.html' + }) + + cy.origin('http://www.foobar.com:3500', () => { + cy.window().then((win) => { + win.location.href = 'http://localhost:3500/fixtures/xhr-fetch-requests.html' + }) + }) + + // expect spec to NOT be patched in primary + cy.then(async () => { + await fetch('/test-request') + + expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') + }) + + // expect AUT to NOT be patched in primary + cy.window().then(async (win) => { + await win.fetch('/test-request') + + expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') + }) + }) + }) + + context('xmlHttpRequest', () => { + describe('from the AUT', () => { + beforeEach(() => { + cy.intercept('/test-request').as('testRequest') + cy.origin('http://www.foobar.com:3500', () => { + cy.stub(Cypress, 'backend').callThrough() + }) + + cy.visit('/fixtures/primary-origin.html') + cy.get('a[data-cy="xhr-fetch-requests"]').click() + }) + + describe('patches xmlHttpRequest in the AUT when going cross origin and sends credential status to server socket', () => { + [false, true].forEach((withCredentials) => { + it(`for withCredentials option ${withCredentials}`, () => { + const postfixedSelector = withCredentials ? '-with-credentials' : '' + + cy.origin('http://www.foobar.com:3500', { + args: { + postfixedSelector, + withCredentials, + }, + }, + ({ postfixedSelector, withCredentials = false }) => { + cy.get(`[data-cy="trigger-xml-http-request${postfixedSelector}"]`).click() + cy.wait('@testRequest') + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://www.foobar.com:3500/test-request', + resourceType: 'xhr', + credentialStatus: withCredentials, + }) + }) + }) + }) + }) + + it('fails gracefully if xhr is called with Bad arguments and we don\'t signal to the socket (must match the legit URL), but xhr request still proceeds', () => { + cy.origin('http://www.foobar.com:3500', + () => { + cy.on('uncaught:exception', (err) => { + expect(err.message).to.contain('404') + expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') + + return false + }) + + cy.get(`[data-cy="trigger-xml-http-request-with-bad-options"]`).click() + }) + }) + }) + + it('works as expected with requests that require preflight that ultimately fail and the request does not succeed', () => { + cy.origin('http://www.foobar.com:3500', + () => { + cy.on('uncaught:exception', (err) => { + expect(err.message).to.contain('CORS ERROR') + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://app.foobar.com:3500/test-request', + resourceType: 'xhr', + credentialStatus: true, + }) + + return false + }) + + cy.get(`[data-cy="trigger-xml-http-request-with-preflight"]`).click() + }) + }) + }) + + describe('from the spec bridge', () => { + beforeEach(() => { + cy.intercept('/test-request').as('testRequest') + cy.stub(Cypress, 'backend').callThrough() + cy.origin('http://www.foobar.com:3500', () => { + cy.stub(Cypress, 'backend').callThrough() + }) + + cy.visit('/fixtures/primary-origin.html') + cy.get('a[data-cy="xhr-fetch-requests"]').click() + }) + + describe('patches xmlHttpRequest in the spec bridge', () => { + [false, true].forEach((withCredentials) => { + it(`for withCredentials option ${withCredentials}`, () => { + cy.origin('http://www.foobar.com:3500', { + args: { + withCredentials, + }, + }, + ({ withCredentials = false }) => { + cy.then(() => { + let url = new URL('/test-request-credentials', 'http://www.foobar.com:3500').toString() + + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest() + + xhr.open('GET', url) + xhr.withCredentials = withCredentials + xhr.onload = function () { + resolve(xhr.response) + } + + xhr.onerror = function () { + reject(xhr.response) + } + + xhr.send() + }) + }) + + cy.then(() => { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://www.foobar.com:3500/test-request-credentials', + resourceType: 'xhr', + credentialStatus: withCredentials, + }) + }) + }) + }) + }) + + it('fails gracefully if xmlHttpRequest is called with bad arguments and we don\'t signal to the socket (must match the legit URL), but xhr request still proceeds', () => { + cy.origin('http://www.foobar.com:3500', + () => { + cy.on('uncaught:exception', (err) => { + expect(err.message).to.contain('404') + expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') + + return false + }) + + cy.get(`[data-cy="trigger-xml-http-request-with-bad-options"]`).click() + }) + }) + + it('works as expected with requests that require preflight that ultimately fail and the request does not succeed', () => { + cy.origin('http://www.foobar.com:3500', + () => { + cy.then(() => { + let url = new URL('/test-request', 'http://app.foobar.com:3500').toString() + + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest() + + xhr.open('GET', url) + xhr.withCredentials = true + xhr.onload = function () { + // if this request passes, fail the test + reject(xhr.response) + } + + xhr.onerror = function () { + expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + url: 'http://app.foobar.com:3500/test-request', + resourceType: 'xhr', + credentialStatus: true, + }) + + resolve() + } + + xhr.send() + }) + }) + }) + }) + }) + }) + + it('does not patch xmlHttpRequest in the spec window or the AUT if the AUT is on the primary', () => { + cy.stub(Cypress, 'backend').callThrough() + cy.visit('fixtures/xhr-fetch-requests.html') + + cy.window().then((win) => { + win.location.href = 'http://www.foobar.com:3500/fixtures/secondary-origin.html' + }) + + cy.origin('http://www.foobar.com:3500', () => { + cy.window().then((win) => { + win.location.href = 'http://localhost:3500/fixtures/xhr-fetch-requests.html' + }) + }) + + // expect spec to NOT be patched in primary + cy.then(async () => { + let url = new URL('/test-request-credentials', 'http://www.foobar.com:3500').toString() + + await new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest() + + xhr.open('GET', url) + xhr.onload = function () { + resolve(xhr.response) + } + + xhr.onerror = function () { + reject(xhr.response) + } + + xhr.send() + }) + + expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') + }) + + // expect AUT to NOT be patched in primary + cy.window().then(async (win) => { + let url = new URL('/test-request-credentials', 'http://www.foobar.com:3500').toString() + + await new Promise((resolve, reject) => { + let xhr = new win.XMLHttpRequest() + + xhr.open('GET', url) + xhr.onload = function () { + resolve(xhr.response) + } + + xhr.onerror = function () { + reject(xhr.response) + } + + xhr.send() + }) + + expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') + }) + }) + }) }) diff --git a/packages/driver/cypress/e2e/e2e/origin/snapshots.cy.ts b/packages/driver/cypress/e2e/e2e/origin/snapshots.cy.ts index 2e9b703f1b33..a9dd06757337 100644 --- a/packages/driver/cypress/e2e/e2e/origin/snapshots.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/snapshots.cy.ts @@ -23,6 +23,7 @@ describe('cy.origin - snapshots', () => { }) cy.visit('/fixtures/primary-origin.html') + cy.get('a[data-cy="xhr-fetch-requests-onload"]').click() }) // TODO: the xhr event is showing up twice in the log, which is wrong and causing flake. skipping until: https://github.com/cypress-io/cypress/issues/23840 is addressed. @@ -47,7 +48,7 @@ describe('cy.origin - snapshots', () => { // TODO: Since we have two events, one of them does not have a request snapshot - expect(snapshots[1].querySelector(`[data-cy="assertion-header"]`)).to.have.property('innerText').that.equals('Making XHR and Fetch Requests behind the scenes!') + expect(snapshots[1].querySelector(`[data-cy="assertion-header"]`)).to.have.property('innerText').that.equals('Making XHR and Fetch Requests behind the scenes if fireOnload is true!') }) }) @@ -70,7 +71,7 @@ describe('cy.origin - snapshots', () => { const snapshots = xhrLogFromSecondaryOrigin.snapshots.map((snapshot) => snapshot.body.get()[0]) snapshots.forEach((snapshot) => { - expect(snapshot.querySelector(`[data-cy="assertion-header"]`)).to.have.property('innerText').that.equals('Making XHR and Fetch Requests behind the scenes!') + expect(snapshot.querySelector(`[data-cy="assertion-header"]`)).to.have.property('innerText').that.equals('Making XHR and Fetch Requests behind the scenes if fireOnload is true!') }) }) }) diff --git a/packages/driver/cypress/fixtures/primary-origin.html b/packages/driver/cypress/fixtures/primary-origin.html index a4046cde4b67..7d98511e9a10 100644 --- a/packages/driver/cypress/fixtures/primary-origin.html +++ b/packages/driver/cypress/fixtures/primary-origin.html @@ -13,7 +13,8 @@
  • http://www.foobar.com:3500/fixtures/files-form.html
  • http://www.foobar.com:3500/fixtures/errors.html
  • http://www.foobar.com:3500/fixtures/screenshots.html
  • -
  • http://www.foobar.com:3500/fixtures/xhr-fetch-onload.html
  • +
  • http://www.foobar.com:3500/fixtures/xhr-fetch-requests.html onLoad
  • +
  • http://www.foobar.com:3500/fixtures/xhr-fetch-requests.html
  • http://www.foobar.com:3500/fixtures/scripts-with-integrity.html
  • Login with Social
  • Login with Social (https)
  • diff --git a/packages/driver/cypress/fixtures/xhr-fetch-onload.html b/packages/driver/cypress/fixtures/xhr-fetch-onload.html deleted file mode 100644 index 65fa87ea0cc4..000000000000 --- a/packages/driver/cypress/fixtures/xhr-fetch-onload.html +++ /dev/null @@ -1,18 +0,0 @@ - - - -

    Making XHR and Fetch Requests behind the scenes!

    - - - \ No newline at end of file diff --git a/packages/driver/cypress/fixtures/xhr-fetch-requests.html b/packages/driver/cypress/fixtures/xhr-fetch-requests.html new file mode 100644 index 000000000000..0692419a74bd --- /dev/null +++ b/packages/driver/cypress/fixtures/xhr-fetch-requests.html @@ -0,0 +1,109 @@ + + + +

    Making XHR and Fetch Requests behind the scenes if fireOnload is true!

    + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/driver/src/cross-origin/communicator.ts b/packages/driver/src/cross-origin/communicator.ts index a249711cb043..edf377ad1f9f 100644 --- a/packages/driver/src/cross-origin/communicator.ts +++ b/packages/driver/src/cross-origin/communicator.ts @@ -88,7 +88,7 @@ export class PrimaryOriginCommunicator extends EventEmitter { // where we need to set the crossOriginDriverWindows to source to // communicate back to the iframe if (messageName === 'bridge:ready' && source) { - this.crossOriginDriverWindows[data.originPolicy] = source as Window + this.crossOriginDriverWindows[data.superDomainOrigin] = source as Window } // reify any logs coming back from the cross-origin spec bridges to serialize snapshot/consoleProp DOM elements as well as select functions. @@ -105,7 +105,7 @@ export class PrimaryOriginCommunicator extends EventEmitter { data.data.err = reifySerializedError(data.data.err, this.userInvocationStack as string) } - this.emit(messageName, data.data, data.originPolicy, source) + this.emit(messageName, data.data, data.superDomainOrigin, source) return } @@ -137,8 +137,8 @@ export class PrimaryOriginCommunicator extends EventEmitter { }) } - toSpecBridge (originPolicy: string, event: string, data?: any) { - debug('=> to spec bridge', originPolicy, event, data) + toSpecBridge (superDomainOrigin: string, event: string, data?: any) { + debug('=> to spec bridge', superDomainOrigin, event, data) const preprocessedData = preprocessForSerialization(data) @@ -148,7 +148,7 @@ export class PrimaryOriginCommunicator extends EventEmitter { } // If there is no crossOriginDriverWindows, there is no need to send the message. - this.crossOriginDriverWindows[originPolicy]?.postMessage({ + this.crossOriginDriverWindows[superDomainOrigin]?.postMessage({ event, data: preprocessedData, }, '*') @@ -161,18 +161,18 @@ export class PrimaryOriginCommunicator extends EventEmitter { * @param options - contains boolean to sync globals * @returns the response from primary of the event with the same name. */ - toSpecBridgePromise (originPolicy: string, event: string, data?: any) { + toSpecBridgePromise (superDomainOrigin: string, event: string, data?: any) { return new Promise((resolve, reject) => { const dataToSend = sharedPromiseSetup({ resolve, reject, data, event, - specBridgeName: originPolicy, + specBridgeName: superDomainOrigin, communicator: this, }) - this.toSpecBridge(originPolicy, event, dataToSend) + this.toSpecBridge(superDomainOrigin, event, dataToSend) }) } } @@ -251,7 +251,7 @@ export class SpecBridgeCommunicator extends EventEmitter { * @param {Cypress.ObjectLike} data - any meta data to be sent with the event. */ toPrimary (event: string, data?: Cypress.ObjectLike, options: { syncGlobals: boolean } = { syncGlobals: false }) { - const { originPolicy } = $Location.create(window.location.href) + const { superDomainOrigin } = $Location.create(window.location.href) const eventName = `${CROSS_ORIGIN_PREFIX}${event}` // Preprocess logs before sending through postMessage() to attempt to serialize some DOM nodes and functions. @@ -265,14 +265,14 @@ export class SpecBridgeCommunicator extends EventEmitter { data = preprocessSnapshotForSerialization(data as any) } - debug('<= to Primary ', event, data, originPolicy) + debug('<= to Primary ', event, data, superDomainOrigin) if (options.syncGlobals) this.syncGlobalsToPrimary() this.handleSubjectAndErr(data, (data: Cypress.ObjectLike) => { window.top?.postMessage({ event: eventName, data, - originPolicy, + superDomainOrigin, }, '*') }) } diff --git a/packages/driver/src/cross-origin/cypress.ts b/packages/driver/src/cross-origin/cypress.ts index 79a0e1c72ef0..f5164d402ca5 100644 --- a/packages/driver/src/cross-origin/cypress.ts +++ b/packages/driver/src/cross-origin/cypress.ts @@ -21,6 +21,8 @@ import { handleTestEvents } from './events/test' import { handleMiscEvents } from './events/misc' import { handleUnsupportedAPIs } from './unsupported_apis' import { patchFormElementSubmit } from './patches/submit' +import { patchFetch } from './patches/fetch' +import { patchXmlHttpRequest } from './patches/xmlHttpRequest' import $errUtils from '../cypress/error_utils' import $Mocha from '../cypress/mocha' import * as cors from '@packages/network/lib/cors' @@ -42,8 +44,8 @@ const createCypress = () => { const frame = window.parent.frames[index] try { - // the AUT would be the frame with a matching origin, but not the same exact href. - if (window.location.origin === cors.getOriginPolicy(frame.location.origin) + // the AUT would be the frame with a matching super domain origin, but not the same exact href. + if (window.location.origin === cors.getSuperDomainOrigin(frame.location.origin) && window.location.href !== frame.location.href) { return frame } @@ -66,10 +68,10 @@ const createCypress = () => { }) Cypress.specBridgeCommunicator.on('generate:final:snapshot', (snapshotUrl: string) => { - const currentAutOriginPolicy = cy.state('autLocation').originPolicy + const currentAutSuperDomainOrigin = cy.state('autLocation').superDomainOrigin const requestedSnapshotUrlLocation = $Location.create(snapshotUrl) - if (requestedSnapshotUrlLocation.originPolicy === currentAutOriginPolicy) { + if (requestedSnapshotUrlLocation.superDomainOrigin === currentAutSuperDomainOrigin) { // if true, this is the correct spec bridge to take the snapshot and send it back const finalSnapshot = cy.createSnapshot(FINAL_SNAPSHOT_NAME) @@ -183,6 +185,15 @@ const attachToWindow = (autWindow: Window) => { cy.overrides.wrapNativeMethods(autWindow) + // place after override incase fetch is polyfilled in the AUT injection + // this can be in the beforeLoad code as we only want to patch fetch/xmlHttpRequest + // when the cy.origin block is active to track credential use + patchFetch(Cypress, autWindow) + patchXmlHttpRequest(Cypress, autWindow) + // also patch it in the spec bridge as well + patchFetch(Cypress, window) + patchXmlHttpRequest(Cypress, window) + // TODO: DRY this up with the mostly-the-same code in src/cypress/cy.js // https://github.com/cypress-io/cypress/issues/20972 bindToListeners(autWindow, { diff --git a/packages/driver/src/cross-origin/events/misc.ts b/packages/driver/src/cross-origin/events/misc.ts index 529969470c5d..585e8e2c28f1 100644 --- a/packages/driver/src/cross-origin/events/misc.ts +++ b/packages/driver/src/cross-origin/events/misc.ts @@ -24,7 +24,7 @@ export const handleMiscEvents = (Cypress: Cypress.Cypress, cy: $Cy) => { // Listen for any unload events in other origins, if any have unloaded we should also become unstable. Cypress.specBridgeCommunicator.on('before:unload', (origin) => { // If the unload event originated from this spec bridge, isStable is already being handled. - if (window.location.origin !== cors.getOriginPolicy(origin)) { + if (window.location.origin !== cors.getSuperDomainOrigin(origin)) { cy.state('isStable', false) } }) diff --git a/packages/driver/src/cross-origin/patches/fetch.ts b/packages/driver/src/cross-origin/patches/fetch.ts new file mode 100644 index 000000000000..28596d35d420 --- /dev/null +++ b/packages/driver/src/cross-origin/patches/fetch.ts @@ -0,0 +1,51 @@ +import { captureFullRequestUrl } from './utils' + +export const patchFetch = (Cypress: Cypress.Cypress, window) => { + // if fetch is available in the browser, or is polyfilled by whatwg fetch + // intercept method calls and add cypress headers to determine cookie applications in the proxy + // for simulated top. @see https://github.github.io/fetch/ for default options + if (!Cypress.config('experimentalSessionAndOrigin') || !window.fetch) { + return + } + + const originalFetch = window.fetch + + window.fetch = function (...args) { + try { + let url: string | undefined = undefined + let credentials: string | undefined = undefined + + const resource = args[0] + + // @see https://developer.mozilla.org/en-US/docs/Web/API/fetch#parameters for fetch resource options. We will only support Request, URL, and strings + if (resource instanceof window.Request) { + ({ url, credentials } = resource) + } else if (resource instanceof window.URL) { + // should be a no-op for URL + url = resource.toString() + + ;({ credentials } = args[1] || {}) + } else if (Cypress._.isString(resource)) { + url = captureFullRequestUrl(resource, window) + + ;({ credentials } = args[1] || {}) + } + + credentials = credentials || 'same-origin' + // if the option is specified, communicate it to the the server to the proxy can make the request aware if it needs to potentially apply cross origin cookies + // if the option isn't set, we can imply the default as we know the resource type in the proxy + if (url) { + // @ts-expect-error + Cypress.backend('request:sent:with:credentials', { + // TODO: might need to go off more information here or at least make collisions less likely + url, + resourceType: 'fetch', + credentialStatus: credentials, + }) + } + } finally { + // if our internal logic errors for whatever reason, do NOT block the end user and continue the request + return originalFetch.apply(this, args) + } + } +} diff --git a/packages/driver/src/cross-origin/patches/utils/index.ts b/packages/driver/src/cross-origin/patches/utils/index.ts new file mode 100644 index 000000000000..cb3d5aacc83c --- /dev/null +++ b/packages/driver/src/cross-origin/patches/utils/index.ts @@ -0,0 +1,17 @@ +export const captureFullRequestUrl = (relativeOrAbsoluteUrlString: string, window: Window) => { + // need to pass the window here by reference to generate the correct absolute URL if needed. Spec Bridge does NOT contain sub domain + let url + + try { + url = new URL(relativeOrAbsoluteUrlString).toString() + } catch (err1) { + try { + // likely a relative path, construct the full url + url = new URL(relativeOrAbsoluteUrlString, window.location.origin).toString() + } catch (err2) { + return undefined + } + } + + return url +} diff --git a/packages/driver/src/cross-origin/patches/xmlHttpRequest.ts b/packages/driver/src/cross-origin/patches/xmlHttpRequest.ts new file mode 100644 index 000000000000..92cb07b204b5 --- /dev/null +++ b/packages/driver/src/cross-origin/patches/xmlHttpRequest.ts @@ -0,0 +1,42 @@ +import { captureFullRequestUrl } from './utils' + +export const patchXmlHttpRequest = (Cypress: Cypress.Cypress, window: Window) => { + // intercept method calls and add cypress headers to determine cookie applications in the proxy + // for simulated top + + if (!Cypress.config('experimentalSessionAndOrigin')) { + return + } + + const originalXmlHttpRequestOpen = window.XMLHttpRequest.prototype.open + const originalXmlHttpRequestSend = window.XMLHttpRequest.prototype.send + + window.XMLHttpRequest.prototype.open = function (...args) { + try { + // since the send method does NOT have access to the arguments passed into open or have the request information, + // we need to store a reference here to what we need in the send method + this._url = captureFullRequestUrl(args[1], window) + } finally { + return originalXmlHttpRequestOpen.apply(this, args as any) + } + } + + window.XMLHttpRequest.prototype.send = function (...args) { + try { + // if the option is specified, communicate it to the the server to the proxy can make the request aware if it needs to potentially apply cross origin cookies + // if the option isn't set, we can imply the default as we know the resource type in the proxy + if (this._url) { + // @ts-expect-error + Cypress.backend('request:sent:with:credentials', { + // TODO: might need to go off more information here or at least make collisions less likely + url: this._url, + resourceType: 'xhr', + credentialStatus: this.withCredentials, + }) + } + } finally { + // if our internal logic errors for whatever reason, do NOT block the end user and continue the request + return originalXmlHttpRequestSend.apply(this, args) + } + } +} diff --git a/packages/driver/src/cy/commands/navigation.ts b/packages/driver/src/cy/commands/navigation.ts index e69c740a975f..9ee3dd204f22 100644 --- a/packages/driver/src/cy/commands/navigation.ts +++ b/packages/driver/src/cy/commands/navigation.ts @@ -312,7 +312,7 @@ const stabilityChanged = async (Cypress, state, config, stable) => { const onPageLoadErr = (err) => { state('onPageLoadErr', null) - const { originPolicy } = $Location.create(window.location.href) + const { superDomainOrigin } = $Location.create(window.location.href) try { $errUtils.throwErrByPath('navigation.cross_origin', { @@ -320,7 +320,7 @@ const stabilityChanged = async (Cypress, state, config, stable) => { args: { configFile: Cypress.config('configFile'), message: err.message, - originPolicy, + superDomainOrigin, }, }) } catch (error) { @@ -1084,10 +1084,11 @@ export default (Commands, Cypress, cy, state, config) => { remote = $Location.create(url) - // if the origin currently matches + // if the super domain origin currently matches // or if we have previously visited a location or are a spec bridge // then go ahead and change the iframe's src - if (remote.originPolicy === existing.originPolicy + // we use the superDomainOrigin policy as we can interact with subdomains based document.domain set to the superdomain + if (remote.superDomainOrigin === existing.superDomainOrigin || ((previouslyVisitedLocation || Cypress.isCrossOriginSpecBridge) && Cypress.config('experimentalSessionAndOrigin')) ) { if (!previouslyVisitedLocation) { diff --git a/packages/driver/src/cy/commands/origin/index.ts b/packages/driver/src/cy/commands/origin/index.ts index f95d091563d0..c4f1f2050d90 100644 --- a/packages/driver/src/cy/commands/origin/index.ts +++ b/packages/driver/src/cy/commands/origin/index.ts @@ -85,16 +85,14 @@ export default (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy, state: State validator.validateLocation(location, urlOrDomain) - const originPolicy = location.originPolicy + const superDomainOrigin = location.superDomainOrigin - // This is intentionally not reset after leaving the cy.origin command. - cy.state('latestActiveOriginPolicy', originPolicy) // This is set while IN the cy.origin command. - cy.state('currentActiveOriginPolicy', originPolicy) + cy.state('currentActiveSuperDomainOrigin', superDomainOrigin) return new Bluebird((resolve, reject, onCancel) => { const cleanup = ({ readyForOriginFailed }: {readyForOriginFailed?: boolean} = {}): void => { - cy.state('currentActiveOriginPolicy', undefined) + cy.state('currentActiveSuperDomainOrigin', undefined) communicator.off('queue:finished', onQueueFinished) communicator.off('sync:globals', onSyncGlobals) @@ -170,23 +168,23 @@ export default (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy, state: State } // fired once the spec bridge is set up and ready to receive messages - communicator.once('bridge:ready', async (_data, specBridgeOriginPolicy) => { - if (specBridgeOriginPolicy === originPolicy) { + communicator.once('bridge:ready', async (_data, specBridgeSuperDomainOrigin) => { + if (specBridgeSuperDomainOrigin === superDomainOrigin) { // now that the spec bridge is ready, instantiate Cypress with the current app config and environment variables for initial sync when creating the instance - communicator.toSpecBridge(originPolicy, 'initialize:cypress', { + communicator.toSpecBridge(superDomainOrigin, 'initialize:cypress', { config: preprocessConfig(Cypress.config()), env: preprocessEnv(Cypress.env()), }) // Attach the spec bridge to the window to be tested. - communicator.toSpecBridge(originPolicy, 'attach:to:window') + communicator.toSpecBridge(superDomainOrigin, 'attach:to:window') const fn = _.isFunction(callbackFn) ? callbackFn.toString() : callbackFn // once the secondary origin page loads, send along the // user-specified callback to run in that origin try { - communicator.toSpecBridge(originPolicy, 'run:origin:fn', { + communicator.toSpecBridge(superDomainOrigin, 'run:origin:fn', { args: options?.args || undefined, fn, // let the spec bridge version of Cypress know if config read-only values can be overwritten since window.top cannot be accessed in cross-origin iframes diff --git a/packages/driver/src/cy/commands/waiting.ts b/packages/driver/src/cy/commands/waiting.ts index 1c0a4873b2f8..82bbd4dda7d8 100644 --- a/packages/driver/src/cy/commands/waiting.ts +++ b/packages/driver/src/cy/commands/waiting.ts @@ -282,14 +282,14 @@ export default (Commands, Cypress, cy, state) => { }) } - Cypress.primaryOriginCommunicator.on('wait:for:xhr', ({ args: [str, options] }, originPolicy) => { + Cypress.primaryOriginCommunicator.on('wait:for:xhr', ({ args: [str, options] }, superDomainOrigin) => { options.isCrossOriginSpecBridge = true waitString(null, str, options).then((responses) => { - Cypress.primaryOriginCommunicator.toSpecBridge(originPolicy, 'wait:for:xhr:end', responses) + Cypress.primaryOriginCommunicator.toSpecBridge(superDomainOrigin, 'wait:for:xhr:end', responses) }).catch((err) => { options._log?.error(err) err.hasSpecBridgeError = true - Cypress.primaryOriginCommunicator.toSpecBridge(originPolicy, 'wait:for:xhr:end', err) + Cypress.primaryOriginCommunicator.toSpecBridge(superDomainOrigin, 'wait:for:xhr:end', err) }) }) diff --git a/packages/driver/src/cy/ensures.ts b/packages/driver/src/cy/ensures.ts index 4c59bc2ad209..7d6c5e8dbd02 100644 --- a/packages/driver/src/cy/ensures.ts +++ b/packages/driver/src/cy/ensures.ts @@ -395,7 +395,7 @@ export const create = (state: StateFunc, expect: $Cy['expect']) => { if (!isRunnerAbleToCommunicateWithAut()) { const crossOriginCommandError = $errUtils.errByPath('miscellaneous.cross_origin_command', { commandOrigin: window.location.origin, - autOrigin: state('autLocation').originPolicy, + autSuperDomainOrigin: state('autLocation').superDomainOrigin, }) if (err) { diff --git a/packages/driver/src/cypress/error_messages.ts b/packages/driver/src/cypress/error_messages.ts index c223a190be70..4911ed578204 100644 --- a/packages/driver/src/cypress/error_messages.ts +++ b/packages/driver/src/cypress/error_messages.ts @@ -914,9 +914,9 @@ export default { return `Timed out retrying after ${ms}ms: ` }, test_stopped: 'Cypress test was stopped while running this command.', - cross_origin_command ({ commandOrigin, autOrigin }) { + cross_origin_command ({ commandOrigin, autSuperDomainOrigin }) { return stripIndent`\ - The command was expected to run against origin \`${commandOrigin }\` but the application is at origin \`${autOrigin}\`. + The command was expected to run against origin \`${commandOrigin }\` but the application is at origin \`${autSuperDomainOrigin}\`. This commonly happens when you have either not navigated to the expected origin or have navigated away unexpectedly.` }, @@ -980,7 +980,7 @@ export default { }, navigation: { - cross_origin ({ message, originPolicy, configFile, projectRoot }) { + cross_origin ({ message, superDomainOrigin, configFile, projectRoot }) { return { message: stripIndent`\ Cypress detected a cross origin error happened on page load: @@ -989,7 +989,7 @@ export default { Before the page load, you were bound to the origin policy: - > ${originPolicy} + > ${superDomainOrigin} A cross origin error happens when your application navigates to a new URL which does not match the origin policy above. @@ -1259,7 +1259,7 @@ export default { This error was thrown by a cross origin page. If you wish to suppress this error you will have to use the cy.origin command to handle the error prior to visiting the page. - \`cy.origin('${autLocation.originPolicy}', () => {\` + \`cy.origin('${autLocation.superDomainOrigin}', () => {\` \` cy.on('uncaught:exception', (e) => {\` \` if (e.message.includes('Things went bad')) {\` \` // we expected this error, so let's ignore it\` @@ -2177,7 +2177,7 @@ export default { message: stripIndent`${cmd('visit')} was called to visit a cross origin site with an \`onLoad\` callback. \`onLoad\` callbacks can only be used with same origin sites. If you wish to specify an \`onLoad\` callback please use the \`cy.origin\` command to setup a \`window:load\` event prior to visiting the cross origin site. - \`cy.origin('${args.autLocation.originPolicy}', () => {\` + \`cy.origin('${args.autLocation.superDomainOrigin}', () => {\` \` cy.on('window:load', () => {\` \` \` \` })\` @@ -2192,7 +2192,7 @@ export default { message: stripIndent`${cmd('visit')} was called to visit a cross origin site with an \`onBeforeLoad\` callback. \`onBeforeLoad\` callbacks can only be used with same origin sites. If you wish to specify an \`onBeforeLoad\` callback please use the \`cy.origin\` command to setup a \`window:before:load\` event prior to visiting the cross origin site. - \`cy.origin('${args.autLocation.originPolicy}', () => {\` + \`cy.origin('${args.autLocation.superDomainOrigin}', () => {\` \` cy.on('window:before:load', () => {\` \` \` \` })\` @@ -2217,7 +2217,7 @@ export default { ${args.experimentalSessionAndOrigin ? `You likely forgot to use ${cmd('origin')}:` : `In order to visit a different origin, you can enable the \`experimentalSessionAndOrigin\` flag and use ${cmd('origin')}:` } ${args.isCrossOriginSpecBridge ? - `\`cy.origin('${args.previousUrl.originPolicy}', () => {\` + `\`cy.origin('${args.previousUrl.superDomainOrigin}', () => {\` \` cy.visit('${args.previousUrl}')\` \` \` \`})\`` : @@ -2225,7 +2225,7 @@ export default { \`\`` } - \`cy.origin('${args.attemptedUrl.originPolicy}', () => {\` + \`cy.origin('${args.attemptedUrl.superDomainOrigin}', () => {\` \` cy.visit('${args.originalUrl}')\` \` \` \`})\` diff --git a/packages/driver/src/cypress/location.ts b/packages/driver/src/cypress/location.ts index 50a24e0e5a19..41fbf61761d3 100644 --- a/packages/driver/src/cypress/location.ts +++ b/packages/driver/src/cypress/location.ts @@ -28,7 +28,7 @@ export interface LocationObject { port: number protocol: string search: string - originPolicy: string + superDomainOrigin: string superDomain: string toString: () => string } @@ -80,15 +80,6 @@ export class $Location { return this.remote.hostname } - getOrigin () { - // https://github.com/unshiftio/url-parse/issues/38 - if (this.remote.origin === 'null') { - return null - } - - return this.remote.origin - } - getProtocol () { return this.remote.protocol } @@ -105,8 +96,17 @@ export class $Location { return this.remote.query } - getOriginPolicy () { - return cors.getOriginPolicy(this.remote.href) + getOrigin () { + // https://github.com/unshiftio/url-parse/issues/38 + if (this.remote.origin === 'null') { + return null + } + + return this.remote.origin + } + + getSuperDomainOrigin () { + return cors.getSuperDomainOrigin(this.remote.href) } getSuperDomain () { @@ -130,7 +130,7 @@ export class $Location { port: this.getPort(), protocol: this.getProtocol(), search: this.getSearch(), - originPolicy: this.getOriginPolicy(), + superDomainOrigin: this.getSuperDomainOrigin(), superDomain: this.getSuperDomain(), toString: _.bind(this.getToString, this), } diff --git a/packages/driver/src/cypress/log.ts b/packages/driver/src/cypress/log.ts index 63b9de52ace7..5b356188bd92 100644 --- a/packages/driver/src/cypress/log.ts +++ b/packages/driver/src/cypress/log.ts @@ -374,16 +374,16 @@ export class Log { } if (this.config('experimentalSessionAndOrigin') && !Cypress.isCrossOriginSpecBridge) { - const activeSpecBridgeOriginPolicyIfApplicable = this.state('currentActiveOriginPolicy') || undefined + const activeSpecBridgeSuperDomainOriginIfApplicable = this.state('currentActiveSuperDomainOrigin') || undefined // @ts-ignore - const { originPolicy: originPolicyThatIsSoonToBeOrIsActive } = Cypress.Location.create(this.state('anticipatingCrossOriginResponse')?.href || this.state('url')) + const { superDomainOrigin: superDomainOriginThatIsSoonToBeOrIsActive } = Cypress.Location.create(this.state('url')) - if (activeSpecBridgeOriginPolicyIfApplicable && activeSpecBridgeOriginPolicyIfApplicable === originPolicyThatIsSoonToBeOrIsActive) { + if (activeSpecBridgeSuperDomainOriginIfApplicable && activeSpecBridgeSuperDomainOriginIfApplicable === superDomainOriginThatIsSoonToBeOrIsActive) { Cypress.emit('request:snapshot:from:spec:bridge', { log: this, name, options, - specBridge: activeSpecBridgeOriginPolicyIfApplicable, + specBridge: activeSpecBridgeSuperDomainOriginIfApplicable, addSnapshot: this.addSnapshot, }) diff --git a/packages/driver/src/cypress/state.ts b/packages/driver/src/cypress/state.ts index cd2492578238..5a438139d273 100644 --- a/packages/driver/src/cypress/state.ts +++ b/packages/driver/src/cypress/state.ts @@ -23,8 +23,7 @@ export interface StateFunc { (k: 'logGroupIds', v?: Array): Array (k: 'autLocation', v?: LocationObject): LocationObject (k: 'originCommandBaseUrl', v?: string): string - (k: 'currentActiveOriginPolicy', v?: string): string - (k: 'latestActiveOriginPolicy', v?: string): string + (k: 'currentActiveSuperDomainOrigin', v?: string): string (k: 'duringUserTestExecution', v?: boolean): boolean (k: 'onQueueEnd', v?: () => void): () => void (k: 'onFail', v?: (err: Error) => void): (err: Error) => void diff --git a/packages/extension/app/background.js b/packages/extension/app/background.js index ed642603b251..b086d9efbfb3 100644 --- a/packages/extension/app/background.js +++ b/packages/extension/app/background.js @@ -61,6 +61,23 @@ const connect = function (host, path, extraOpts) { // adds a header to the request to mark it as a request for the AUT frame // itself, so the proxy can utilize that for injection purposes browser.webRequest.onBeforeSendHeaders.addListener((details) => { + const requestModifications = { + requestHeaders: [ + ...(details.requestHeaders || []), + /** + * Unlike CDP, the web extensions onBeforeSendHeaders resourceType cannot discern the difference + * between fetch or xhr resource types, but classifies both as 'xmlhttprequest'. Because of this, + * we set X-Cypress-Is-XHR-Or-Fetch to true if the request is made with 'xhr' or 'fetch' so the + * middleware doesn't incorrectly assume which request type is being sent + * @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/webRequest/ResourceType + */ + ...(details.type === 'xmlhttprequest' ? [{ + name: 'X-Cypress-Is-XHR-Or-Fetch', + value: 'true', + }] : []), + ], + } + if ( // parentFrameId: 0 means the parent is the top-level, so if it isn't // 0, it's nested inside the AUT and can't be the AUT itself @@ -69,11 +86,11 @@ const connect = function (host, path, extraOpts) { || details.type !== 'sub_frame' // is the spec frame, not the AUT || details.url.includes('__cypress') - ) return + ) return requestModifications return { requestHeaders: [ - ...details.requestHeaders, + ...requestModifications.requestHeaders, { name: 'X-Cypress-Is-AUT-Frame', value: 'true', diff --git a/packages/extension/test/integration/background_spec.js b/packages/extension/test/integration/background_spec.js index 494cd65bc8db..8ced8091df83 100644 --- a/packages/extension/test/integration/background_spec.js +++ b/packages/extension/test/integration/background_spec.js @@ -297,7 +297,7 @@ describe('app/background', () => { const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) - expect(result).to.be.undefined + expect(result).to.deep.equal({ requestHeaders: [] }) }) it('does not add header if it is a nested frame', async function () { @@ -311,7 +311,7 @@ describe('app/background', () => { const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) - expect(result).to.be.undefined + expect(result).to.deep.equal({ requestHeaders: [] }) }) it('does not add header if it is not a sub frame request', async function () { @@ -326,7 +326,7 @@ describe('app/background', () => { const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) - expect(result).to.be.undefined + expect(result).to.deep.equal({ requestHeaders: [] }) }) it('does not add header if it is a spec frame request', async function () { @@ -341,7 +341,7 @@ describe('app/background', () => { await this.connect(withExperimentalFlagOn) const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) - expect(result).to.be.undefined + expect(result).to.deep.equal({ requestHeaders: [] }) }) it('appends X-Cypress-Is-AUT-Frame header to AUT iframe request', async function () { @@ -373,6 +373,60 @@ describe('app/background', () => { }) }) + it('appends X-Cypress-Is-XHR-Or-Fetch header to request if the resourceType is "xmlhttprequest"', async function () { + const details = { + parentFrameId: 0, + type: 'xmlhttprequest', + url: 'http://localhost:3000/index.html', + requestHeaders: [ + { name: 'X-Foo', value: 'Bar' }, + ], + } + + sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener') + + await this.connect(withExperimentalFlagOn) + const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) + + expect(result).to.deep.equal({ + requestHeaders: [ + { + name: 'X-Foo', + value: 'Bar', + }, + { + name: 'X-Cypress-Is-XHR-Or-Fetch', + value: 'true', + }, + ], + }) + }) + + it('does not append X-Cypress-Is-XHR-Or-Fetch header to request if the resourceType is not an "xmlhttprequest"', async function () { + const details = { + parentFrameId: 0, + type: 'sub_frame', + url: 'http://localhost:3000/index.html', + requestHeaders: [ + { name: 'X-Foo', value: 'Bar' }, + ], + } + + sinon.stub(browser.webRequest.onBeforeSendHeaders, 'addListener') + + await this.connect(withExperimentalFlagOn) + const result = browser.webRequest.onBeforeSendHeaders.addListener.lastCall.args[0](details) + + expect(result).to.not.deep.equal({ + requestHeaders: [ + { + name: 'X-Cypress-Is-XHR-Or-Fetch', + value: 'true', + }, + ], + }) + }) + it('does not add before-headers listener if in non-Firefox browser', async function () { browser.runtime.getBrowserInfo = undefined diff --git a/packages/network/lib/cors.ts b/packages/network/lib/cors.ts index 00adc5b15647..08abc5363149 100644 --- a/packages/network/lib/cors.ts +++ b/packages/network/lib/cors.ts @@ -1,8 +1,10 @@ import _ from 'lodash' import * as uri from './uri' import debugModule from 'debug' -import _parseDomain, { ParsedDomain } from '@cypress/parse-domain' -import type { ParsedHost } from './types' +import _parseDomain from '@cypress/parse-domain' +import type { ParsedHost, ParsedHostWithProtocolAndHost } from './types' + +type Policy = 'same-origin' | 'same-super-domain-origin' | 'schemeful-same-site' const debug = debugModule('cypress:network:cors') @@ -10,7 +12,7 @@ const debug = debugModule('cypress:network:cors') const customTldsRe = /(^[\d\.]+$|\.[^\.]+$)/ export function getSuperDomain (url) { - const parsed = parseUrlIntoDomainTldPort(url) + const parsed = parseUrlIntoHostProtocolDomainTldPort(url) return _.compact([parsed.domain, parsed.tld]).join('.') } @@ -22,7 +24,7 @@ export function parseDomain (domain: string, options = {}) { })) } -export function parseUrlIntoDomainTldPort (str) { +export function parseUrlIntoHostProtocolDomainTldPort (str) { let { hostname, port, protocol } = uri.parse(str) if (!hostname) { @@ -33,7 +35,7 @@ export function parseUrlIntoDomainTldPort (str) { port = protocol === 'https:' ? '443' : '80' } - let parsed: Partial | null = parseDomain(hostname) + let parsed: Partial | null = parseDomain(hostname) // if we couldn't get a parsed domain if (!parsed) { @@ -45,16 +47,19 @@ export function parseUrlIntoDomainTldPort (str) { const segments = hostname.split('.') parsed = { + subdomain: segments[segments.length - 3] || '', tld: segments[segments.length - 1] || '', domain: segments[segments.length - 2] || '', } } - const obj: ParsedHost = {} - - obj.port = port - obj.tld = parsed.tld - obj.domain = parsed.domain + const obj: ParsedHostWithProtocolAndHost = { + port, + protocol, + subdomain: parsed.subdomain || null, + domain: parsed.domain, + tld: parsed.tld, + } debug('Parsed URL %o', obj) @@ -62,7 +67,7 @@ export function parseUrlIntoDomainTldPort (str) { } export function getDomainNameFromUrl (url: string) { - const parsedHost = parseUrlIntoDomainTldPort(url) + const parsedHost = parseUrlIntoHostProtocolDomainTldPort(url) return getDomainNameFromParsedHost(parsedHost) } @@ -71,26 +76,116 @@ export function getDomainNameFromParsedHost (parsedHost: ParsedHost) { return _.compact([parsedHost.domain, parsedHost.tld]).join('.') } -export function urlMatchesOriginPolicyProps (urlStr, props) { - // take a shortcut here in the case - // where remoteHostAndPort is null - if (!props) { +/** + * same-origin: Whether or not a a urls scheme, port, and host match. @see https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy + * same-super-domain-origin: Whether or not a url's scheme, domain, top-level domain, and port match + * same-site: Whether or not a url's scheme, domain, and top-level domain match. @see https://developer.mozilla.org/en-US/docs/Glossary/Site + * @param {Policy} policy - the policy being used + * @param {string} url - the url being compared + * @param {ParsedHostWithProtocolAndHost} props - the props being compared against the url + * @returns {boolean} whether or not the props and url fit the policy + */ +function urlMatchesPolicyProps ({ policy, url, props }: { + policy: Policy + url: string + props: ParsedHostWithProtocolAndHost +}): boolean { + if (!policy || !url || !props) { return false } - const parsedUrl = parseUrlIntoDomainTldPort(urlStr) + const urlProps = parseUrlIntoHostProtocolDomainTldPort(url) - // does the parsedUrl match the parsedHost? - return _.isEqual(parsedUrl, props) + switch (policy) { + case 'same-origin': { + // if same origin, all parts of the props needs to match, including subdomain and scheme + return _.isEqual(urlProps, props) + } + case 'same-super-domain-origin': + case 'schemeful-same-site': { + const { port: port1, subdomain: _unused1, ...parsedUrl } = urlProps + const { port: port2, subdomain: _unused2, ...relevantProps } = props + + let doPortsPassSameSchemeCheck: boolean + + if (policy === 'same-super-domain-origin') { + // if a super domain origin comparison, the ports MUST be strictly equal + doPortsPassSameSchemeCheck = port1 === port2 + } else { + // otherwise, this is a same-site comparison + // If HTTPS, ports NEED to match. Otherwise, HTTP ports can be different and are same origin + doPortsPassSameSchemeCheck = port1 !== port2 ? (port1 !== '443' && port2 !== '443') : true + } + + return doPortsPassSameSchemeCheck && _.isEqual(parsedUrl, relevantProps) + } + default: + return false + } } -export function urlOriginsMatch (url1, url2) { - if (!url1 || !url2) return false +function urlMatchesPolicy ({ policy, url1, url2 }: { + policy: Policy + url1: string + url2: string +}): boolean { + if (!policy || !url1 || !url2) { + return false + } + + return urlMatchesPolicyProps({ + policy, + url: url1, + props: parseUrlIntoHostProtocolDomainTldPort(url2), + }) +} - const parsedUrl1 = parseUrlIntoDomainTldPort(url1) - const parsedUrl2 = parseUrlIntoDomainTldPort(url2) +export function urlMatchesOriginProps (url, props) { + return urlMatchesPolicyProps({ + policy: 'same-origin', + url, + props, + }) +} + +export function urlMatchesSuperDomainOriginProps (url, props) { + return urlMatchesPolicyProps({ + policy: 'same-super-domain-origin', + url, + props, + }) +} - return _.isEqual(parsedUrl1, parsedUrl2) +export function urlMatchesSameSiteProps (url: string, props: ParsedHostWithProtocolAndHost) { + return urlMatchesPolicyProps({ + policy: 'schemeful-same-site', + url, + props, + }) +} + +export function urlOriginsMatch (url1: string, url2: string) { + return urlMatchesPolicy({ + policy: 'same-origin', + url1, + url2, + }) +} + +export function urlsSuperDomainOriginMatch (url1: string, url2: string) { + return urlMatchesPolicy({ + policy: 'same-super-domain-origin', + url1, + url2, + }) +} + +export const urlSameSiteMatch = (url1: string, url2: string) => { + return urlMatchesPolicy({ + policy: 'schemeful-same-site', + url1, + url2, + }) } declare module 'url' { @@ -106,11 +201,27 @@ export function urlMatchesOriginProtectionSpace (urlStr, origin) { return _.startsWith(normalizedUrl, normalizedOrigin) } -export function getOriginPolicy (url: string) { +export function getOrigin (url: string) { // @ts-ignore - const { port, protocol } = new URL(url) + const { origin } = new URL(url) // origin policy is comprised of: + // protocol + subdomain + superdomain + port + return origin +} + +/** + * We use the super domain origin policy in the driver to determine whether or not we need to reload/interact with the AUT, and + * currently in the spec bridge to interact with the AUT frame, which uses document.domain set to the super domain + * @param url - the full absolute url + * @returns the super domain origin policy - + * ex: http://www.example.com:8081/my/path -> http://example.com:8081/my/path + */ +export function getSuperDomainOrigin (url: string) { + // @ts-ignore + const { port, protocol } = new URL(url) + + // super domain origin policy is comprised of: // protocol + superdomain + port (subdomain is not factored in) return _.compact([`${protocol}//${getSuperDomain(url)}`, port]).join(':') } diff --git a/packages/network/lib/types.ts b/packages/network/lib/types.ts index c3cf8a2736dc..8466a9aa73b1 100644 --- a/packages/network/lib/types.ts +++ b/packages/network/lib/types.ts @@ -3,3 +3,8 @@ export type ParsedHost = { tld?: string domain?: string } + +export type ParsedHostWithProtocolAndHost = { + subdomain: string | null + protocol: string | null +} & ParsedHost diff --git a/packages/network/test/unit/cors_spec.ts b/packages/network/test/unit/cors_spec.ts index e3e475c2057c..8ed22d55cca3 100644 --- a/packages/network/test/unit/cors_spec.ts +++ b/packages/network/test/unit/cors_spec.ts @@ -2,9 +2,9 @@ import { cors } from '../../lib' import { expect } from 'chai' describe('lib/cors', () => { - context('.parseUrlIntoDomainTldPort', () => { + context('.parseUrlIntoHostProtocolDomainTldPort', () => { const expectUrlToBeParsedCorrectly = (url, obj) => { - expect(cors.parseUrlIntoDomainTldPort(url)).to.deep.eq(obj) + expect(cors.parseUrlIntoHostProtocolDomainTldPort(url)).to.deep.eq(obj) } it('parses https://www.google.com', function () { @@ -12,6 +12,8 @@ describe('lib/cors', () => { port: '443', domain: 'google', tld: 'com', + subdomain: 'www', + protocol: 'https:', }) }) @@ -20,6 +22,8 @@ describe('lib/cors', () => { port: '8080', domain: '', tld: 'localhost', + subdomain: null, + protocol: 'http:', }) }) @@ -28,6 +32,8 @@ describe('lib/cors', () => { port: '8080', domain: 'app', tld: 'localhost', + subdomain: null, + protocol: 'http:', }) }) @@ -36,6 +42,8 @@ describe('lib/cors', () => { port: '8080', domain: 'localhost', tld: 'dev', + subdomain: 'app', + protocol: 'http:', }) }) @@ -44,6 +52,8 @@ describe('lib/cors', () => { port: '8080', domain: 'app', tld: 'local', + subdomain: null, + protocol: 'http:', }) }) @@ -53,6 +63,8 @@ describe('lib/cors', () => { port: '443', domain: 'example', tld: 'herokuapp.com', + subdomain: null, + protocol: 'https:', }) }) @@ -61,15 +73,18 @@ describe('lib/cors', () => { port: '80', domain: 'local', tld: 'nl', + subdomain: 'www', + protocol: 'http:', }) }) - // https://github.com/cypress-io/cypress/issues/3717 it('parses http://dev.classea12.beta.gouv.fr', function () { expectUrlToBeParsedCorrectly('http://dev.classea12.beta.gouv.fr', { port: '80', domain: 'beta', tld: 'gouv.fr', + subdomain: 'dev.classea12', + protocol: 'http:', }) }) @@ -78,6 +93,8 @@ describe('lib/cors', () => { port: '8080', domain: 'local', tld: 'nl', + subdomain: 'www', + protocol: 'http:', }) }) @@ -86,21 +103,23 @@ describe('lib/cors', () => { port: '8080', domain: '', tld: '192.168.1.1', + subdomain: null, + protocol: 'http:', }) }) }) - context('.urlMatchesOriginPolicyProps', () => { + context('.urlMatchesOriginProps', () => { const assertOriginsDoNotMatch = (url, props) => { - expect(cors.urlMatchesOriginPolicyProps(url, props)).to.be.false + expect(cors.urlMatchesOriginProps(url, props)).to.be.false } const assertOriginsDoMatch = (url, props) => { - expect(cors.urlMatchesOriginPolicyProps(url, props)).to.be.true + expect(cors.urlMatchesOriginProps(url, props)).to.be.true } describe('domain + subdomain', () => { - const props = cors.parseUrlIntoDomainTldPort('https://staging.google.com') + const props = cors.parseUrlIntoHostProtocolDomainTldPort('https://staging.google.com') it('does not match', function () { assertOriginsDoNotMatch('https://foo.bar:443', props) @@ -112,32 +131,32 @@ describe('lib/cors', () => { assertOriginsDoNotMatch('https://staging.google.net:443', props) assertOriginsDoNotMatch('https://google.net:443', props) assertOriginsDoNotMatch('http://google.com', props) + assertOriginsDoNotMatch('https://google.com:443', props) + assertOriginsDoNotMatch('https://foo.google.com:443', props) + assertOriginsDoNotMatch('https://foo.bar.google.com:443', props) }) it('matches', function () { assertOriginsDoMatch('https://staging.google.com:443', props) - assertOriginsDoMatch('https://google.com:443', props) - assertOriginsDoMatch('https://foo.google.com:443', props) - assertOriginsDoMatch('https://foo.bar.google.com:443', props) }) }) describe('public suffix', () => { - const props = cors.parseUrlIntoDomainTldPort('https://example.gitlab.io') + const props = cors.parseUrlIntoHostProtocolDomainTldPort('https://example.gitlab.io') it('does not match', function () { assertOriginsDoNotMatch('http://example.gitlab.io', props) assertOriginsDoNotMatch('https://foo.gitlab.io:443', props) + assertOriginsDoNotMatch('https://foo.example.gitlab.io:443', props) }) it('matches', function () { assertOriginsDoMatch('https://example.gitlab.io:443', props) - assertOriginsDoMatch('https://foo.example.gitlab.io:443', props) }) }) describe('localhost', () => { - const props = cors.parseUrlIntoDomainTldPort('http://localhost:4200') + const props = cors.parseUrlIntoHostProtocolDomainTldPort('http://localhost:4200') it('does not match', function () { assertOriginsDoNotMatch('http://localhost:4201', props) @@ -150,36 +169,37 @@ describe('lib/cors', () => { }) describe('app.localhost', () => { - const props = cors.parseUrlIntoDomainTldPort('http://app.localhost:4200') + const props = cors.parseUrlIntoHostProtocolDomainTldPort('http://app.localhost:4200') it('does not match', function () { assertOriginsDoNotMatch('http://app.localhost:4201', props) assertOriginsDoNotMatch('http://app.localhoss:4200', props) + assertOriginsDoNotMatch('http://name.app.localhost:4200', props) }) it('matches', function () { assertOriginsDoMatch('http://app.localhost:4200', props) - assertOriginsDoMatch('http://name.app.localhost:4200', props) }) }) describe('local', () => { - const props = cors.parseUrlIntoDomainTldPort('http://brian.dev.local') + const props = cors.parseUrlIntoHostProtocolDomainTldPort('http://brian.dev.local') it('does not match', function () { assertOriginsDoNotMatch('https://brian.dev.local:443', props) assertOriginsDoNotMatch('https://brian.dev.local', props) assertOriginsDoNotMatch('http://brian.dev2.local:81', props) + assertOriginsDoNotMatch('http://jennifer.dev.local:80', props) + assertOriginsDoNotMatch('http://jennifer.dev.local', props) }) it('matches', function () { - assertOriginsDoMatch('http://jennifer.dev.local:80', props) - assertOriginsDoMatch('http://jennifer.dev.local', props) + assertOriginsDoMatch('http://brian.dev.local:80', props) }) }) describe('ip address', () => { - const props = cors.parseUrlIntoDomainTldPort('http://192.168.5.10') + const props = cors.parseUrlIntoHostProtocolDomainTldPort('http://192.168.5.10') it('does not match', function () { assertOriginsDoNotMatch('http://192.168.5.10:443', props) @@ -195,6 +215,222 @@ describe('lib/cors', () => { }) }) + context('.urlMatchesSuperDomainOriginProps', () => { + const assertSuperDomainOriginDoesNotMatch = (url, props) => { + expect(cors.urlMatchesSuperDomainOriginProps(url, props)).to.be.false + } + + const assertSuperDomainOriginDoesMatch = (url, props) => { + expect(cors.urlMatchesSuperDomainOriginProps(url, props)).to.be.true + } + + describe('domain + subdomain', () => { + const props = cors.parseUrlIntoHostProtocolDomainTldPort('https://staging.google.com') + + it('does not match', function () { + assertSuperDomainOriginDoesNotMatch('https://foo.bar:443', props) + assertSuperDomainOriginDoesNotMatch('http://foo.bar:80', props) + assertSuperDomainOriginDoesNotMatch('http://foo.bar', props) + assertSuperDomainOriginDoesNotMatch('http://staging.google.com', props) + assertSuperDomainOriginDoesNotMatch('http://staging.google.com:80', props) + assertSuperDomainOriginDoesNotMatch('https://staging.google2.com:443', props) + assertSuperDomainOriginDoesNotMatch('https://staging.google.net:443', props) + assertSuperDomainOriginDoesNotMatch('https://google.net:443', props) + assertSuperDomainOriginDoesNotMatch('http://google.com', props) + }) + + it('matches', function () { + assertSuperDomainOriginDoesMatch('https://staging.google.com:443', props) + assertSuperDomainOriginDoesMatch('https://google.com:443', props) + assertSuperDomainOriginDoesMatch('https://foo.google.com:443', props) + assertSuperDomainOriginDoesMatch('https://foo.bar.google.com:443', props) + }) + }) + + describe('public suffix', () => { + const props = cors.parseUrlIntoHostProtocolDomainTldPort('https://example.gitlab.io') + + it('does not match', function () { + assertSuperDomainOriginDoesNotMatch('http://example.gitlab.io', props) + assertSuperDomainOriginDoesNotMatch('https://foo.gitlab.io:443', props) + }) + + it('matches', function () { + assertSuperDomainOriginDoesMatch('https://example.gitlab.io:443', props) + assertSuperDomainOriginDoesMatch('https://foo.example.gitlab.io:443', props) + }) + }) + + describe('localhost', () => { + const props = cors.parseUrlIntoHostProtocolDomainTldPort('http://localhost:4200') + + it('does not match', function () { + assertSuperDomainOriginDoesNotMatch('http://localhoss:4200', props) + assertSuperDomainOriginDoesNotMatch('http://localhost:4201', props) + }) + + it('matches', function () { + assertSuperDomainOriginDoesMatch('http://localhost:4200', props) + }) + }) + + describe('app.localhost', () => { + const props = cors.parseUrlIntoHostProtocolDomainTldPort('http://app.localhost:4200') + + it('does not match', function () { + assertSuperDomainOriginDoesNotMatch('http://app.localhoss:4200', props) + assertSuperDomainOriginDoesNotMatch('http://app.localhost:4201', props) + }) + + it('matches', function () { + assertSuperDomainOriginDoesMatch('http://app.localhost:4200', props) + assertSuperDomainOriginDoesMatch('http://name.app.localhost:4200', props) + }) + }) + + describe('local', () => { + const props = cors.parseUrlIntoHostProtocolDomainTldPort('http://brian.dev.local') + + it('does not match', function () { + assertSuperDomainOriginDoesNotMatch('https://brian.dev.local:443', props) + assertSuperDomainOriginDoesNotMatch('https://brian.dev.local', props) + assertSuperDomainOriginDoesNotMatch('http://brian.dev2.local:81', props) + assertSuperDomainOriginDoesNotMatch('http://brian.dev.local:8081', props) + }) + + it('matches', function () { + assertSuperDomainOriginDoesMatch('http://brian.dev.local:80', props) + assertSuperDomainOriginDoesMatch('http://jennifer.dev.local:80', props) + assertSuperDomainOriginDoesMatch('http://jennifer.dev.local', props) + }) + }) + + describe('ip address', () => { + const props = cors.parseUrlIntoHostProtocolDomainTldPort('http://192.168.5.10') + + it('does not match', function () { + assertSuperDomainOriginDoesNotMatch('http://192.168.5.10:443', props) + assertSuperDomainOriginDoesNotMatch('https://192.168.5.10', props) + assertSuperDomainOriginDoesNotMatch('http://193.168.5.10', props) + assertSuperDomainOriginDoesNotMatch('http://193.168.5.10:80', props) + assertSuperDomainOriginDoesNotMatch('http://192.168.5.10:8081', props) + }) + + it('matches', function () { + assertSuperDomainOriginDoesMatch('http://192.168.5.10', props) + assertSuperDomainOriginDoesMatch('http://192.168.5.10:80', props) + }) + }) + }) + + context('.urlMatchesSameSiteProps', () => { + const assertSameSiteDoesNotMatch = (url, props) => { + expect(cors.urlMatchesSameSiteProps(url, props)).to.be.false + } + + const assertSameSiteDoesMatch = (url, props) => { + expect(cors.urlMatchesSameSiteProps(url, props)).to.be.true + } + + describe('domain + subdomain', () => { + const props = cors.parseUrlIntoHostProtocolDomainTldPort('https://staging.google.com') + + it('does not match', function () { + assertSameSiteDoesNotMatch('https://foo.bar:443', props) + assertSameSiteDoesNotMatch('http://foo.bar:80', props) + assertSameSiteDoesNotMatch('http://foo.bar', props) + assertSameSiteDoesNotMatch('http://staging.google.com', props) + assertSameSiteDoesNotMatch('http://staging.google.com:80', props) + assertSameSiteDoesNotMatch('https://staging.google2.com:443', props) + assertSameSiteDoesNotMatch('https://staging.google.net:443', props) + assertSameSiteDoesNotMatch('https://google.net:443', props) + assertSameSiteDoesNotMatch('http://google.com', props) + }) + + it('matches', function () { + assertSameSiteDoesMatch('https://staging.google.com:443', props) + assertSameSiteDoesMatch('https://google.com:443', props) + assertSameSiteDoesMatch('https://foo.google.com:443', props) + assertSameSiteDoesMatch('https://foo.bar.google.com:443', props) + }) + }) + + describe('public suffix', () => { + const props = cors.parseUrlIntoHostProtocolDomainTldPort('https://example.gitlab.io') + + it('does not match', function () { + assertSameSiteDoesNotMatch('http://example.gitlab.io', props) + assertSameSiteDoesNotMatch('https://foo.gitlab.io:443', props) + }) + + it('matches', function () { + assertSameSiteDoesMatch('https://example.gitlab.io:443', props) + assertSameSiteDoesMatch('https://foo.example.gitlab.io:443', props) + }) + }) + + describe('localhost', () => { + const props = cors.parseUrlIntoHostProtocolDomainTldPort('http://localhost:4200') + + it('does not match', function () { + assertSameSiteDoesNotMatch('http://localhoss:4200', props) + }) + + it('matches', function () { + assertSameSiteDoesMatch('http://localhost:4201', props) + assertSameSiteDoesMatch('http://localhost:4200', props) + }) + }) + + describe('app.localhost', () => { + const props = cors.parseUrlIntoHostProtocolDomainTldPort('http://app.localhost:4200') + + it('does not match', function () { + assertSameSiteDoesNotMatch('http://app.localhoss:4200', props) + }) + + it('matches', function () { + assertSameSiteDoesMatch('http://app.localhost:4200', props) + assertSameSiteDoesMatch('http://name.app.localhost:4200', props) + assertSameSiteDoesMatch('http://app.localhost:4201', props) + }) + }) + + describe('local', () => { + const props = cors.parseUrlIntoHostProtocolDomainTldPort('http://brian.dev.local') + + it('does not match', function () { + assertSameSiteDoesNotMatch('https://brian.dev.local:443', props) + assertSameSiteDoesNotMatch('https://brian.dev.local', props) + assertSameSiteDoesNotMatch('http://brian.dev2.local:81', props) + }) + + it('matches', function () { + assertSameSiteDoesMatch('http://brian.dev.local:80', props) + assertSameSiteDoesMatch('http://jennifer.dev.local:80', props) + assertSameSiteDoesMatch('http://jennifer.dev.local', props) + assertSameSiteDoesMatch('http://brian.dev.local:8081', props) + }) + }) + + describe('ip address', () => { + const props = cors.parseUrlIntoHostProtocolDomainTldPort('http://192.168.5.10') + + it('does not match', function () { + assertSameSiteDoesNotMatch('http://192.168.5.10:443', props) + assertSameSiteDoesNotMatch('https://192.168.5.10', props) + assertSameSiteDoesNotMatch('http://193.168.5.10', props) + assertSameSiteDoesNotMatch('http://193.168.5.10:80', props) + }) + + it('matches', function () { + assertSameSiteDoesMatch('http://192.168.5.10', props) + assertSameSiteDoesMatch('http://192.168.5.10:80', props) + assertSameSiteDoesMatch('http://192.168.5.10:8081', props) + }) + }) + }) + context('.urlOriginsMatch', () => { const assertOriginsDoNotMatch = (url1, url2) => { expect(cors.urlOriginsMatch(url1, url2)).to.be.false @@ -217,13 +453,13 @@ describe('lib/cors', () => { assertOriginsDoNotMatch('https://staging.google.net:443', url) assertOriginsDoNotMatch('https://google.net:443', url) assertOriginsDoNotMatch('http://google.com', url) + assertOriginsDoNotMatch('https://google.com:443', url) + assertOriginsDoNotMatch('https://foo.google.com:443', url) + assertOriginsDoNotMatch('https://foo.bar.google.com:443', url) }) it('matches', function () { assertOriginsDoMatch('https://staging.google.com:443', url) - assertOriginsDoMatch('https://google.com:443', url) - assertOriginsDoMatch('https://foo.google.com:443', url) - assertOriginsDoMatch('https://foo.bar.google.com:443', url) }) }) @@ -233,11 +469,11 @@ describe('lib/cors', () => { it('does not match', function () { assertOriginsDoNotMatch('http://example.gitlab.io', url) assertOriginsDoNotMatch('https://foo.gitlab.io:443', url) + assertOriginsDoNotMatch('https://foo.example.gitlab.io:443', url) }) it('matches', function () { assertOriginsDoMatch('https://example.gitlab.io:443', url) - assertOriginsDoMatch('https://foo.example.gitlab.io:443', url) }) }) @@ -260,11 +496,11 @@ describe('lib/cors', () => { it('does not match', function () { assertOriginsDoNotMatch('http://app.localhoss:4200', url) assertOriginsDoNotMatch('http://app.localhost:4201', url) + assertOriginsDoNotMatch('http://name.app.localhost:4200', url) }) it('matches', function () { assertOriginsDoMatch('http://app.localhost:4200', url) - assertOriginsDoMatch('http://name.app.localhost:4200', url) }) }) @@ -276,11 +512,8 @@ describe('lib/cors', () => { assertOriginsDoNotMatch('https://brian.dev.local', url) assertOriginsDoNotMatch('http://brian.dev2.local:81', url) assertOriginsDoNotMatch('http://jennifer.dev.local:4201', url) - }) - - it('matches', function () { - assertOriginsDoMatch('http://jennifer.dev.local:80', url) - assertOriginsDoMatch('http://jennifer.dev.local', url) + assertOriginsDoNotMatch('http://jennifer.dev.local:80', url) + assertOriginsDoNotMatch('http://jennifer.dev.local', url) }) }) @@ -302,6 +535,222 @@ describe('lib/cors', () => { }) }) + context('.urlsSuperDomainOriginMatch', () => { + const assertsUrlsAreNotASuperDomainOriginMatch = (url1, url2) => { + expect(cors.urlsSuperDomainOriginMatch(url1, url2)).to.be.false + } + + const assertsUrlsAreASuperDomainOriginMatch = (url1, url2) => { + expect(cors.urlsSuperDomainOriginMatch(url1, url2)).to.be.true + } + + describe('domain + subdomain', () => { + const url = 'https://staging.google.com' + + it('does not match', function () { + assertsUrlsAreNotASuperDomainOriginMatch('https://foo.bar:443', url) + assertsUrlsAreNotASuperDomainOriginMatch('http://foo.bar:80', url) + assertsUrlsAreNotASuperDomainOriginMatch('http://foo.bar', url) + assertsUrlsAreNotASuperDomainOriginMatch('http://staging.google.com', url) + assertsUrlsAreNotASuperDomainOriginMatch('http://staging.google.com:80', url) + assertsUrlsAreNotASuperDomainOriginMatch('https://staging.google2.com:443', url) + assertsUrlsAreNotASuperDomainOriginMatch('https://staging.google.net:443', url) + assertsUrlsAreNotASuperDomainOriginMatch('https://google.net:443', url) + assertsUrlsAreNotASuperDomainOriginMatch('http://google.com', url) + }) + + it('matches', function () { + assertsUrlsAreASuperDomainOriginMatch('https://staging.google.com:443', url) + assertsUrlsAreASuperDomainOriginMatch('https://google.com:443', url) + assertsUrlsAreASuperDomainOriginMatch('https://foo.google.com:443', url) + assertsUrlsAreASuperDomainOriginMatch('https://foo.bar.google.com:443', url) + }) + }) + + describe('public suffix', () => { + const url = 'https://example.gitlab.io' + + it('does not match', function () { + assertsUrlsAreNotASuperDomainOriginMatch('http://example.gitlab.io', url) + assertsUrlsAreNotASuperDomainOriginMatch('https://foo.gitlab.io:443', url) + }) + + it('matches', function () { + assertsUrlsAreASuperDomainOriginMatch('https://example.gitlab.io:443', url) + assertsUrlsAreASuperDomainOriginMatch('https://foo.example.gitlab.io:443', url) + }) + }) + + describe('localhost', () => { + const url = 'http://localhost:4200' + + it('does not match', function () { + assertsUrlsAreNotASuperDomainOriginMatch('http://localhoss:4200', url) + assertsUrlsAreNotASuperDomainOriginMatch('http://localhost:4201', url) + }) + + it('matches', function () { + assertsUrlsAreASuperDomainOriginMatch('http://localhost:4200', url) + }) + }) + + describe('app.localhost', () => { + const url = 'http://app.localhost:4200' + + it('does not match', function () { + assertsUrlsAreNotASuperDomainOriginMatch('http://app.localhoss:4200', url) + assertsUrlsAreNotASuperDomainOriginMatch('http://app.localhost:4201', url) + }) + + it('matches', function () { + assertsUrlsAreASuperDomainOriginMatch('http://app.localhost:4200', url) + assertsUrlsAreASuperDomainOriginMatch('http://name.app.localhost:4200', url) + }) + }) + + describe('local', () => { + const url = 'http://brian.dev.local' + + it('does not match', function () { + assertsUrlsAreNotASuperDomainOriginMatch('https://brian.dev.local:443', url) + assertsUrlsAreNotASuperDomainOriginMatch('https://brian.dev.local', url) + assertsUrlsAreNotASuperDomainOriginMatch('http://brian.dev2.local:81', url) + assertsUrlsAreNotASuperDomainOriginMatch('http://brian.dev.local:8081', url) + }) + + it('matches', function () { + assertsUrlsAreASuperDomainOriginMatch('http://jennifer.dev.local', url) + assertsUrlsAreASuperDomainOriginMatch('http://jennifer.dev.local:80', url) + assertsUrlsAreASuperDomainOriginMatch('http://jennifer.dev.local', url) + }) + }) + + describe('ip address', () => { + const url = 'http://192.168.5.10' + + it('does not match', function () { + assertsUrlsAreNotASuperDomainOriginMatch('http://192.168.5.10:443', url) + assertsUrlsAreNotASuperDomainOriginMatch('https://192.168.5.10', url) + assertsUrlsAreNotASuperDomainOriginMatch('http://193.168.5.10', url) + assertsUrlsAreNotASuperDomainOriginMatch('http://193.168.5.10:80', url) + assertsUrlsAreNotASuperDomainOriginMatch('http://192.168.5.10:12345', url) + }) + + it('matches', function () { + assertsUrlsAreASuperDomainOriginMatch('http://192.168.5.10', url) + assertsUrlsAreASuperDomainOriginMatch('http://192.168.5.10:80', url) + }) + }) + }) + + context('.urlSameSiteMatch', () => { + const assertsUrlsAreNotSameSite = (url1, url2) => { + expect(cors.urlSameSiteMatch(url1, url2)).to.be.false + } + + const assertsUrlsAreSameSite = (url1, url2) => { + expect(cors.urlSameSiteMatch(url1, url2)).to.be.true + } + + describe('domain + subdomain', () => { + const url = 'https://staging.google.com' + + it('does not match', function () { + assertsUrlsAreNotSameSite('https://foo.bar:443', url) + assertsUrlsAreNotSameSite('http://foo.bar:80', url) + assertsUrlsAreNotSameSite('http://foo.bar', url) + assertsUrlsAreNotSameSite('http://staging.google.com', url) + assertsUrlsAreNotSameSite('http://staging.google.com:80', url) + assertsUrlsAreNotSameSite('https://staging.google2.com:443', url) + assertsUrlsAreNotSameSite('https://staging.google.net:443', url) + assertsUrlsAreNotSameSite('https://google.net:443', url) + assertsUrlsAreNotSameSite('http://google.com', url) + }) + + it('matches', function () { + assertsUrlsAreSameSite('https://staging.google.com:443', url) + assertsUrlsAreSameSite('https://google.com:443', url) + assertsUrlsAreSameSite('https://foo.google.com:443', url) + assertsUrlsAreSameSite('https://foo.bar.google.com:443', url) + }) + }) + + describe('public suffix', () => { + const url = 'https://example.gitlab.io' + + it('does not match', function () { + assertsUrlsAreNotSameSite('http://example.gitlab.io', url) + assertsUrlsAreNotSameSite('https://foo.gitlab.io:443', url) + }) + + it('matches', function () { + assertsUrlsAreSameSite('https://example.gitlab.io:443', url) + assertsUrlsAreSameSite('https://foo.example.gitlab.io:443', url) + }) + }) + + describe('localhost', () => { + const url = 'http://localhost:4200' + + it('does not match', function () { + assertsUrlsAreNotSameSite('http://localhoss:4200', url) + }) + + it('matches', function () { + assertsUrlsAreSameSite('http://localhost:4200', url) + assertsUrlsAreSameSite('http://localhost:4201', url) + }) + }) + + describe('app.localhost', () => { + const url = 'http://app.localhost:4200' + + it('does not match', function () { + assertsUrlsAreNotSameSite('http://app.localhoss:4200', url) + }) + + it('matches', function () { + assertsUrlsAreSameSite('http://app.localhost:4200', url) + assertsUrlsAreSameSite('http://name.app.localhost:4200', url) + assertsUrlsAreSameSite('http://app.localhost:4201', url) + }) + }) + + describe('local', () => { + const url = 'http://brian.dev.local' + + it('does not match', function () { + assertsUrlsAreNotSameSite('https://brian.dev.local:443', url) + assertsUrlsAreNotSameSite('https://brian.dev.local', url) + assertsUrlsAreNotSameSite('http://brian.dev2.local:81', url) + }) + + it('matches', function () { + assertsUrlsAreSameSite('http://jennifer.dev.local:4201', url) + assertsUrlsAreSameSite('http://jennifer.dev.local:80', url) + assertsUrlsAreSameSite('http://jennifer.dev.local', url) + assertsUrlsAreSameSite('http://brian.dev.local:8081', url) + }) + }) + + describe('ip address', () => { + const url = 'http://192.168.5.10' + + it('does not match', function () { + assertsUrlsAreNotSameSite('http://192.168.5.10:443', url) + assertsUrlsAreNotSameSite('https://192.168.5.10', url) + assertsUrlsAreNotSameSite('http://193.168.5.10', url) + assertsUrlsAreNotSameSite('http://193.168.5.10:80', url) + }) + + it('matches', function () { + assertsUrlsAreSameSite('http://192.168.5.10', url) + assertsUrlsAreSameSite('http://192.168.5.10:80', url) + assertsUrlsAreSameSite('http://192.168.5.10:12345', url) + }) + }) + }) + context('.urlMatchesOriginProtectionSpace', () => { const assertMatchesOriginProtectionSpace = (urlStr, origin) => { expect(urlStr, `the url: '${urlStr}' did not match origin protection space: '${origin}'`).to.satisfy(() => { @@ -353,15 +802,27 @@ describe('lib/cors', () => { }) }) - context('.getOriginPolicy', () => { + context('.getSuperDomainOrigin', () => { + it('ports', () => { + expect(cors.getSuperDomainOrigin('https://example.com')).to.equal('https://example.com') + expect(cors.getSuperDomainOrigin('http://example.com:8080')).to.equal('http://example.com:8080') + }) + + it('subdomain', () => { + expect(cors.getSuperDomainOrigin('http://www.example.com')).to.equal('http://example.com') + expect(cors.getSuperDomainOrigin('http://www.app.herokuapp.com:8080')).to.equal('http://app.herokuapp.com:8080') + }) + }) + + context('.getOrigin', () => { it('ports', () => { - expect(cors.getOriginPolicy('https://example.com')).to.equal('https://example.com') - expect(cors.getOriginPolicy('http://example.com:8080')).to.equal('http://example.com:8080') + expect(cors.getOrigin('https://example.com')).to.equal('https://example.com') + expect(cors.getOrigin('http://example.com:8080')).to.equal('http://example.com:8080') }) it('subdomain', () => { - expect(cors.getOriginPolicy('http://www.example.com')).to.equal('http://example.com') - expect(cors.getOriginPolicy('http://www.app.herokuapp.com:8080')).to.equal('http://app.herokuapp.com:8080') + expect(cors.getOrigin('http://www.example.com')).to.equal('http://www.example.com') + expect(cors.getOrigin('http://www.app.herokuapp.com:8080')).to.equal('http://www.app.herokuapp.com:8080') }) }) }) diff --git a/packages/proxy/lib/http/index.ts b/packages/proxy/lib/http/index.ts index 7263e2bba2bc..8c40d84f0640 100644 --- a/packages/proxy/lib/http/index.ts +++ b/packages/proxy/lib/http/index.ts @@ -21,6 +21,7 @@ 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 { ResourceTypeAndCredentialManager } from '@packages/server/lib/util/resourceTypeAndCredentialManager' import type { Automation } from '@packages/server/lib/automation/automation' function getRandomColorFn () { @@ -73,6 +74,7 @@ export type ServerCtx = Readonly<{ getFileServerToken: () => string getCookieJar: () => CookieJar remoteStates: RemoteStates + resourceTypeAndCredentialManager: ResourceTypeAndCredentialManager getRenderedHTMLOrigins: Http['getRenderedHTMLOrigins'] netStubbingState: NetStubbingState middleware: HttpMiddlewareStacks @@ -222,6 +224,7 @@ export class Http { request: any socket: CyServer.Socket serverBus: EventEmitter + resourceTypeAndCredentialManager: ResourceTypeAndCredentialManager renderedHTMLOrigins: {[key: string]: boolean} = {} autUrl?: string getCookieJar: () => CookieJar @@ -240,6 +243,7 @@ export class Http { this.socket = opts.socket this.request = opts.request this.serverBus = opts.serverBus + this.resourceTypeAndCredentialManager = opts.resourceTypeAndCredentialManager this.getCookieJar = opts.getCookieJar if (typeof opts.middleware === 'undefined') { @@ -267,6 +271,7 @@ export class Http { netStubbingState: this.netStubbingState, socket: this.socket, serverBus: this.serverBus, + resourceTypeAndCredentialManager: this.resourceTypeAndCredentialManager, getCookieJar: this.getCookieJar, debug: (formatter, ...args) => { if (!debugVerbose.enabled) return diff --git a/packages/proxy/lib/http/request-middleware.ts b/packages/proxy/lib/http/request-middleware.ts index c625addce84d..de8b93ff00fb 100644 --- a/packages/proxy/lib/http/request-middleware.ts +++ b/packages/proxy/lib/http/request-middleware.ts @@ -2,7 +2,8 @@ import _ from 'lodash' import { blocked, cors } from '@packages/network' import { InterceptRequest } from '@packages/net-stubbing' import type { HttpMiddleware } from './' -import { getSameSiteContext, addCookieJarCookiesToRequest } from './util/cookies' +import { getSameSiteContext, addCookieJarCookiesToRequest, shouldAttachAndSetCookies } from './util/cookies' +import { doesTopNeedToBeSimulated } from './util/top-simulation' // do not use a debug namespace in this file - use the per-request `this.debug` instead // available as cypress-verbose:proxy:http @@ -21,13 +22,37 @@ const LogRequest: RequestMiddleware = function () { this.next() } -const ExtractIsAUTFrameHeader: RequestMiddleware = function () { +const ExtractCypressMetadataHeaders: RequestMiddleware = function () { this.req.isAUTFrame = !!this.req.headers['x-cypress-is-aut-frame'] + const requestIsXhrOrFetch = this.req.headers['x-cypress-is-xhr-or-fetch'] if (this.req.headers['x-cypress-is-aut-frame']) { delete this.req.headers['x-cypress-is-aut-frame'] } + if (this.req.headers['x-cypress-is-xhr-or-fetch']) { + this.debug(`found x-cypress-is-xhr-or-fetch header. Deleting x-cypress-is-xhr-or-fetch header.`) + delete this.req.headers['x-cypress-is-xhr-or-fetch'] + } + + if (!this.config.experimentalSessionAndOrigin || + !doesTopNeedToBeSimulated(this) || + // this should be unreachable, as the x-cypress-is-xhr-or-fetch header is only attached if + // the resource type is 'xhr' or 'fetch or 'true' (in the case of electron|extension). + // This is only needed for defensive purposes. + (requestIsXhrOrFetch !== 'true' && requestIsXhrOrFetch !== 'xhr' && requestIsXhrOrFetch !== 'fetch')) { + this.next() + + return + } + + this.debug(`looking up credentials for ${this.req.proxiedUrl}`) + let { resourceType, credentialStatus } = this.resourceTypeAndCredentialManager.get(this.req.proxiedUrl, requestIsXhrOrFetch !== 'true' ? requestIsXhrOrFetch : undefined) + + this.debug(`credentials calculated for ${resourceType}:${credentialStatus}`) + + this.req.requestedWith = resourceType + this.req.credentialsLevel = credentialStatus this.next() } @@ -47,9 +72,16 @@ const MaybeSimulateSecHeaders: RequestMiddleware = function () { } const MaybeAttachCrossOriginCookies: RequestMiddleware = function () { + if (!this.config.experimentalSessionAndOrigin || !doesTopNeedToBeSimulated(this)) { + return this.next() + } + + // Top needs to be simulated since the AUT is in a cross origin state. Get the requestedWith and credentials and see what cookies need to be attached const currentAUTUrl = this.getAUTUrl() + const shouldCookiesBeAttachedToRequest = shouldAttachAndSetCookies(this.req.proxiedUrl, currentAUTUrl, this.req.requestedWith, this.req.credentialsLevel, this.req.isAUTFrame) - if (!this.config.experimentalSessionAndOrigin || !currentAUTUrl) { + this.debug(`should cookies be attached to request?: ${shouldCookiesBeAttachedToRequest}`) + if (!shouldCookiesBeAttachedToRequest) { return this.next() } @@ -65,7 +97,8 @@ const MaybeAttachCrossOriginCookies: RequestMiddleware = function () { this.debug('existing cookies on request from cookie jar: %s', applicableCookiesInCookieJar.join('; ')) this.debug('add cookies to request from header: %s', cookiesOnRequest.join('; ')) - this.req.headers['cookie'] = addCookieJarCookiesToRequest(applicableCookiesInCookieJar, cookiesOnRequest) + // if the cookie header is empty (i.e. ''), set it to undefined for expected behavior + this.req.headers['cookie'] = addCookieJarCookiesToRequest(applicableCookiesInCookieJar, cookiesOnRequest) || undefined this.debug('cookies being sent with request: %s', this.req.headers['cookie']) this.next() @@ -247,7 +280,7 @@ const SendRequestOutgoing: RequestMiddleware = function () { export default { LogRequest, - ExtractIsAUTFrameHeader, + ExtractCypressMetadataHeaders, MaybeSimulateSecHeaders, MaybeAttachCrossOriginCookies, MaybeEndRequestWithBufferedResponse, diff --git a/packages/proxy/lib/http/response-middleware.ts b/packages/proxy/lib/http/response-middleware.ts index bfa2ffc684a8..230f828ea469 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 { /** @@ -53,9 +54,9 @@ function getNodeCharsetFromResponse (headers: IncomingHttpHeaders, body: Buffer, return 'latin1' } -function reqMatchesOriginPolicy (req: CypressIncomingRequest, remoteState) { +function reqMatchesSuperDomainOrigin (req: CypressIncomingRequest, remoteState) { if (remoteState.strategy === 'http') { - return cors.urlMatchesOriginPolicyProps(req.proxiedUrl, remoteState.props) + return cors.urlMatchesSuperDomainOriginProps(req.proxiedUrl, remoteState.props) } if (remoteState.strategy === 'file') { @@ -248,7 +249,7 @@ const SetInjectionLevel: ResponseMiddleware = function () { this.debug('determine injection') - const isReqMatchOriginPolicy = reqMatchesOriginPolicy(this.req, this.remoteStates.current()) + const isReqMatchSuperDomainOrigin = reqMatchesSuperDomainOrigin(this.req, this.remoteStates.current()) const getInjectionLevel = () => { if (this.incomingRes.headers['x-cypress-file-server-error'] && !this.res.isInitial) { this.debug('- partial injection (x-cypress-file-server-error)') @@ -256,7 +257,7 @@ const SetInjectionLevel: ResponseMiddleware = function () { return 'partial' } - const isCrossOrigin = !reqMatchesOriginPolicy(this.req, this.remoteStates.getPrimary()) + const isCrossOrigin = !reqMatchesSuperDomainOrigin(this.req, this.remoteStates.getPrimary()) const isAUTFrame = this.req.isAUTFrame if (this.config.experimentalSessionAndOrigin && isCrossOrigin && isAUTFrame && (isHTML || isRenderedHTML)) { @@ -265,7 +266,7 @@ const SetInjectionLevel: ResponseMiddleware = function () { return 'fullCrossOrigin' } - if (!isHTML || (!isReqMatchOriginPolicy && !isAUTFrame)) { + if (!isHTML || (!isReqMatchSuperDomainOrigin && !isAUTFrame)) { this.debug('- no injection (not html)') return false @@ -314,7 +315,7 @@ const SetInjectionLevel: ResponseMiddleware = function () { this.res.wantsInjection === 'full' || this.res.wantsInjection === 'fullCrossOrigin' || // only modify JavasScript if matching the current origin policy or if experimentalModifyObstructiveThirdPartyCode is enabled (above) - (resContentTypeIsJavaScript(this.incomingRes) && isReqMatchOriginPolicy)) + (resContentTypeIsJavaScript(this.incomingRes) && isReqMatchSuperDomainOrigin)) this.debug('injection levels: %o', _.pick(this.res, 'isInitial', 'wantsInjection', 'wantsSecurityRemoved')) @@ -370,48 +371,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 @@ -425,7 +411,7 @@ const CopyCookiesFromIncomingRes: ResponseMiddleware = async function () { } } - if (!this.config.experimentalSessionAndOrigin) { + if (!this.config.experimentalSessionAndOrigin || !doesTopNeedSimulating) { ([] as string[]).concat(cookies).forEach((cookie) => { appendCookie(cookie) }) @@ -440,7 +426,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, }, }) @@ -454,7 +442,7 @@ const CopyCookiesFromIncomingRes: ResponseMiddleware = async function () { const addedCookies = await cookiesHelper.getAddedCookies() - if (!needsCrossOriginHandling || !addedCookies.length) { + if (!addedCookies.length) { return this.next() } @@ -599,7 +587,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 6c8ed1a364fc..8d052d250ac3 100644 --- a/packages/proxy/lib/http/util/cookies.ts +++ b/packages/proxy/lib/http/util/cookies.ts @@ -2,34 +2,93 @@ import _ from 'lodash' 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 type { RequestCredentialLevel, RequestResourceType } from '../../types' + +type SiteContext = 'same-origin' | 'same-site' | 'cross-site' interface RequestDetails { url: string isAUTFrame: boolean - needsCrossOriginHandling: boolean + doesTopNeedSimulating: boolean + resourceType?: RequestResourceType + credentialLevel?: RequestCredentialLevel } /** - * Whether or not a url's scheme, domain, and top-level domain match to determine whether or not - * a cookie is considered first-party. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#third-party_cookies - * for more details. - * @param {string} url1 - the first url - * @param {string} url2 - the second url - * @returns {boolean} whether or not the URL Scheme, Domain, and TLD match. This is called same-site and - * is allowed to have a different port or subdomain. @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site#directives - * for more details. + * Determines whether or not a request should attach cookies from the tough-cookie jar or whether a response should set cookies inside the tough-cookie jar. + * same-origin requests send cookies by default (unless 'omit' is specified by the fetch API). Otherwise, for same-site/cross-site requests, credentials either need to + * be 'include' via the fetch API or 'true for XmlHttpRequest. If the AUT Iframe is making the request, this is likely a navigation and we should attach/set cookies in the browser, + * which is critical for lax cookies + * @param {string} requestUrl - the url of the request + * @param {string} AUTUrl - The current url of the app under test + * @param {RequestResourceType} [resourceType] - + * @param {RequestCredentialLevel} [credentialLevel] - The credentialLevel of the request. For `fetch` this is `omit|same-origin|include` (defaults to same-origin) + * and for `XmlHttpRequest` it is `true|false` (defaults to false) + * @param {isAutFrame} [boolean] - whether or not the request is from the AUT Iframe or not + * @returns {boolean} */ -export const areUrlsSameSite = (url1: string, url2: string) => { - if (!url1 || !url2) return false +export const shouldAttachAndSetCookies = (requestUrl: string, AUTUrl: string | undefined, resourceType?: RequestResourceType, credentialLevel?: RequestCredentialLevel, isAutFrame?: boolean): boolean => { + if (!AUTUrl) return false + + const siteContext = calculateSiteContext(requestUrl, AUTUrl) + + switch (resourceType) { + case 'fetch': + // never attach cookies regardless of siteContext if omit is optioned + if (credentialLevel === 'omit') { + return false + } + + // attach cookies here if at least one of the following is true + // a) The siteContext is 'same-origin' (since 'omit' is handled above) + // b) The credentialLevel is 'include' regardless of siteContext + if (siteContext === 'same-origin' || credentialLevel === 'include') { + return true + } + + return false + case 'xhr': + // attach cookies here if at least one of the following is true + // a) The siteContext is 'same-origin' + // b) The credentialLevel (withCredentials) is set to true + if (siteContext === 'same-origin' || credentialLevel) { + return true + } + + return false + default: + // if we cannot determine a resource level, we likely should store the cookie as it is a navigation or another event as long as the context is same-origin + if (siteContext === 'same-origin' || isAutFrame) { + return true + } + + return false + } +} - const { port: port1, ...parsedUrl1 } = cors.parseUrlIntoDomainTldPort(url1) - const { port: port2, ...parsedUrl2 } = cors.parseUrlIntoDomainTldPort(url2) +/** + * Calculates the site context of two urls. + * This is needed to figure out if cookies are sent by default, as well as if first/third-party cookies apply to a given request + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#third-party_cookies + * + * @param {string} url1 - the first url being compared + * @param {string} url2 - the second url being compared + * @returns {SiteContext} - the appropriate site context. This is most similar to the Sec-Fetch-Site Directive when + * calculating the siteContext barring the none option. @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site#directives + * to see definitions for same-origin, same-site, and cross-site + */ +export const calculateSiteContext = (url1: string, url2: string): SiteContext => { + if (urlOriginsMatch(url1, url2)) { + return 'same-origin' + } - // If HTTPS, ports NEED to match. Otherwise, HTTP ports can be different and are same origin - const doPortsPassSameSchemeCheck = port1 !== port2 ? (port1 !== '443' && port2 !== '443') : true + if (urlSameSiteMatch(url1, url2)) { + return 'same-site' + } - return doPortsPassSameSchemeCheck && _.isEqual(parsedUrl1, parsedUrl2) + return 'cross-site' } export const addCookieJarCookiesToRequest = (applicableCookieJarCookies: Cookie[] = [], requestCookieStringArray: string[] = []): string => { @@ -62,7 +121,7 @@ export const getSameSiteContext = (autUrl: string | undefined, requestUrl: strin // if there's no AUT URL, it's a request for the first URL visited, or if // the request origin is considered the same site as the AUT origin; // both indicate that it's not a cross-site request - if (!autUrl || areUrlsSameSite(autUrl, requestUrl)) { + if (!autUrl || cors.urlSameSiteMatch(autUrl, requestUrl)) { return 'strict' } @@ -105,6 +164,7 @@ export class CookiesHelper { debug: Debug.Debugger defaultDomain: string sameSiteContext: 'strict' | 'lax' | 'none' + siteContext: SiteContext previousCookies: Cookie[] = [] constructor ({ cookieJar, currentAUTUrl, request, debug }) { @@ -113,6 +173,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) @@ -123,7 +184,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() } @@ -132,7 +193,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() @@ -151,10 +212,30 @@ 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 } + // cross site cookies cannot set lax/strict cookies in the browser for xhr/fetch requests (but ok with navigation/document requests) + // NOTE: This is allowable in firefox as the default cookie behavior is no_restriction (none). However, this shouldn't + // impact what is happening in the server-side cookie jar as Set-Cookie is still called and firefox will allow it to be set in the browser + 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 + } + + // 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. + const shouldSetCookieGivenSiteContext = shouldAttachAndSetCookies(this.request.url, this.currentAUTUrl, this.request.resourceType, this.request.credentialLevel, this.request.isAUTFrame) + + if (!shouldSetCookieGivenSiteContext) { + this.debug(`not setting cookie for ${this.request.url} with simulated top ${ this.currentAUTUrl} for ${ this.request.resourceType}:${this.request.credentialLevel}, cookie: ${toughCookie}`) + + return + } + try { this.cookieJar.setCookie(toughCookie, this.request.url, this.sameSiteContext) } catch (err) { diff --git a/packages/proxy/lib/http/util/top-simulation.ts b/packages/proxy/lib/http/util/top-simulation.ts new file mode 100644 index 000000000000..dc0676e1ac3f --- /dev/null +++ b/packages/proxy/lib/http/util/top-simulation.ts @@ -0,0 +1,14 @@ +import type { HttpMiddlewareThis } from '../index' + +export const doesTopNeedToBeSimulated = (ctx: HttpMiddlewareThis): boolean => { + const currentAUTUrl = ctx.getAUTUrl() + + // if the AUT url is undefined for whatever reason, return false as we do not want to complicate top simulation + if (!currentAUTUrl) { + return false + } + + // only simulate top if the AUT is NOT the primary origin, meaning that we should treat the AUT as top + // or the request is the AUT frame, which is common for redirects and navigations. + return !ctx.remoteStates.isPrimaryOrigin(currentAUTUrl) || ctx.req.isAUTFrame +} diff --git a/packages/proxy/lib/types.ts b/packages/proxy/lib/types.ts index d6a8b2f1f54a..9d82a918b358 100644 --- a/packages/proxy/lib/types.ts +++ b/packages/proxy/lib/types.ts @@ -13,8 +13,14 @@ export type CypressIncomingRequest = Request & { responseTimeout?: number followRedirect?: boolean isAUTFrame: boolean + requestedWith?: RequestResourceType + credentialsLevel?: RequestCredentialLevel } +export type RequestResourceType = 'fetch' | 'xhr' + +export type RequestCredentialLevel = 'same-origin' | 'include' | 'omit' | boolean + export type CypressWantsInjection = 'full' | 'fullCrossOrigin' | 'partial' | false /** diff --git a/packages/proxy/test/unit/http/request-middleware.spec.ts b/packages/proxy/test/unit/http/request-middleware.spec.ts index 36e5154ed1a6..67ca0442534f 100644 --- a/packages/proxy/test/unit/http/request-middleware.spec.ts +++ b/packages/proxy/test/unit/http/request-middleware.spec.ts @@ -1,17 +1,19 @@ import _ from 'lodash' import RequestMiddleware from '../../../lib/http/request-middleware' import { expect } from 'chai' +import sinon from 'sinon' import { testMiddleware } from './helpers' import { CypressIncomingRequest, CypressOutgoingResponse } from '../../../lib' import { HttpBuffer, HttpBuffers } from '../../../lib/http/util/buffers' import { RemoteStates } from '@packages/server/lib/remote_states' import { CookieJar } from '@packages/server/lib/util/cookies' +import { HttpMiddlewareThis } from '../../../lib/http' describe('http/request-middleware', () => { it('exports the members in the correct order', () => { expect(_.keys(RequestMiddleware)).to.have.ordered.members([ 'LogRequest', - 'ExtractIsAUTFrameHeader', + 'ExtractCypressMetadataHeaders', 'MaybeSimulateSecHeaders', 'MaybeAttachCrossOriginCookies', 'MaybeEndRequestWithBufferedResponse', @@ -26,8 +28,8 @@ describe('http/request-middleware', () => { ]) }) - describe('ExtractIsAUTFrameHeader', () => { - const { ExtractIsAUTFrameHeader } = RequestMiddleware + describe('ExtractCypressMetadataHeaders', () => { + const { ExtractCypressMetadataHeaders } = RequestMiddleware it('removes x-cypress-is-aut-frame header when it exists, sets in on the req', async () => { const ctx = { @@ -38,7 +40,7 @@ describe('http/request-middleware', () => { } as Partial, } - await testMiddleware([ExtractIsAUTFrameHeader], ctx) + await testMiddleware([ExtractCypressMetadataHeaders], ctx) .then(() => { expect(ctx.req.headers['x-cypress-is-aut-frame']).not.to.exist expect(ctx.req.isAUTFrame).to.be.true @@ -52,12 +54,186 @@ describe('http/request-middleware', () => { } as Partial, } - await testMiddleware([ExtractIsAUTFrameHeader], ctx) + await testMiddleware([ExtractCypressMetadataHeaders], ctx) .then(() => { expect(ctx.req.headers['x-cypress-is-aut-frame']).not.to.exist expect(ctx.req.isAUTFrame).to.be.false }) }) + + it('removes x-cypress-is-xhr-or-fetch header when it exists', async () => { + const ctx = { + req: { + headers: { + 'x-cypress-is-xhr-or-fetch': 'true', + }, + } as Partial, + } + + await testMiddleware([ExtractCypressMetadataHeaders], ctx) + .then(() => { + expect(ctx.req.headers['x-cypress-is-xhr-or-fetch']).not.to.exist + }) + }) + + it('removes x-cypress-is-xhr-or-fetch header when it does not exist', async () => { + const ctx = { + req: { + headers: {}, + } as Partial, + } + + await testMiddleware([ExtractCypressMetadataHeaders], ctx) + .then(() => { + expect(ctx.req.headers['x-cypress-is-xhr-or-fetch']).not.to.exist + }) + }) + + it('does not set requestedWith or credentialLevel on the request if the the experimentalSessionAndOrigin flag is off', async () => { + const ctx = { + config: { + experimentalSessionAndOrigin: false, + }, + req: { + headers: { + 'x-cypress-is-xhr-or-fetch': 'true', + }, + } as Partial, + } + + await testMiddleware([ExtractCypressMetadataHeaders], ctx) + .then(() => { + expect(ctx.req.requestedWith).not.to.exist + expect(ctx.req.credentialsLevel).not.to.exist + }) + }) + + it('does not set requestedWith or credentialLevel on the request if top does NOT need to be simulated', async () => { + const ctx = { + config: { + experimentalSessionAndOrigin: true, + }, + getAUTUrl: sinon.stub().returns(undefined), + req: { + headers: { + 'x-cypress-is-xhr-or-fetch': 'true', + }, + } as Partial, + } + + await testMiddleware([ExtractCypressMetadataHeaders], ctx) + .then(() => { + expect(ctx.req.requestedWith).not.to.exist + expect(ctx.req.credentialsLevel).not.to.exist + }) + }) + + it('does not set requestedWith or credentialLevel on the request if x-cypress-is-xhr-or-fetch has invalid values', async () => { + const ctx = { + config: { + experimentalSessionAndOrigin: true, + }, + getAUTUrl: sinon.stub().returns('http://localhost:8080'), + remoteStates: { + isPrimaryOrigin: sinon.stub().returns(false), + }, + req: { + headers: { + 'x-cypress-is-xhr-or-fetch': 'sub_frame', + }, + } as Partial, + } + + await testMiddleware([ExtractCypressMetadataHeaders], ctx) + .then(() => { + expect(ctx.req.requestedWith).not.to.exist + expect(ctx.req.credentialsLevel).not.to.exist + }) + }) + + // CDP can determine whether or not the request is xhr | fetch, but the extension or electron cannot + it('provides resourceTypeAndCredentialManager with resourceType if able to determine from header (xhr)', async () => { + const ctx = { + config: { + experimentalSessionAndOrigin: true, + }, + getAUTUrl: sinon.stub().returns('http://localhost:8080'), + remoteStates: { + isPrimaryOrigin: sinon.stub().returns(false), + }, + resourceTypeAndCredentialManager: { + get: sinon.stub().returns({}), + }, + req: { + proxiedUrl: 'http://localhost:8080', + headers: { + 'x-cypress-is-xhr-or-fetch': 'xhr', + }, + } as Partial, + } + + await testMiddleware([ExtractCypressMetadataHeaders], ctx) + .then(() => { + expect(ctx.resourceTypeAndCredentialManager.get).to.have.been.calledWith('http://localhost:8080', `xhr`) + }) + }) + + // CDP can determine whether or not the request is xhr | fetch, but the extension or electron cannot + it('provides resourceTypeAndCredentialManager with resourceType if able to determine from header (fetch)', async () => { + const ctx = { + config: { + experimentalSessionAndOrigin: true, + }, + getAUTUrl: sinon.stub().returns('http://localhost:8080'), + remoteStates: { + isPrimaryOrigin: sinon.stub().returns(false), + }, + resourceTypeAndCredentialManager: { + get: sinon.stub().returns({}), + }, + req: { + proxiedUrl: 'http://localhost:8080', + headers: { + 'x-cypress-is-xhr-or-fetch': 'fetch', + }, + } as Partial, + } + + await testMiddleware([ExtractCypressMetadataHeaders], ctx) + .then(() => { + expect(ctx.resourceTypeAndCredentialManager.get).to.have.been.calledWith('http://localhost:8080', `fetch`) + }) + }) + + it('sets the resourceType and credentialsLevel on the request from whatever is returned by resourceTypeAndCredentialManager if conditions apply', async () => { + const ctx = { + config: { + experimentalSessionAndOrigin: true, + }, + getAUTUrl: sinon.stub().returns('http://localhost:8080'), + remoteStates: { + isPrimaryOrigin: sinon.stub().returns(false), + }, + resourceTypeAndCredentialManager: { + get: sinon.stub().returns({ + resourceType: 'fetch', + credentialStatus: 'same-origin', + }), + }, + req: { + proxiedUrl: 'http://localhost:8080', + headers: { + 'x-cypress-is-xhr-or-fetch': 'true', + }, + } as Partial, + } + + await testMiddleware([ExtractCypressMetadataHeaders], ctx) + .then(() => { + expect(ctx.req.requestedWith).to.equal('fetch') + expect(ctx.req.credentialsLevel).to.equal('same-origin') + }) + }) }) describe('MaybeSimulateSecHeaders', () => { @@ -158,9 +334,67 @@ describe('http/request-middleware', () => { expect(ctx.req.headers['cookie']).to.equal('request=cookie') }) - it('prepends cookie jar cookies to request', async () => { + it('is a noop if does not need to simulate top', async () => { const ctx = await getContext() + ctx.req.isAUTFrame = false + ctx.remoteStates.isPrimaryOrigin.returns(true), + + await testMiddleware([MaybeAttachCrossOriginCookies], ctx) + + expect(ctx.req.headers['cookie']).to.equal('request=cookie') + }) + + it('is a noop if cookies do NOT need to be attached to request', async () => { + const ctx = await getContext(['request=cookie'], ['jar=cookie'], 'http://foobar.com', 'http://app.foobar.com') + + ctx.req.requestedWith = 'fetch' + ctx.req.credentialsLevel = 'omit' + + await testMiddleware([MaybeAttachCrossOriginCookies], ctx) + + expect(ctx.req.headers['cookie']).to.equal('request=cookie') + }) + + it(`allows setting cookies on request if resource type cannot be determined, but comes from the AUT frame (likely in the case of documents or redirects)`, async function () { + const ctx = await getContext([], ['jar=cookie'], 'http://foobar.com/index.html', 'http://app.foobar.com/index.html') + + ctx.req.requestedWith = undefined + ctx.req.credentialsLevel = undefined + ctx.req.isAUTFrame = true + await testMiddleware([MaybeAttachCrossOriginCookies], ctx) + + expect(ctx.req.headers['cookie']).to.equal('jar=cookie') + }) + + it(`otherwise, does not allow setting cookies if request type cannot be determined and is not from the AUT and is cross-origin`, async function () { + const ctx = await getContext([], ['jar=cookie'], 'http://foobar.com/index.html', 'http://app.foobar.com/index.html') + + ctx.req.requestedWith = undefined + ctx.req.credentialsLevel = undefined + ctx.req.isAUTFrame = false + await testMiddleware([MaybeAttachCrossOriginCookies], ctx) + + expect(ctx.req.headers['cookie']).to.be.undefined + }) + + it('sets the cookie header to undefined if no cookies exist on the request, none in the jar, but cookies should be attached', async () => { + const ctx = await getContext([], [], 'http://foobar.com', 'http://app.foobar.com') + + ctx.req.requestedWith = 'xhr' + ctx.req.credentialsLevel = true + + await testMiddleware([MaybeAttachCrossOriginCookies], ctx) + + expect(ctx.req.headers['cookie']).to.equal(undefined) + }) + + it('prepends cookie jar cookies to request', async () => { + const ctx = await getContext(['request=cookie'], ['jar=cookie'], 'http://foobar.com', 'http://app.foobar.com') + + ctx.req.requestedWith = 'fetch' + ctx.req.credentialsLevel = 'include' + await testMiddleware([MaybeAttachCrossOriginCookies], ctx) expect(ctx.req.headers['cookie']).to.equal('jar=cookie; request=cookie') @@ -214,7 +448,7 @@ describe('http/request-middleware', () => { describe('does not add request cookie to request if cookie exists in jar, and preserves duplicate cookies when same key/value if', () => { describe('subdomain and TLD', () => { it('matches hierarchy', async () => { - const ctx = await getContext(['jar=cookie', 'request=cookie'], ['jar=cookie1; Domain=app.foobar.com', 'jar=cookie2; Domain=foobar.com', 'jar=cookie3; Domain=exclude.foobar.com'], 'http://app.foobar.com/generic') + const ctx = await getContext(['jar=cookie', 'request=cookie'], ['jar=cookie1; Domain=app.foobar.com', 'jar=cookie2; Domain=foobar.com', 'jar=cookie3; Domain=exclude.foobar.com'], 'http://app.foobar.com/generic', 'http://app.foobar.com/generic') await testMiddleware([MaybeAttachCrossOriginCookies], ctx) @@ -222,7 +456,7 @@ describe('http/request-middleware', () => { }) it('matches hierarchy and gives order to the cookie that was created first', async () => { - const ctx = await getContext(['jar=cookie', 'request=cookie'], ['jar=cookie1; Domain=app.foobar.com;', 'jar=cookie2; Domain=.foobar.com;'], 'http://app.foobar.com/generic') + const ctx = await getContext(['jar=cookie', 'request=cookie'], ['jar=cookie1; Domain=app.foobar.com;', 'jar=cookie2; Domain=.foobar.com;'], 'http://app.foobar.com/generic', 'http://app.foobar.com/generic') const cookies = ctx.getCookieJar().getCookies('http://app.foobar.com/generic', 'strict') @@ -236,7 +470,7 @@ describe('http/request-middleware', () => { }) it('matches hierarchy and gives order to the cookie with the most specific path, regardless of creation time', async () => { - const ctx = await getContext(['jar=cookie', 'request=cookie'], ['jar=cookie1; Domain=app.foobar.com; Path=/generic', 'jar=cookie2; Domain=.foobar.com;'], 'http://app.foobar.com/generic') + const ctx = await getContext(['jar=cookie', 'request=cookie'], ['jar=cookie1; Domain=app.foobar.com; Path=/generic', 'jar=cookie2; Domain=.foobar.com;'], 'http://app.foobar.com/generic', 'http://app.foobar.com/generic') const cookies = ctx.getCookieJar().getCookies('http://app.foobar.com/generic', 'strict') @@ -264,7 +498,7 @@ describe('http/request-middleware', () => { 'jar=cookie9; Domain=exclude.foobar.com; Path=/generic/specific', ] - const ctx = await getContext(['request=cookie'], cookieJarCookies, 'http://app.foobar.com/generic/specific') + const ctx = await getContext(['request=cookie'], cookieJarCookies, 'http://app.foobar.com/generic/specific', 'http://app.foobar.com/generic/specific') const cookies = ctx.getCookieJar().getCookies('http://app.foobar.com/generic', 'strict') @@ -279,12 +513,12 @@ describe('http/request-middleware', () => { }) }) - async function getContext (requestCookieStrings = ['request=cookie'], cookieJarStrings = ['jar=cookie'], autAndRequestUrl = 'http://foobar.com') { + async function getContext (requestCookieStrings = ['request=cookie'], cookieJarStrings = ['jar=cookie'], autUrl = 'http://foobar.com', requestUrl = 'http://foobar.com') { const cookieJar = new CookieJar() await Promise.all(cookieJarStrings.map(async (cookieString) => { try { - await cookieJar._cookieJar.setCookie(cookieString, autAndRequestUrl) + await cookieJar._cookieJar.setCookie(cookieString, requestUrl) } catch (e) { // likely doesn't match the url policy, path, or is another type of cookie mismatch return @@ -292,18 +526,20 @@ describe('http/request-middleware', () => { })) return { - getAUTUrl: () => autAndRequestUrl, + getAUTUrl: () => autUrl, getCookieJar: () => cookieJar, + remoteStates: { + isPrimaryOrigin: sinon.stub().returns(false), + }, config: { experimentalSessionAndOrigin: true }, req: { - proxiedUrl: autAndRequestUrl, + proxiedUrl: requestUrl, isAUTFrame: true, headers: { - - cookie: requestCookieStrings.join('; '), + cookie: requestCookieStrings.join('; ') || undefined, }, }, - } + } as HttpMiddlewareThis } }) diff --git a/packages/proxy/test/unit/http/response-middleware.spec.ts b/packages/proxy/test/unit/http/response-middleware.spec.ts index 81203d8d0e86..9f476139dd4f 100644 --- a/packages/proxy/test/unit/http/response-middleware.spec.ts +++ b/packages/proxy/test/unit/http/response-middleware.spec.ts @@ -19,7 +19,7 @@ describe('http/response-middleware', function () { 'OmitProblematicHeaders', 'MaybePreventCaching', 'MaybeStripDocumentDomainFeaturePolicy', - 'CopyCookiesFromIncomingRes', + 'MaybeCopyCookiesFromIncomingRes', 'MaybeSendRedirectToClient', 'CopyResponseStatusCode', 'ClearCyInitialCookie', @@ -550,8 +550,8 @@ describe('http/response-middleware', function () { remoteStates.set('http://127.0.0.1:3501') // set the secondary remote states - props.secondaryOrigins?.forEach((originPolicy) => { - remoteStates.set(originPolicy, {}, false) + props.secondaryOrigins?.forEach((origin) => { + remoteStates.set(origin, {}, false) }) ctx = { @@ -584,8 +584,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({ @@ -596,7 +596,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') @@ -606,7 +606,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') @@ -620,15 +620,722 @@ 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, but comes from the AUT frame (likely in the case of documents or redirects)`, async function () { + const appendStub = sinon.stub() + + const cookieJar = { + getAllCookies: () => [{ key: 'cookie', value: 'value' }], + setCookie: sinon.stub(), + } + + const ctx = prepareContext({ + cookieJar, + res: { + append: appendStub, + }, + req: { + isAUTFrame: true, + 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', 'lax') + + // send to browser anyway even though these will likely fail to be set + expect(appendStub).to.be.calledWith('Set-Cookie', 'cookie=value') + }) + + it(`otherwise, does not allow setting cookies if request type cannot be determined and is not from the AUT and is cross-origin`, async function () { + const appendStub = sinon.stub() + + const cookieJar = { + getAllCookies: () => [{ key: 'cookie', value: 'value' }], + setCookie: sinon.stub(), + } + + const ctx = prepareContext({ + cookieJar, + res: { + append: appendStub, + }, + req: { + proxiedUrl: 'https://www.barbaz.com/some-image.png', + }, + 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).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', '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 }) @@ -647,7 +1354,7 @@ describe('http/response-middleware', function () { }, }) - await testMiddleware([CopyCookiesFromIncomingRes], ctx) + await testMiddleware([MaybeCopyCookiesFromIncomingRes], ctx) expect(ctx.serverBus.emit).not.to.be.called }) @@ -673,7 +1380,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') @@ -690,8 +1397,8 @@ describe('http/response-middleware', function () { remoteStates.set('http://foobar.com') // set the secondary remote states - props.secondaryOrigins?.forEach((originPolicy) => { - remoteStates.set(originPolicy, {}, false) + props.secondaryOrigins?.forEach((origin) => { + remoteStates.set(origin, {}, false) }) remoteStates.isPrimaryOrigin = () => false diff --git a/packages/proxy/test/unit/http/util/cookies.spec.ts b/packages/proxy/test/unit/http/util/cookies.spec.ts index 253091aba2ce..24a923f0574e 100644 --- a/packages/proxy/test/unit/http/util/cookies.spec.ts +++ b/packages/proxy/test/unit/http/util/cookies.spec.ts @@ -1,5 +1,5 @@ import { expect } from 'chai' -import { getSameSiteContext } from '../../../../lib/http/util/cookies' +import { calculateSiteContext, getSameSiteContext, shouldAttachAndSetCookies } from '../../../../lib/http/util/cookies' context('getSameSiteContext', () => { describe('calculates the same site context correctly for', () => { @@ -169,3 +169,97 @@ context('getSameSiteContext', () => { }) }) }) + +context('shouldAttachAndSetCookies', () => { + const autUrl = 'http://localhost:8080' + + context('fetch', () => { + it('returns false if credentials are set to omit, regardless of site context', () => { + // same-origin + expect(shouldAttachAndSetCookies('http://localhost:8080/test-request', autUrl, 'fetch', 'omit')).to.be.false + // same-site + expect(shouldAttachAndSetCookies('http://localhost:8081/test-request', autUrl, 'fetch', 'omit')).to.be.false + // cross-site + expect(shouldAttachAndSetCookies('http://www.foobar.com:3500/test-request', autUrl, 'fetch', 'omit')).to.be.false + }) + + it('returns true if credentials are set to "include", regardless of site context', () => { + // same-origin + expect(shouldAttachAndSetCookies('http://localhost:8080/test-request', autUrl, 'fetch', 'include')).to.be.true + // same-site + expect(shouldAttachAndSetCookies('http://localhost:8081/test-request', autUrl, 'fetch', 'include')).to.be.true + // cross-site + expect(shouldAttachAndSetCookies('http://www.foobar.com:3500/test-request', autUrl, 'fetch', 'include')).to.be.true + }) + + it('returns true if credentials are set to "same-origin" and the site context is "same-origin"', () => { + expect(shouldAttachAndSetCookies('http://localhost:8080/test-request', autUrl, 'fetch', 'same-origin')).to.be.true + }) + + it('returns false if credentials are set to "same-origin" (default), but the site context is "same-site"', () => { + expect(shouldAttachAndSetCookies('http://localhost:8081/test-request', autUrl, 'fetch', 'same-origin')).to.be.false + expect(shouldAttachAndSetCookies('http://localhost:8081/test-request', autUrl, 'fetch')).to.be.false + }) + + it('returns false if credentials are set to "same-origin" (default), but the site context is "cross-site"', () => { + expect(shouldAttachAndSetCookies('http://www.foobar.com:3500/test-request', autUrl, 'fetch', 'same-origin')).to.be.false + }) + }) + + context('xhr', () => { + it('returns true if credentials are set to true, regardless of site context', () => { + // same-origin + expect(shouldAttachAndSetCookies('http://localhost:8080/test-request', autUrl, 'xhr', true)).to.be.true + // same-site + expect(shouldAttachAndSetCookies('http://localhost:8081/test-request', autUrl, 'xhr', true)).to.be.true + // cross-site + expect(shouldAttachAndSetCookies('http://www.foobar.com:3500/test-request', autUrl, 'xhr', true)).to.be.true + }) + + it('returns true if the site context is same-origin, regardless of credential level', () => { + expect(shouldAttachAndSetCookies('http://localhost:8080/test-request', autUrl, 'xhr', true)).to.be.true + expect(shouldAttachAndSetCookies('http://localhost:8080/test-request', autUrl, 'xhr', false)).to.be.true + }) + + it('returns false if site context is same-site and "withCredentials" is set to false', () => { + expect(shouldAttachAndSetCookies('http://localhost:8081/test-request', autUrl, 'xhr', false)).to.be.false + }) + + it('returns false if site context is cross-site and "withCredentials" is set to false', () => { + expect(shouldAttachAndSetCookies('http://www.foobar.com:3500/test-request', autUrl, 'xhr', false)).to.be.false + }) + }) + + context('misc', () => { + it('returns true if the resource type is unknown, but the request comes from the aut frame (could be a navigation request to set top level cookies)', () => { + // possibly a navigation request for a document or another resource. If this is the case, attach cookies based on the siteContext and cookies should be attached regardless + expect(shouldAttachAndSetCookies('http://www.foobar.com:3500/index.html', autUrl, undefined, undefined, true)).to.be.true + }) + + it('returns true if the resource type is unknown, but the request is same-origin', () => { + // possibly a navigation request for a document or another resource. If this is the case, attach cookies based on the siteContext and cookies should be attached regardless + expect(shouldAttachAndSetCookies('http://www.foobar.com:3500/index.html', 'http://www.foobar.com:3500/index.html')).to.be.true + }) + + it('returns false if the resource type is unknown and the request does NOT come from the AUTFrame', () => { + // possibly a navigation request for a document or another resource. If this is the case, attach cookies based on the siteContext and cookies should be attached regardless + expect(shouldAttachAndSetCookies('http://www.foobar.com:3500/index.html', autUrl)).to.be.false + }) + }) +}) + +context('.calculateSiteContext', () => { + const autUrl = 'https://staging.google.com' + + it('calculates same-origin correctly for same-origin / same-site urls', () => { + expect(calculateSiteContext(autUrl, 'https://staging.google.com')).to.equal('same-origin') + }) + + it('calculates same-site correctly for cross-origin / same-site urls', () => { + expect(calculateSiteContext(autUrl, 'https://app.google.com')).to.equal('same-site') + }) + + it('calculates cross-site correctly for cross-origin / cross-site urls', () => { + expect(calculateSiteContext(autUrl, 'https://staging.google2.com')).to.equal('cross-site') + }) +}) diff --git a/packages/proxy/test/unit/http/util/top-simulation.spec.ts b/packages/proxy/test/unit/http/util/top-simulation.spec.ts new file mode 100644 index 000000000000..cb29a6fd4b8a --- /dev/null +++ b/packages/proxy/test/unit/http/util/top-simulation.spec.ts @@ -0,0 +1,61 @@ +import { expect } from 'chai' +import sinon from 'sinon' +import { HttpMiddlewareThis } from '../../../../lib/http' +import { doesTopNeedToBeSimulated } from '../../../../lib/http/util/top-simulation' + +context('.doesTopNeedToBeSimulated', () => { + const autUrl = 'http://localhost:8080' + + it('returns false when URL matches the AUT Url origin policy and the AUT Url exists and is NOT the AUT frame', () => { + const mockCtx: HttpMiddlewareThis = { + getAUTUrl: sinon.stub().returns(autUrl), + req: { + isAUTFrame: false, + }, + remoteStates: { + isPrimaryOrigin: sinon.stub().returns(true), + }, + } + + expect(doesTopNeedToBeSimulated(mockCtx)).to.be.false + }) + + /** + * We want to make an exception for the AUT Frame to attach/set cookies as redirects could have set cookies in the browsers which would later be attached + * + * If this proves problematic in the future, we can likely leverage the sec-fetch-mode header for requests and 3xx status for responses to determine + * whether or not cookies need to be attached from the jar or set into the jar + */ + it('returns true when URL matches the AUT Url origin policy and the AUT Url exists and is the AUT frame', () => { + const mockCtx: HttpMiddlewareThis = { + getAUTUrl: sinon.stub().returns(autUrl), + req: { + isAUTFrame: true, + }, + remoteStates: { + isPrimaryOrigin: sinon.stub().returns(true), + }, + } + + expect(doesTopNeedToBeSimulated(mockCtx)).to.be.true + }) + + it('returns false when AUT Url is not defined, regardless of primary origin stack', () => { + const mockCtx: HttpMiddlewareThis = { + getAUTUrl: sinon.stub().returns(undefined), + } + + expect(doesTopNeedToBeSimulated(mockCtx)).to.be.false + }) + + it('returns true when AUT Url is defined but AUT Url no longer matches the primary origin', () => { + const mockCtx: HttpMiddlewareThis = { + getAUTUrl: sinon.stub().returns(autUrl), + remoteStates: { + isPrimaryOrigin: sinon.stub().returns(false), + }, + } + + expect(doesTopNeedToBeSimulated(mockCtx)).to.be.true + }) +}) diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index 8f276d7a6c03..0537fb8b30ab 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -342,19 +342,19 @@ const _listenForFrameTreeChanges = (client) => { client.on('Page.frameDetached', _updateFrameTree(client, 'Page.frameDetached')) } -const _continueRequest = (client, params, header?) => { +const _continueRequest = (client, params, headers?) => { const details: Protocol.Fetch.ContinueRequestRequest = { requestId: params.requestId, } - if (header) { + if (headers && headers.length) { // headers are received as an object but need to be an array // to modify them const currentHeaders = _.map(params.request.headers, (value, name) => ({ name, value })) details.headers = [ ...currentHeaders, - header, + ...headers, ] } @@ -403,20 +403,41 @@ const _handlePausedRequests = async (client) => { // adds a header to the request to mark it as a request for the AUT frame // itself, so the proxy can utilize that for injection purposes client.on('Fetch.requestPaused', async (params: Protocol.Fetch.RequestPausedEvent) => { + const addedHeaders: { + name: string + value: string + }[] = [] + + /** + * Unlike the the web extension or Electrons's onBeforeSendHeaders, CDP can discern the difference + * between fetch or xhr resource types. Because of this, we set X-Cypress-Is-XHR-Or-Fetch to either + * 'xhr' or 'fetch' with CDP so the middleware can assume correct defaults in case credential/resourceTypes + * are not sent to the server. + * @see https://chromedevtools.github.io/devtools-protocol/tot/Network/#type-ResourceType + */ + if (params.resourceType === 'XHR' || params.resourceType === 'Fetch') { + debug('add X-Cypress-Is-XHR-Or-Fetch header to: %s', params.request.url) + addedHeaders.push({ + name: 'X-Cypress-Is-XHR-Or-Fetch', + value: params.resourceType.toLowerCase(), + }) + } + if ( // is a script, stylesheet, image, etc params.resourceType !== 'Document' || !(await _isAUTFrame(params.frameId)) ) { - return _continueRequest(client, params) + return _continueRequest(client, params, addedHeaders) } debug('add X-Cypress-Is-AUT-Frame header to: %s', params.request.url) - - _continueRequest(client, params, { + addedHeaders.push({ name: 'X-Cypress-Is-AUT-Frame', value: 'true', }) + + return _continueRequest(client, params, addedHeaders) }) } diff --git a/packages/server/lib/browsers/electron.ts b/packages/server/lib/browsers/electron.ts index e56ed3680246..9d420d92c5da 100644 --- a/packages/server/lib/browsers/electron.ts +++ b/packages/server/lib/browsers/electron.ts @@ -384,6 +384,22 @@ export = { // adds a header to the request to mark it as a request for the AUT frame // itself, so the proxy can utilize that for injection purposes win.webContents.session.webRequest.onBeforeSendHeaders((details, cb) => { + const requestModifications = { + requestHeaders: { + ...details.requestHeaders, + /** + * Unlike CDP, Electrons's onBeforeSendHeaders resourceType cannot discern the difference + * between fetch or xhr resource types, but classifies both as 'xhr'. Because of this, + * we set X-Cypress-Is-XHR-Or-Fetch to true if the request is made with 'xhr' or 'fetch' so the + * middleware doesn't incorrectly assume which request type is being sent + * @see https://www.electronjs.org/docs/latest/api/web-request#webrequestonbeforesendheadersfilter-listener + */ + ...(details.resourceType === 'xhr') ? { + 'X-Cypress-Is-XHR-Or-Fetch': 'true', + } : {}, + }, + } + if ( // isn't an iframe details.resourceType !== 'subFrame' @@ -392,14 +408,14 @@ export = { // is the spec frame, not the AUT || details.url.includes('__cypress') ) { - cb({}) + cb(requestModifications) return } cb({ requestHeaders: { - ...details.requestHeaders, + ...requestModifications.requestHeaders, 'X-Cypress-Is-AUT-Frame': 'true', }, }) diff --git a/packages/server/lib/controllers/files.js b/packages/server/lib/controllers/files.js index 62e1d5811a2d..d5516d13a043 100644 --- a/packages/server/lib/controllers/files.js +++ b/packages/server/lib/controllers/files.js @@ -5,6 +5,7 @@ const cwd = require('../cwd') const debug = require('debug')('cypress:server:controllers') const { escapeFilenameInUrl } = require('../util/escape_filename') const { getCtx } = require('@packages/data-context') +const { cors } = require('@packages/network') module.exports = { @@ -40,7 +41,7 @@ module.exports = { handleCrossOriginIframe (req, res) { const iframePath = cwd('lib', 'html', 'spec-bridge-iframe.html') - const domain = req.hostname + const domain = cors.getSuperDomain(req.proxiedUrl) const iframeOptions = { domain, diff --git a/packages/server/lib/remote_states.ts b/packages/server/lib/remote_states.ts index a9b8ad78dcca..397dfd459376 100644 --- a/packages/server/lib/remote_states.ts +++ b/packages/server/lib/remote_states.ts @@ -37,6 +37,7 @@ const debug = Debug('cypress:server:remote-states') * port: 443 * tld: "com" * domain: "google" + * protocol: "https" * } * } */ @@ -52,7 +53,7 @@ export class RemoteStates { } get (url: string) { - const state = this.remoteStates.get(cors.getOriginPolicy(url)) + const state = this.remoteStates.get(cors.getSuperDomainOrigin(url)) debug('getting remote state: %o for: %s', state, url) @@ -68,7 +69,7 @@ export class RemoteStates { } isPrimaryOrigin (url: string): boolean { - return this.primaryOriginKey === cors.getOriginPolicy(url) + return this.primaryOriginKey === cors.getSuperDomainOrigin(url) } reset () { @@ -90,7 +91,7 @@ export class RemoteStates { if (_.isString(urlOrState)) { const remoteOrigin = uri.origin(urlOrState) - const remoteProps = cors.parseUrlIntoDomainTldPort(remoteOrigin) + const { subdomain: _unused, ...remoteProps } = cors.parseUrlIntoHostProtocolDomainTldPort(remoteOrigin) if ((urlOrState === '') || !fullyQualifiedRe.test(urlOrState)) { state = { @@ -115,26 +116,26 @@ export class RemoteStates { state = urlOrState } - const remoteOriginPolicy = cors.getOriginPolicy(state.origin) + const remoteOrigin = cors.getSuperDomainOrigin(state.origin) - this.currentOriginKey = remoteOriginPolicy + this.currentOriginKey = remoteOrigin if (isPrimaryOrigin) { // convert map to array const stateArray = Array.from(this.remoteStates.entries()) // set the primary remote state and convert back to map - stateArray[0] = [remoteOriginPolicy, state] + stateArray[0] = [remoteOrigin, state] this.remoteStates = new Map(stateArray) - this.primaryOriginKey = remoteOriginPolicy + this.primaryOriginKey = remoteOrigin } else { - this.remoteStates.set(remoteOriginPolicy, state) + this.remoteStates.set(remoteOrigin, state) } - debug('setting remote state %o for %s', state, remoteOriginPolicy) + debug('setting remote state %o for %s', state, remoteOrigin) - return this.get(remoteOriginPolicy) as Cypress.RemoteState + return this.get(remoteOrigin) as Cypress.RemoteState } private get config () { diff --git a/packages/server/lib/server-base.ts b/packages/server/lib/server-base.ts index d1f5c9aa530a..527a23d18996 100644 --- a/packages/server/lib/server-base.ts +++ b/packages/server/lib/server-base.ts @@ -35,6 +35,7 @@ import { RemoteStates } from './remote_states' import { cookieJar } from './util/cookies' import type { Automation } from './automation/automation' import type { AutomationCookie } from './automation/cookies' +import { resourceTypeAndCredentialManager, ResourceTypeAndCredentialManager } from './util/resourceTypeAndCredentialManager' const debug = Debug('cypress:server:server-base') @@ -112,6 +113,7 @@ export abstract class ServerBase { protected request: Request protected isListening: boolean protected socketAllowed: SocketAllowed + protected resourceTypeAndCredentialManager: ResourceTypeAndCredentialManager protected _fileServer protected _baseUrl: string | null protected _server?: DestroyableHttpServer @@ -141,6 +143,8 @@ export abstract class ServerBase { fileServerPort: this._fileServer?.port(), } }) + + this.resourceTypeAndCredentialManager = resourceTypeAndCredentialManager } ensureProp = ensureProp @@ -181,6 +185,8 @@ export abstract class ServerBase { this.socket.toDriver('cross:origin:automation:cookies', cookies) }) + + this.socket.localBus.on('request:sent:with:credentials', this.resourceTypeAndCredentialManager.set) } abstract createServer ( @@ -218,7 +224,12 @@ export abstract class ServerBase { clientCertificates.loadClientCertificateConfig(config) - this.createNetworkProxy({ config, getAutomation, remoteStates: this._remoteStates, shouldCorrelatePreRequests }) + this.createNetworkProxy({ + config, getAutomation, + remoteStates: this._remoteStates, + resourceTypeAndCredentialManager: this.resourceTypeAndCredentialManager, + shouldCorrelatePreRequests, + }) if (config.experimentalSourceRewriting) { createInitialWorkers() @@ -310,7 +321,7 @@ export abstract class ServerBase { return e } - createNetworkProxy ({ config, getAutomation, remoteStates, shouldCorrelatePreRequests }) { + createNetworkProxy ({ config, getAutomation, remoteStates, resourceTypeAndCredentialManager, shouldCorrelatePreRequests }) { const getFileServerToken = () => { return this._fileServer.token } @@ -328,6 +339,7 @@ export abstract class ServerBase { netStubbingState: this.netStubbingState, request: this.request, serverBus: this._eventBus, + resourceTypeAndCredentialManager, }) } @@ -341,6 +353,7 @@ export abstract class ServerBase { this.networkProxy.reset() this.netStubbingState.reset() this._remoteStates.reset() + this.resourceTypeAndCredentialManager.clear() } const io = this.socket.startListening(this.server, automation, config, options) @@ -446,7 +459,7 @@ export abstract class ServerBase { // get the port & hostname from host header const fullUrl = `${req.connection.encrypted ? 'https' : 'http'}://${host}` const { hostname, protocol } = url.parse(fullUrl) - const { port } = cors.parseUrlIntoDomainTldPort(fullUrl) + const { port } = cors.parseUrlIntoHostProtocolDomainTldPort(fullUrl) const onProxyErr = (err, req, res) => { return debug('Got ERROR proxying websocket connection', { err, port, protocol, hostname, req }) @@ -475,7 +488,7 @@ export abstract class ServerBase { reset () { this._networkProxy?.reset() - + this.resourceTypeAndCredentialManager.clear() const baseUrl = this._baseUrl ?? '' return this._remoteStates.set(baseUrl) diff --git a/packages/server/lib/server-e2e.ts b/packages/server/lib/server-e2e.ts index 8889be8f7121..74438f30c536 100644 --- a/packages/server/lib/server-e2e.ts +++ b/packages/server/lib/server-e2e.ts @@ -307,7 +307,7 @@ export class ServerE2E extends ServerBase { // TODO: think about moving this logic back into the frontend so that the driver can be in control // of when to buffer and set the remote state if (isOk && details.isHtml) { - const isCrossOrigin = options.hasAlreadyVisitedUrl && !cors.urlOriginsMatch(primaryRemoteState.origin, newUrl) || options.isFromSpecBridge + const isCrossOrigin = options.hasAlreadyVisitedUrl && !cors.urlsSuperDomainOriginMatch(primaryRemoteState.origin, newUrl || '') || options.isFromSpecBridge if (!handlingLocalFile) { this._remoteStates.set(newUrl as string, options, !isCrossOrigin) diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index 7829c1ee691b..590b309cf4e1 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -463,6 +463,8 @@ export class SocketBase { return resetRenderedHTMLOrigins() case 'cross:origin:automation:cookies:received': return this.localBus.emit('cross:origin:automation:cookies:received') + case 'request:sent:with:credentials': + return this.localBus.emit('request:sent:with:credentials', args[0]) default: throw new Error(`You requested a backend event we cannot handle: ${eventName}`) } diff --git a/packages/server/lib/util/resourceTypeAndCredentialManager.ts b/packages/server/lib/util/resourceTypeAndCredentialManager.ts new file mode 100644 index 000000000000..d573c3106716 --- /dev/null +++ b/packages/server/lib/util/resourceTypeAndCredentialManager.ts @@ -0,0 +1,88 @@ +import md5 from 'md5' +import Debug from 'debug' +import type { RequestCredentialLevel, RequestResourceType } from '@packages/proxy' + +type AppliedCredentialByUrlAndResourceMap = Map> + +const debug = Debug('cypress:server:util:resource-type-and-credential') + +const hashUrl = (url: string): string => { + return md5(decodeURIComponent(url)) +} + +// leverage a singleton Map throughout the server to prevent clashes with this context bindings +const _appliedCredentialByUrlAndResourceMap: AppliedCredentialByUrlAndResourceMap = new Map() + +class ResourceTypeAndCredentialManagerClass { + get (url: string, optionalResourceType?: RequestResourceType): { + resourceType: RequestResourceType + credentialStatus: RequestCredentialLevel + } { + const hashKey = hashUrl(url) + + debug(`credentials request received for request url ${url}, hashKey ${hashKey}`) + let value: { + resourceType: RequestResourceType + credentialStatus: RequestCredentialLevel + } | undefined + + const credentialsObj = _appliedCredentialByUrlAndResourceMap.get(hashKey) + + if (credentialsObj) { + // remove item from queue + value = credentialsObj?.shift() + debug(`credential value found ${value}`) + } + + // if value is undefined for any reason, apply defaults and assume xhr if no optionalResourceType + // optionalResourceType should be provided with CDP based browsers, so at least we have a fallback that is more accurate + if (value === undefined) { + value = { + resourceType: optionalResourceType || 'xhr', + credentialStatus: optionalResourceType === 'fetch' ? 'same-origin' : false, + } + } + + return value + } + + set ({ url, + resourceType, + credentialStatus, + }: { + url: string + resourceType: RequestResourceType + credentialStatus: RequestCredentialLevel + }) { + const hashKey = hashUrl(url) + + debug(`credentials request stored for request url ${url}, resourceType ${resourceType}, hashKey ${hashKey}`) + + let urlHashValue = _appliedCredentialByUrlAndResourceMap.get(hashKey) + + if (!urlHashValue) { + _appliedCredentialByUrlAndResourceMap.set(hashKey, [{ + resourceType, + credentialStatus, + }]) + } else { + urlHashValue.push({ + resourceType, + credentialStatus, + }) + } + } + + clear () { + _appliedCredentialByUrlAndResourceMap.clear() + } +} + +// export as a singleton +export const resourceTypeAndCredentialManager = new ResourceTypeAndCredentialManagerClass() + +// export but only as a type. We do NOT want others to create instances of the class as it is intended to be a singleton +export type ResourceTypeAndCredentialManager = ResourceTypeAndCredentialManagerClass diff --git a/packages/server/test/integration/server_spec.js b/packages/server/test/integration/server_spec.js index adae863e4700..22daf4c01105 100644 --- a/packages/server/test/integration/server_spec.js +++ b/packages/server/test/integration/server_spec.js @@ -606,6 +606,7 @@ describe('Server', () => { domain: 'go', tld: 'com', port: '80', + protocol: 'http:', }, }) }) @@ -922,6 +923,7 @@ describe('Server', () => { domain: 'google', tld: 'com', port: '80', + protocol: 'http:', }, }) }) @@ -952,6 +954,7 @@ describe('Server', () => { domain: 'cypress', port: '80', tld: 'io', + protocol: 'http:', }, origin: 'http://cypress.io', strategy: 'http', @@ -989,6 +992,7 @@ describe('Server', () => { domain: 'cypress', port: '80', tld: 'io', + protocol: 'http:', }, origin: 'http://www.cypress.io', strategy: 'http', @@ -1022,6 +1026,7 @@ describe('Server', () => { domain: '', port: '3500', tld: 'localhost', + protocol: 'http:', }, origin: 'http://localhost:3500', strategy: 'http', @@ -1052,6 +1057,7 @@ describe('Server', () => { domain: '', port: '3500', tld: 'localhost', + protocol: 'http:', }, origin: 'http://localhost:3500', strategy: 'http', @@ -1087,6 +1093,7 @@ describe('Server', () => { domain: '', port: '3500', tld: 'localhost', + protocol: 'http:', }, origin: 'http://localhost:3500', strategy: 'http', @@ -1175,6 +1182,7 @@ describe('Server', () => { domain: 'google', tld: 'com', port: '80', + protocol: 'http:', }, }) }).then(() => { @@ -1257,6 +1265,7 @@ describe('Server', () => { domain: 'google', tld: 'com', port: '80', + protocol: 'http:', }, }) }).then(() => { @@ -1330,6 +1339,7 @@ describe('Server', () => { domain: 'google', tld: 'com', port: '80', + protocol: 'http:', }, }) }) @@ -1374,6 +1384,7 @@ describe('Server', () => { domain: 'foobar', tld: 'com', port: '8443', + protocol: 'https:', }, }) }).then(() => { @@ -1447,6 +1458,7 @@ describe('Server', () => { domain: 'foobar', tld: 'com', port: '8443', + protocol: 'https:', }, }) }) @@ -1499,6 +1511,7 @@ describe('Server', () => { domain: '', tld: 's3.amazonaws.com', port: '443', + protocol: 'https:', }, }) }).then(() => { @@ -1578,6 +1591,7 @@ describe('Server', () => { domain: '', tld: 's3.amazonaws.com', port: '443', + protocol: 'https:', }, }) }) diff --git a/packages/server/test/unit/browsers/chrome_spec.js b/packages/server/test/unit/browsers/chrome_spec.js index 428b0a6b6a20..2d1def952876 100644 --- a/packages/server/test/unit/browsers/chrome_spec.js +++ b/packages/server/test/unit/browsers/chrome_spec.js @@ -464,6 +464,70 @@ describe('lib/browsers/chrome', () => { }) }) + it('appends X-Cypress-Is-XHR-Or-Fetch header to fetch request', async function () { + await chrome.open('chrome', 'http://', withExperimentalFlagOn, this.automation) + + this.pageCriClient.on.withArgs('Page.frameAttached').yield() + + await this.pageCriClient.on.withArgs('Fetch.requestPaused').args[0][1]({ + frameId: 'aut-frame-id', + requestId: '1234', + resourceType: 'Fetch', + request: { + url: 'http://localhost:3000/test-request', + headers: { + 'X-Foo': 'Bar', + }, + }, + }) + + expect(this.pageCriClient.send).to.be.calledWith('Fetch.continueRequest', { + requestId: '1234', + headers: [ + { + name: 'X-Foo', + value: 'Bar', + }, + { + name: 'X-Cypress-Is-XHR-Or-Fetch', + value: 'fetch', + }, + ], + }) + }) + + it('appends X-Cypress-Is-XHR-Or-Fetch header to xhr request', async function () { + await chrome.open('chrome', 'http://', withExperimentalFlagOn, this.automation) + + this.pageCriClient.on.withArgs('Page.frameAttached').yield() + + await this.pageCriClient.on.withArgs('Fetch.requestPaused').args[0][1]({ + frameId: 'aut-frame-id', + requestId: '1234', + resourceType: 'XHR', + request: { + url: 'http://localhost:3000/test-request', + headers: { + 'X-Foo': 'Bar', + }, + }, + }) + + expect(this.pageCriClient.send).to.be.calledWith('Fetch.continueRequest', { + requestId: '1234', + headers: [ + { + name: 'X-Foo', + value: 'Bar', + }, + { + name: 'X-Cypress-Is-XHR-Or-Fetch', + value: 'xhr', + }, + ], + }) + }) + it('gets frame tree on Page.frameAttached', async function () { await chrome.open('chrome', 'http://', withExperimentalFlagOn, this.automation) diff --git a/packages/server/test/unit/browsers/electron_spec.js b/packages/server/test/unit/browsers/electron_spec.js index c981ce4f43d7..54a4f81e18ff 100644 --- a/packages/server/test/unit/browsers/electron_spec.js +++ b/packages/server/test/unit/browsers/electron_spec.js @@ -358,7 +358,9 @@ describe('lib/browsers/electron', () => { this.win.webContents.session.webRequest.onBeforeSendHeaders.lastCall.args[0](details, cb) expect(cb).to.be.calledOnce - expect(cb).to.be.calledWith({}) + expect(cb).to.be.calledWith({ + requestHeaders: {}, + }) }) }) @@ -378,7 +380,9 @@ describe('lib/browsers/electron', () => { this.win.webContents.session.webRequest.onBeforeSendHeaders.lastCall.args[0](details, cb) expect(cb).to.be.calledOnce - expect(cb).to.be.calledWith({}) + expect(cb).to.be.calledWith({ + requestHeaders: {}, + }) }) }) @@ -402,7 +406,9 @@ describe('lib/browsers/electron', () => { this.win.webContents.session.webRequest.onBeforeSendHeaders.lastCall.args[0](details, cb) expect(cb).to.be.calledOnce - expect(cb).to.be.calledWith({}) + expect(cb).to.be.calledWith({ + requestHeaders: {}, + }) }) }) @@ -424,7 +430,9 @@ describe('lib/browsers/electron', () => { this.win.webContents.session.webRequest.onBeforeSendHeaders.lastCall.args[0](details, cb) - expect(cb).to.be.calledWith({}) + expect(cb).to.be.calledWith({ + requestHeaders: {}, + }) }) }) @@ -441,7 +449,9 @@ describe('lib/browsers/electron', () => { this.win.webContents.session.webRequest.onBeforeSendHeaders.lastCall.args[0](details, cb) - expect(cb).to.be.calledWith({}) + expect(cb).to.be.calledWith({ + requestHeaders: {}, + }) }) }) @@ -475,6 +485,37 @@ describe('lib/browsers/electron', () => { }) }) }) + + it('adds X-Cypress-Is-XHR-Or-Fetch header if xhr request (includes fetch)', function () { + sinon.stub(this.win.webContents.session.webRequest, 'onBeforeSendHeaders') + + return electron._launch(this.win, this.url, this.automation, this.options) + .then(() => { + const details = { + resourceType: 'xhr', + frame: { + parent: { + parent: null, + }, + }, + url: 'http://localhost:3000/test-request', + requestHeaders: { + 'X-Foo': 'Bar', + }, + } + const cb = sinon.stub() + + this.win.webContents.session.webRequest.onBeforeSendHeaders.lastCall.args[0](details, cb) + + expect(cb).to.be.calledOnce + expect(cb).to.be.calledWith({ + requestHeaders: { + 'X-Foo': 'Bar', + 'X-Cypress-Is-XHR-Or-Fetch': 'true', + }, + }) + }) + }) }) }) diff --git a/packages/server/test/unit/remote_states.spec.ts b/packages/server/test/unit/remote_states.spec.ts index 6328cf10acd5..84195ad93809 100644 --- a/packages/server/test/unit/remote_states.spec.ts +++ b/packages/server/test/unit/remote_states.spec.ts @@ -29,6 +29,7 @@ describe('remote states', () => { port: '3500', domain: '', tld: 'localhost', + protocol: 'http:', }, }) }) @@ -52,6 +53,7 @@ describe('remote states', () => { port: '3500', domain: '', tld: 'localhost', + protocol: 'http:', }, }) @@ -69,6 +71,7 @@ describe('remote states', () => { port: '3500', domain: '', tld: 'localhost', + protocol: 'http:', }, }) }) @@ -88,6 +91,7 @@ describe('remote states', () => { port: '3500', domain: '', tld: 'localhost', + protocol: 'http:', }, }) }) @@ -107,6 +111,7 @@ describe('remote states', () => { port: '3500', domain: '', tld: 'localhost', + protocol: 'http:', }, }) }) @@ -156,6 +161,7 @@ describe('remote states', () => { port: '443', domain: 'google', tld: 'com', + protocol: 'https:', }, }) }) @@ -177,6 +183,7 @@ describe('remote states', () => { port: '443', domain: 'google', tld: 'com', + protocol: 'https:', }, }) @@ -200,6 +207,7 @@ describe('remote states', () => { port: '443', domain: 'google', tld: 'com', + protocol: 'https:', }, }) @@ -224,6 +232,7 @@ describe('remote states', () => { port: '443', domain: 'google', tld: 'com', + protocol: 'https:', }, }) @@ -241,6 +250,7 @@ describe('remote states', () => { port: '443', domain: 'google', tld: 'com', + protocol: 'https:', }, }) }) @@ -258,6 +268,7 @@ describe('remote states', () => { port: '443', domain: 'google', tld: 'com', + protocol: 'https:', }, }) }) @@ -275,6 +286,7 @@ describe('remote states', () => { port: '80', domain: 'google', tld: 'com', + protocol: 'http:', }, }) }) @@ -292,6 +304,7 @@ describe('remote states', () => { port: '4200', domain: '', tld: 'localhost', + protocol: 'http:', }, }) }) @@ -333,6 +346,7 @@ describe('remote states', () => { port: '80', domain: 'foobar', tld: 'com', + protocol: 'http:', }, } diff --git a/packages/server/test/unit/util/resourceTypeAndCredential_spec.ts b/packages/server/test/unit/util/resourceTypeAndCredential_spec.ts new file mode 100644 index 000000000000..a0422885fdc0 --- /dev/null +++ b/packages/server/test/unit/util/resourceTypeAndCredential_spec.ts @@ -0,0 +1,86 @@ +import { expect } from 'chai' +import { resourceTypeAndCredentialManager } from '../../../lib/util/resourceTypeAndCredentialManager' + +context('resourceTypeAndCredentialManager Singleton', () => { + beforeEach(() => { + resourceTypeAndCredentialManager.clear() + resourceTypeAndCredentialManager.set({ + url: 'www.foobar.com/test-request', + resourceType: 'xhr', + credentialStatus: true, + }) + + resourceTypeAndCredentialManager.set({ + url: 'www.foobar.com%2Ftest-request-2', + resourceType: 'fetch', + credentialStatus: 'same-origin', + }) + + resourceTypeAndCredentialManager.set({ + url: 'www.foobar.com/test-request-2', + resourceType: 'fetch', + credentialStatus: 'include', + }) + + resourceTypeAndCredentialManager.set({ + url: 'www.foobar.com/test-request', + resourceType: 'fetch', + credentialStatus: 'omit', + }) + + resourceTypeAndCredentialManager.set({ + url: 'www.foobar.com/test-request', + resourceType: 'fetch', + credentialStatus: 'include', + }) + }) + + it('gets the first record out of the queue matching the absolute url and removes it', () => { + expect(resourceTypeAndCredentialManager.get('www.foobar.com/test-request')).to.deep.equal({ + resourceType: 'xhr', + credentialStatus: true, + }) + + expect(resourceTypeAndCredentialManager.get('www.foobar.com/test-request')).to.deep.equal({ + resourceType: 'fetch', + credentialStatus: 'omit', + }) + + expect(resourceTypeAndCredentialManager.get('www.foobar.com/test-request')).to.deep.equal({ + resourceType: 'fetch', + credentialStatus: 'include', + }) + + // the default as no other records should exist in the map for this URL + expect(resourceTypeAndCredentialManager.get('www.foobar.com/test-request')).to.deep.equal({ + resourceType: 'xhr', + credentialStatus: false, + }) + }) + + it('can locate a record hash even when the URL is encoded', () => { + expect(resourceTypeAndCredentialManager.get('www.foobar.com%2Ftest-request')).to.deep.equal({ + resourceType: 'xhr', + credentialStatus: true, + }) + }) + + it('applies defaults if a record cannot be found without a resourceType', () => { + expect(resourceTypeAndCredentialManager.get('www.barbaz.com/test-request')).to.deep.equal({ + resourceType: 'xhr', + credentialStatus: false, + }) + }) + + it('applies defaults if a record cannot be found with a resourceType', () => { + expect(resourceTypeAndCredentialManager.get('www.barbaz.com/test-request', 'xhr')).to.deep.equal({ + resourceType: 'xhr', + credentialStatus: false, + }) + + expect(resourceTypeAndCredentialManager.get('www.barbaz.com/test-request', 'fetch')).to.deep.equal({ + resourceType: 'fetch', + credentialStatus: 'same-origin', + }) + }) +})