diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dedc914..a3470df2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 1.4.17 - TBD +Bug fix: +- [#1556](https://github.com/elysiajs/elysia/issues/1556) prevent sending set-cookie header when cookie value is not modified + # 1.4.16 - 13 Nov 2025 Improvement: - ValidationError: add `messageValue` as an alias of `errorValue` diff --git a/src/cookies.ts b/src/cookies.ts index c1dc42b1..e1d438d3 100644 --- a/src/cookies.ts +++ b/src/cookies.ts @@ -142,7 +142,7 @@ export class Cookie implements ElysiaCookie { } protected get setCookie() { - if (!(this.name in this.jar)) this.jar[this.name] = this.initial + if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial } return this.jar[this.name] } @@ -156,87 +156,135 @@ export class Cookie implements ElysiaCookie { } set value(value: T) { - this.setCookie.value = value + // Check if value actually changed before creating entry in jar + const current = this.cookie.value + + // Simple equality check + if (current === value) return + + // For objects, do a deep equality check + if ( + typeof current === 'object' && + current !== null && + typeof value === 'object' && + value !== null + ) { + try { + if (JSON.stringify(current) === JSON.stringify(value)) return + } catch { + // If stringify fails, proceed with setting the value + } + } + + // Only create entry in jar if value actually changed + if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial } + this.jar[this.name].value = value } get expires() { return this.cookie.expires } - set expires(expires) { - this.setCookie.expires = expires + set expires(expires: Date | undefined) { + // Handle undefined values and compare timestamps instead of Date objects + const currentExpires = this.cookie.expires + if (currentExpires === undefined && expires === undefined) return + if (currentExpires?.getTime() === expires?.getTime()) return + + if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial } + this.jar[this.name].expires = expires } get maxAge() { return this.cookie.maxAge } - set maxAge(maxAge) { - this.setCookie.maxAge = maxAge + set maxAge(maxAge: number | undefined) { + if (this.cookie.maxAge === maxAge) return + if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial } + this.jar[this.name].maxAge = maxAge } get domain() { return this.cookie.domain } - set domain(domain) { - this.setCookie.domain = domain + set domain(domain: string | undefined) { + if (this.cookie.domain === domain) return + if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial } + this.jar[this.name].domain = domain } get path() { return this.cookie.path } - set path(path) { - this.setCookie.path = path + set path(path: string | undefined) { + if (this.cookie.path === path) return + if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial } + this.jar[this.name].path = path } get secure() { return this.cookie.secure } - set secure(secure) { - this.setCookie.secure = secure + set secure(secure: boolean | undefined) { + if (this.cookie.secure === secure) return + if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial } + this.jar[this.name].secure = secure } get httpOnly() { return this.cookie.httpOnly } - set httpOnly(httpOnly) { - this.setCookie.httpOnly = httpOnly + set httpOnly(httpOnly: boolean | undefined) { + if (this.cookie.httpOnly === httpOnly) return + if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial } + this.jar[this.name].httpOnly = httpOnly } get sameSite() { return this.cookie.sameSite } - set sameSite(sameSite) { - this.setCookie.sameSite = sameSite + set sameSite( + sameSite: true | false | 'lax' | 'strict' | 'none' | undefined + ) { + if (this.cookie.sameSite === sameSite) return + if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial } + this.jar[this.name].sameSite = sameSite } get priority() { return this.cookie.priority } - set priority(priority) { - this.setCookie.priority = priority + set priority(priority: 'low' | 'medium' | 'high' | undefined) { + if (this.cookie.priority === priority) return + if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial } + this.jar[this.name].priority = priority } get partitioned() { return this.cookie.partitioned } - set partitioned(partitioned) { - this.setCookie.partitioned = partitioned + set partitioned(partitioned: boolean | undefined) { + if (this.cookie.partitioned === partitioned) return + if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial } + this.jar[this.name].partitioned = partitioned } get secrets() { return this.cookie.secrets } - set secrets(secrets) { - this.setCookie.secrets = secrets + set secrets(secrets: string | string[] | undefined) { + if (this.cookie.secrets === secrets) return + if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial } + this.jar[this.name].secrets = secrets } update(config: Updater>) { diff --git a/test/cookie/unchanged.test.ts b/test/cookie/unchanged.test.ts new file mode 100644 index 00000000..4da30526 --- /dev/null +++ b/test/cookie/unchanged.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'bun:test' +import { Elysia, t } from '../../src' + +describe('Cookie - Unchanged Values', () => { + it('should not send set-cookie header when cookie is only read', async () => { + const app = new Elysia() + .guard({ + cookie: t.Cookie({ + value: t.Optional( + t.Object({ + a: t.String(), + b: t.String() + }) + ) + }) + }) + .get('/cookie', ({ cookie: { value } }) => value.value) + .post('/cookie', ({ cookie: { value } }) => { + value.value = { a: '1', b: '2' } + return 'ok' + }) + + // POST request should set cookie + const postResponse = await app.handle( + new Request('http://localhost/cookie', { + method: 'POST' + }) + ) + + const setCookieHeaders = postResponse.headers.getAll('set-cookie') + expect(setCookieHeaders.length).toBeGreaterThan(0) + + // GET request should NOT set cookie (only reading) + const getResponse = await app.handle( + new Request('http://localhost/cookie', { + method: 'GET', + headers: { + cookie: setCookieHeaders[0].split(';')[0] + } + }) + ) + + const getSetCookieHeaders = getResponse.headers.getAll('set-cookie') + expect(getSetCookieHeaders.length).toBe(0) + }) + + it('should not send set-cookie header when cookie value is accessed but not modified', async () => { + const app = new Elysia() + .get('/read', ({ cookie: { session } }) => { + // Just reading the value + const val = session.value + return { read: val } + }) + .get('/write', ({ cookie: { session } }) => { + // Writing the value + session.value = 'test' + return { written: true } + }) + + // Read endpoint should not set cookie + const readResponse = await app.handle( + new Request('http://localhost/read') + ) + expect(readResponse.headers.getAll('set-cookie').length).toBe(0) + + // Write endpoint should set cookie + const writeResponse = await app.handle( + new Request('http://localhost/write') + ) + expect( + writeResponse.headers.getAll('set-cookie').length + ).toBeGreaterThan(0) + }) + + it('should not send set-cookie header when setting same value', async () => { + const app = new Elysia().get('/same', ({ cookie: { session } }) => { + // Setting the same value that came from request + session.value = 'existing' + return 'ok' + }) + + const response = await app.handle( + new Request('http://localhost/same', { + headers: { + cookie: 'session=existing' + } + }) + ) + + expect(response.headers.getAll('set-cookie').length).toBe(0) + }) + + it('should send set-cookie header when value actually changes', async () => { + const app = new Elysia().get('/change', ({ cookie: { session } }) => { + session.value = 'new-value' + return 'ok' + }) + + const response = await app.handle( + new Request('http://localhost/change', { + headers: { + cookie: 'session=old-value' + } + }) + ) + + expect(response.headers.getAll('set-cookie').length).toBeGreaterThan(0) + }) +}) diff --git a/test/validator/cookie.test.ts b/test/validator/cookie.test.ts index 4f52fcd8..f3db6c21 100644 --- a/test/validator/cookie.test.ts +++ b/test/validator/cookie.test.ts @@ -462,3 +462,41 @@ describe('Cookie Validation', () => { expect(await response.text()).toBe('empty') }) }) + + it('expires setter compares timestamps not Date objects', async () => { + const app = new Elysia().get('/', ({ cookie: { session }, set }) => { + // Test 1: Setting expires with same timestamp should not update + const date1 = new Date('2025-12-31T23:59:59.000Z') + const date2 = new Date('2025-12-31T23:59:59.000Z') + + session.value = 'test' + session.expires = date1 + + // Get reference to jar before setting with same timestamp + const jarBefore = set.cookie + session.expires = date2 // Same timestamp, should not update + const jarAfter = set.cookie + + // Verify jar wasn't recreated (same reference) + expect(jarBefore).toBe(jarAfter) + expect(session.expires?.getTime()).toBe(date1.getTime()) + + // Test 2: Setting expires with different timestamp should update + const date3 = new Date('2026-01-01T00:00:00.000Z') + session.expires = date3 + expect(session.expires?.getTime()).toBe(date3.getTime()) + + // Test 3: Both undefined should not update + session.expires = undefined + const jarBeforeUndefined = set.cookie + session.expires = undefined + const jarAfterUndefined = set.cookie + expect(jarBeforeUndefined).toBe(jarAfterUndefined) + + return 'ok' + }) + + const response = await app.handle(req('/')) + expect(response.status).toBe(200) + expect(await response.text()).toBe('ok') + })