Skip to content

Commit a6ea431

Browse files
authored
Merge pull request #1558 from NongTham/fix/cookie-unchanged-resend
fix: prevent sending set-cookie header when cookie value is unchanged...
2 parents 78f4828 + cfefdd7 commit a6ea431

File tree

4 files changed

+221
-22
lines changed

4 files changed

+221
-22
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# 1.4.17 - TBD
2+
Bug fix:
3+
- [#1556](https://github.com/elysiajs/elysia/issues/1556) prevent sending set-cookie header when cookie value is not modified
4+
15
# 1.4.16 - 13 Nov 2025
26
Improvement:
37
- ValidationError: add `messageValue` as an alias of `errorValue`

src/cookies.ts

Lines changed: 70 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ export class Cookie<T> implements ElysiaCookie {
142142
}
143143

144144
protected get setCookie() {
145-
if (!(this.name in this.jar)) this.jar[this.name] = this.initial
145+
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
146146

147147
return this.jar[this.name]
148148
}
@@ -156,87 +156,135 @@ export class Cookie<T> implements ElysiaCookie {
156156
}
157157

158158
set value(value: T) {
159-
this.setCookie.value = value
159+
// Check if value actually changed before creating entry in jar
160+
const current = this.cookie.value
161+
162+
// Simple equality check
163+
if (current === value) return
164+
165+
// For objects, do a deep equality check
166+
if (
167+
typeof current === 'object' &&
168+
current !== null &&
169+
typeof value === 'object' &&
170+
value !== null
171+
) {
172+
try {
173+
if (JSON.stringify(current) === JSON.stringify(value)) return
174+
} catch {
175+
// If stringify fails, proceed with setting the value
176+
}
177+
}
178+
179+
// Only create entry in jar if value actually changed
180+
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
181+
this.jar[this.name].value = value
160182
}
161183

162184
get expires() {
163185
return this.cookie.expires
164186
}
165187

166-
set expires(expires) {
167-
this.setCookie.expires = expires
188+
set expires(expires: Date | undefined) {
189+
// Handle undefined values and compare timestamps instead of Date objects
190+
const currentExpires = this.cookie.expires
191+
if (currentExpires === undefined && expires === undefined) return
192+
if (currentExpires?.getTime() === expires?.getTime()) return
193+
194+
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
195+
this.jar[this.name].expires = expires
168196
}
169197

170198
get maxAge() {
171199
return this.cookie.maxAge
172200
}
173201

174-
set maxAge(maxAge) {
175-
this.setCookie.maxAge = maxAge
202+
set maxAge(maxAge: number | undefined) {
203+
if (this.cookie.maxAge === maxAge) return
204+
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
205+
this.jar[this.name].maxAge = maxAge
176206
}
177207

178208
get domain() {
179209
return this.cookie.domain
180210
}
181211

182-
set domain(domain) {
183-
this.setCookie.domain = domain
212+
set domain(domain: string | undefined) {
213+
if (this.cookie.domain === domain) return
214+
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
215+
this.jar[this.name].domain = domain
184216
}
185217

186218
get path() {
187219
return this.cookie.path
188220
}
189221

190-
set path(path) {
191-
this.setCookie.path = path
222+
set path(path: string | undefined) {
223+
if (this.cookie.path === path) return
224+
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
225+
this.jar[this.name].path = path
192226
}
193227

194228
get secure() {
195229
return this.cookie.secure
196230
}
197231

198-
set secure(secure) {
199-
this.setCookie.secure = secure
232+
set secure(secure: boolean | undefined) {
233+
if (this.cookie.secure === secure) return
234+
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
235+
this.jar[this.name].secure = secure
200236
}
201237

202238
get httpOnly() {
203239
return this.cookie.httpOnly
204240
}
205241

206-
set httpOnly(httpOnly) {
207-
this.setCookie.httpOnly = httpOnly
242+
set httpOnly(httpOnly: boolean | undefined) {
243+
if (this.cookie.httpOnly === httpOnly) return
244+
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
245+
this.jar[this.name].httpOnly = httpOnly
208246
}
209247

210248
get sameSite() {
211249
return this.cookie.sameSite
212250
}
213251

214-
set sameSite(sameSite) {
215-
this.setCookie.sameSite = sameSite
252+
set sameSite(
253+
sameSite: true | false | 'lax' | 'strict' | 'none' | undefined
254+
) {
255+
if (this.cookie.sameSite === sameSite) return
256+
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
257+
this.jar[this.name].sameSite = sameSite
216258
}
217259

218260
get priority() {
219261
return this.cookie.priority
220262
}
221263

222-
set priority(priority) {
223-
this.setCookie.priority = priority
264+
set priority(priority: 'low' | 'medium' | 'high' | undefined) {
265+
if (this.cookie.priority === priority) return
266+
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
267+
this.jar[this.name].priority = priority
224268
}
225269

226270
get partitioned() {
227271
return this.cookie.partitioned
228272
}
229273

230-
set partitioned(partitioned) {
231-
this.setCookie.partitioned = partitioned
274+
set partitioned(partitioned: boolean | undefined) {
275+
if (this.cookie.partitioned === partitioned) return
276+
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
277+
this.jar[this.name].partitioned = partitioned
232278
}
233279

234280
get secrets() {
235281
return this.cookie.secrets
236282
}
237283

238-
set secrets(secrets) {
239-
this.setCookie.secrets = secrets
284+
set secrets(secrets: string | string[] | undefined) {
285+
if (this.cookie.secrets === secrets) return
286+
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
287+
this.jar[this.name].secrets = secrets
240288
}
241289

242290
update(config: Updater<Partial<ElysiaCookie>>) {

test/cookie/unchanged.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { describe, it, expect } from 'bun:test'
2+
import { Elysia, t } from '../../src'
3+
4+
describe('Cookie - Unchanged Values', () => {
5+
it('should not send set-cookie header when cookie is only read', async () => {
6+
const app = new Elysia()
7+
.guard({
8+
cookie: t.Cookie({
9+
value: t.Optional(
10+
t.Object({
11+
a: t.String(),
12+
b: t.String()
13+
})
14+
)
15+
})
16+
})
17+
.get('/cookie', ({ cookie: { value } }) => value.value)
18+
.post('/cookie', ({ cookie: { value } }) => {
19+
value.value = { a: '1', b: '2' }
20+
return 'ok'
21+
})
22+
23+
// POST request should set cookie
24+
const postResponse = await app.handle(
25+
new Request('http://localhost/cookie', {
26+
method: 'POST'
27+
})
28+
)
29+
30+
const setCookieHeaders = postResponse.headers.getAll('set-cookie')
31+
expect(setCookieHeaders.length).toBeGreaterThan(0)
32+
33+
// GET request should NOT set cookie (only reading)
34+
const getResponse = await app.handle(
35+
new Request('http://localhost/cookie', {
36+
method: 'GET',
37+
headers: {
38+
cookie: setCookieHeaders[0].split(';')[0]
39+
}
40+
})
41+
)
42+
43+
const getSetCookieHeaders = getResponse.headers.getAll('set-cookie')
44+
expect(getSetCookieHeaders.length).toBe(0)
45+
})
46+
47+
it('should not send set-cookie header when cookie value is accessed but not modified', async () => {
48+
const app = new Elysia()
49+
.get('/read', ({ cookie: { session } }) => {
50+
// Just reading the value
51+
const val = session.value
52+
return { read: val }
53+
})
54+
.get('/write', ({ cookie: { session } }) => {
55+
// Writing the value
56+
session.value = 'test'
57+
return { written: true }
58+
})
59+
60+
// Read endpoint should not set cookie
61+
const readResponse = await app.handle(
62+
new Request('http://localhost/read')
63+
)
64+
expect(readResponse.headers.getAll('set-cookie').length).toBe(0)
65+
66+
// Write endpoint should set cookie
67+
const writeResponse = await app.handle(
68+
new Request('http://localhost/write')
69+
)
70+
expect(
71+
writeResponse.headers.getAll('set-cookie').length
72+
).toBeGreaterThan(0)
73+
})
74+
75+
it('should not send set-cookie header when setting same value', async () => {
76+
const app = new Elysia().get('/same', ({ cookie: { session } }) => {
77+
// Setting the same value that came from request
78+
session.value = 'existing'
79+
return 'ok'
80+
})
81+
82+
const response = await app.handle(
83+
new Request('http://localhost/same', {
84+
headers: {
85+
cookie: 'session=existing'
86+
}
87+
})
88+
)
89+
90+
expect(response.headers.getAll('set-cookie').length).toBe(0)
91+
})
92+
93+
it('should send set-cookie header when value actually changes', async () => {
94+
const app = new Elysia().get('/change', ({ cookie: { session } }) => {
95+
session.value = 'new-value'
96+
return 'ok'
97+
})
98+
99+
const response = await app.handle(
100+
new Request('http://localhost/change', {
101+
headers: {
102+
cookie: 'session=old-value'
103+
}
104+
})
105+
)
106+
107+
expect(response.headers.getAll('set-cookie').length).toBeGreaterThan(0)
108+
})
109+
})

test/validator/cookie.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,3 +462,41 @@ describe('Cookie Validation', () => {
462462
expect(await response.text()).toBe('empty')
463463
})
464464
})
465+
466+
it('expires setter compares timestamps not Date objects', async () => {
467+
const app = new Elysia().get('/', ({ cookie: { session }, set }) => {
468+
// Test 1: Setting expires with same timestamp should not update
469+
const date1 = new Date('2025-12-31T23:59:59.000Z')
470+
const date2 = new Date('2025-12-31T23:59:59.000Z')
471+
472+
session.value = 'test'
473+
session.expires = date1
474+
475+
// Get reference to jar before setting with same timestamp
476+
const jarBefore = set.cookie
477+
session.expires = date2 // Same timestamp, should not update
478+
const jarAfter = set.cookie
479+
480+
// Verify jar wasn't recreated (same reference)
481+
expect(jarBefore).toBe(jarAfter)
482+
expect(session.expires?.getTime()).toBe(date1.getTime())
483+
484+
// Test 2: Setting expires with different timestamp should update
485+
const date3 = new Date('2026-01-01T00:00:00.000Z')
486+
session.expires = date3
487+
expect(session.expires?.getTime()).toBe(date3.getTime())
488+
489+
// Test 3: Both undefined should not update
490+
session.expires = undefined
491+
const jarBeforeUndefined = set.cookie
492+
session.expires = undefined
493+
const jarAfterUndefined = set.cookie
494+
expect(jarBeforeUndefined).toBe(jarAfterUndefined)
495+
496+
return 'ok'
497+
})
498+
499+
const response = await app.handle(req('/'))
500+
expect(response.status).toBe(200)
501+
expect(await response.text()).toBe('ok')
502+
})

0 commit comments

Comments
 (0)