diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 3e39c4342c6d..6b42b6c48165 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -3,6 +3,10 @@ _Released 03/1/2023 (PENDING)_ +**Features:** + +- It is now possible to set `hostOnly` cookies with [`cy.setCookie()`](https://docs.cypress.io/api/commands/setcookie) for a given domain. Addresses [#16856](https://github.com/cypress-io/cypress/issues/16856) and [#17527](https://github.com/cypress-io/cypress/issues/17527). + **Bugfixes:** - Fixed an issue where cookies were being duplicated with the same hostname, but a prepended dot. Fixed an issue where cookies may not be expiring correctly. Fixes [#25174](https://github.com/cypress-io/cypress/issues/25174), [#25205](https://github.com/cypress-io/cypress/issues/25205) and [#25495](https://github.com/cypress-io/cypress/issues/25495). diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index 17a27f2b3eda..fb927383598c 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -3569,12 +3569,49 @@ declare namespace Cypress { action: 'select' | 'drag-drop' } + /** + * Options that control how the `cy.setCookie` command + * sets the cookie in the browser. + * @see https://on.cypress.io/setcookie#Arguments + */ interface SetCookieOptions extends Loggable, Timeoutable { + /** + * The path of the cookie. + * @default "/" + */ path: string + /** + * Represents the domain the cookie belongs to (e.g. "docs.cypress.io", "github.com"). + * @default location.hostname + */ domain: string + /** + * Whether a cookie's scope is limited to secure channels, such as HTTPS. + * @default false + */ secure: boolean + /** + * Whether or not the cookie is HttpOnly, meaning the cookie is inaccessible to client-side scripts. + * The Cypress cookie API has access to HttpOnly cookies. + * @default false + */ httpOnly: boolean + /** + * Whether or not the cookie is a host-only cookie, meaning the request's host must exactly match the domain of the cookie. + * @default false + */ + hostOnly: boolean + /** + * The cookie's expiry time, specified in seconds since Unix Epoch. + * The default is expiry is 20 years in the future from current time. + */ expiry: number + /** + * The cookie's SameSite value. If set, should be one of `lax`, `strict`, or `no_restriction`. + * `no_restriction` is the equivalent of `SameSite=None`. Pass `undefined` to use the browser's default. + * Note: `no_restriction` can only be used if the secure flag is set to `true`. + * @default undefined + */ sameSite: SameSiteStatus } @@ -6104,6 +6141,7 @@ declare namespace Cypress { value: string path: string domain: string + hostOnly?: boolean httpOnly: boolean secure: boolean expiry?: number diff --git a/cli/types/tests/cypress-tests.ts b/cli/types/tests/cypress-tests.ts index 26010d29df3a..4b036baa10e5 100644 --- a/cli/types/tests/cypress-tests.ts +++ b/cli/types/tests/cypress-tests.ts @@ -1063,6 +1063,14 @@ namespace CypressSetCookieTests { expiry: 12345, sameSite: 'lax', }) + cy.setCookie('name', 'value', { + domain: 'www.foobar.com', + path: '/', + secure: false, + httpOnly: false, + hostOnly: true, + sameSite: 'lax', + }) cy.setCookie('name', 'value', { log: true, timeout: 10, domain: 'localhost' }) cy.setCookie('name') // $ExpectError diff --git a/packages/driver/cypress/e2e/commands/cookies.cy.js b/packages/driver/cypress/e2e/commands/cookies.cy.js index e87b5f5b9310..e47455d0faf8 100644 --- a/packages/driver/cypress/e2e/commands/cookies.cy.js +++ b/packages/driver/cypress/e2e/commands/cookies.cy.js @@ -562,6 +562,40 @@ describe('src/cy/commands/cookies - no stub', () => { }) }) }) + + it('sets the cookie on the specified domain as hostOnly and validates hostOnly property persists through related commands that fetch cookies', () => { + const isWebkit = Cypress.browser.name.includes('webkit') + + cy.visit('http://www.barbaz.com:3500/fixtures/generic.html') + cy.setCookie('foo', 'bar', { hostOnly: true }) + + cy.getCookie('foo').its('domain').should('eq', 'www.barbaz.com') + if (!isWebkit) { + cy.getCookie('foo').its('hostOnly').should('eq', true) + } + + cy.getCookies().then((cookies) => { + expect(cookies).to.have.lengthOf(1) + + const cookie = cookies[0] + + expect(cookie).to.have.property('domain', 'www.barbaz.com') + if (!isWebkit) { + expect(cookie).to.have.property('hostOnly', true) + } + }) + + cy.getAllCookies().then((cookies) => { + expect(cookies).to.have.lengthOf(1) + + const cookie = cookies[0] + + expect(cookie).to.have.property('domain', 'www.barbaz.com') + if (!isWebkit) { + expect(cookie).to.have.property('hostOnly', true) + } + }) + }) }) describe('src/cy/commands/cookies', () => { @@ -785,7 +819,7 @@ describe('src/cy/commands/cookies', () => { it('#consoleProps', () => { cy.getCookies().then(function (cookies) { - expect(cookies).to.deep.eq([{ name: 'foo', value: 'bar', domain: 'localhost', path: '/', secure: true, httpOnly: false }]) + expect(cookies).to.deep.eq([{ name: 'foo', value: 'bar', domain: 'localhost', path: '/', secure: true, httpOnly: false, hostOnly: false }]) const c = this.lastLog.invoke('consoleProps') expect(c['Yielded']).to.deep.eq(cookies) @@ -938,7 +972,7 @@ describe('src/cy/commands/cookies', () => { it('#consoleProps', () => { cy.getCookies().then(function (cookies) { - expect(cookies).to.deep.eq([{ name: 'foo', value: 'bar', domain: 'localhost', path: '/', secure: true, httpOnly: false }]) + expect(cookies).to.deep.eq([{ name: 'foo', value: 'bar', domain: 'localhost', path: '/', secure: true, httpOnly: false, hostOnly: false }]) const c = this.lastLog.invoke('consoleProps') expect(c['Yielded']).to.deep.eq(cookies) @@ -955,7 +989,7 @@ describe('src/cy/commands/cookies', () => { }) cy.getCookie('foo').should('deep.eq', { - name: 'foo', value: 'bar', domain: 'localhost', path: '/', secure: true, httpOnly: false, + name: 'foo', value: 'bar', domain: 'localhost', path: '/', secure: true, httpOnly: false, hostOnly: true, }) .then(() => { expect(Cypress.automation).to.be.calledWith( @@ -1196,7 +1230,7 @@ describe('src/cy/commands/cookies', () => { .then(() => { expect(Cypress.automation).to.be.calledWith( 'set:cookie', - { domain: 'localhost', name: 'foo', value: 'bar', path: '/', secure: false, httpOnly: false, expiry: 12345, sameSite: undefined }, + { domain: 'localhost', name: 'foo', value: 'bar', path: '/', secure: false, httpOnly: false, hostOnly: false, expiry: 12345, sameSite: undefined }, ) }) }) @@ -1212,7 +1246,7 @@ describe('src/cy/commands/cookies', () => { .then(() => { expect(Cypress.automation).to.be.calledWith( 'set:cookie', - { domain: 'brian.dev.local', name: 'foo', value: 'bar', path: '/foo', secure: true, httpOnly: true, expiry: 987, sameSite: undefined }, + { domain: 'brian.dev.local', name: 'foo', value: 'bar', path: '/foo', secure: true, httpOnly: true, hostOnly: false, expiry: 987, sameSite: undefined }, ) }) }) @@ -1461,7 +1495,7 @@ describe('src/cy/commands/cookies', () => { Cypress.automation .withArgs('set:cookie', { - domain: 'localhost', name: 'foo', value: 'bar', path: '/', secure: false, httpOnly: false, expiry: 12345, sameSite: undefined, + domain: 'localhost', name: 'foo', value: 'bar', path: '/', secure: false, httpOnly: false, hostOnly: false, expiry: 12345, sameSite: undefined, }) .resolves({ name: 'foo', value: 'bar', domain: 'localhost', path: '/', secure: true, httpOnly: false, hostOnly: true, @@ -1494,7 +1528,7 @@ describe('src/cy/commands/cookies', () => { it('#consoleProps', () => { cy.setCookie('foo', 'bar').then(function (cookie) { - expect(cookie).to.deep.eq({ name: 'foo', value: 'bar', domain: 'localhost', path: '/', secure: true, httpOnly: false }) + expect(cookie).to.deep.eq({ name: 'foo', value: 'bar', domain: 'localhost', path: '/', secure: true, httpOnly: false, hostOnly: true }) const c = this.lastLog.invoke('consoleProps') expect(c['Yielded']).to.deep.eq(cookie) @@ -1688,7 +1722,7 @@ describe('src/cy/commands/cookies', () => { const c = this.lastLog.invoke('consoleProps') expect(c['Yielded']).to.eq('null') - expect(c['Cleared Cookie']).to.deep.eq({ name: 'foo', value: 'bar', domain: 'localhost', path: '/', secure: true, httpOnly: false }) + expect(c['Cleared Cookie']).to.deep.eq({ name: 'foo', value: 'bar', domain: 'localhost', path: '/', secure: true, httpOnly: false, hostOnly: false }) }) }) diff --git a/packages/driver/cypress/e2e/e2e/origin/cookie_login.cy.ts b/packages/driver/cypress/e2e/e2e/origin/cookie_login.cy.ts index e54da5957830..a747a1ec4b33 100644 --- a/packages/driver/cypress/e2e/e2e/origin/cookie_login.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/cookie_login.cy.ts @@ -725,6 +725,7 @@ describe('cy.origin - cookie login', { browser: '!webkit' }, () => { expect(Cypress._.omit(cookie, 'expiry')).to.deep.equal({ domain: 'www.foobar.com', httpOnly: false, + hostOnly: true, name: 'key', path: '/fixtures', sameSite: 'strict', @@ -853,6 +854,7 @@ describe('cy.origin - cookie login', { browser: '!webkit' }, () => { expect(Cypress._.omit(cookie, 'expiry')).to.deep.equal({ domain: 'www.foobar.com', httpOnly: false, + hostOnly: true, name: 'name', path: '/', sameSite: 'lax', diff --git a/packages/driver/src/cy/commands/cookies.ts b/packages/driver/src/cy/commands/cookies.ts index b29505614572..6d311ca5b8c3 100644 --- a/packages/driver/src/cy/commands/cookies.ts +++ b/packages/driver/src/cy/commands/cookies.ts @@ -4,10 +4,7 @@ import Promise from 'bluebird' import $utils from '../../cypress/utils' import $errUtils from '../../cypress/error_utils' -// TODO: add hostOnly to COOKIE_PROPS -// https://github.com/cypress-io/cypress/issues/363 -// https://github.com/cypress-io/cypress/issues/17527 -const COOKIE_PROPS = 'name value path secure httpOnly expiry domain sameSite'.split(' ') +const COOKIE_PROPS = 'name value path secure hostOnly httpOnly expiry domain sameSite'.split(' ') function pickCookieProps (cookie) { if (!cookie) return cookie @@ -359,6 +356,7 @@ export default function (Commands, Cypress: InternalCypress.Cypress, cy, state, path: '/', secure: false, httpOnly: false, + hostOnly: false, log: true, expiry: $utils.addTwentyYears(), }) diff --git a/packages/extension/app/background.js b/packages/extension/app/background.js index 04afe3154193..7dd81726f32c 100644 --- a/packages/extension/app/background.js +++ b/packages/extension/app/background.js @@ -179,6 +179,8 @@ const setOneCookie = (props) => { } if (props.hostOnly) { + // If the hostOnly prop is available, delete the domain. + // This will wind up setting a hostOnly cookie based on the calculated cookieURL above. delete props.domain } diff --git a/system-tests/projects/e2e/cypress/e2e/cookies_spec_baseurl.cy.js b/system-tests/projects/e2e/cypress/e2e/cookies_spec_baseurl.cy.js index 342e4f74650c..86f254e2ef29 100644 --- a/system-tests/projects/e2e/cypress/e2e/cookies_spec_baseurl.cy.js +++ b/system-tests/projects/e2e/cypress/e2e/cookies_spec_baseurl.cy.js @@ -256,13 +256,19 @@ describe('cookies', () => { _.times(n + 1, (i) => { ['foo', 'bar'].forEach((tag) => { + const domain = (i % 2) === (8 - n) ? expectedDomain : altDomain + const expectedCookie = { 'name': `name${tag}${i}`, 'value': `val${tag}${i}`, 'path': '/', - 'domain': (i % 2) === (8 - n) ? expectedDomain : altDomain, + // eslint-disable-next-line object-shorthand + 'domain': domain, 'secure': false, 'httpOnly': false, + ...(domain[0] !== '.' && domain !== 'localhost' && domain !== '127.0.0.1' ? { + 'hostOnly': true, + } : {}), } if (defaultSameSite) {