nodejs / nodejs/undici

setCookie writes a broken Expires header when expires is an out-of-range number

Closed Beginner friendly
#5,831 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
JavaScript
Stars
7.7k
Forks
879
Avg merge
2d 16h
Merged PRs (30d)
68

Description

What happens

setCookie writes a broken Expires attribute if you pass expires as a number that is too large.

import { Headers, setCookie } from 'undici'

const headers = new Headers()
setCookie(headers, { name: 'Space', value: 'Cat', expires: 1e16 })

console.log(headers.getSetCookie()[0])
// Space=Cat; Expires=undefined, undefined undefined NaN undefined:undefined:undefined GMT

That is not a valid date. Instead of an expiry time, the header contains the literal words undefined and NaN.

This is easy to hit in real code: 1e16 is what you get when you pass seconds where the API expects milliseconds. A date in seconds is always far too large for Date.

Why it happens

expires can be a Date or a number. The check in stringify only understands the Date form:

// lib/web/cookies/util.js
if (cookie.expires != null && cookie.expires.toString() !== 'Invalid Date') {
  out.push(`Expires=${toIMFDate(cookie.expires)}`)
}

The idea is "skip it if the date is invalid". That works for a Date, because a bad Date turns into the text "Invalid Date". But a number never does:

  • (new Date(NaN)).toString() is "Invalid Date" -> skipped, correct
  • NaN.toString() is "NaN" -> not skipped
  • (1e16).toString() is "10000000000000000" -> not skipped

So for a number the check always says "fine, go ahead". The value then goes into toIMFDate, which builds the date from a Date object whose values are all NaN. Reading NaN out of it gives undefined for the day name, and those values get pasted straight into the header text.

I confirmed this with a debugger on both lines. Below is the state at each stop.

Stop 1 - the check passes even though the value is not a valid date

cookie.expires is 10000000000000000. The check says true, so it does not skip. But new Date(...) on that value is invalid, and the value is outside the range a Date can hold:

paused in stringify, the guard evaluates to true while new Date gives NaN

Expression Value
cookie.expires 10000000000000000
cookie.expires.toString() '10000000000000000'
cookie.expires.toString() !== 'Invalid Date' true (so it does not skip)
new Date(cookie.expires).getTime() NaN (but it is not a real date)
Math.abs(cookie.expires) <= 8640000000000000 false (out of range)
Stop 2 - toIMFDate builds the header out of undefined and NaN

Stepping into toIMFDate, the lookup that produces the day name returns undefined:

paused in toIMFDate, the IMFDays lookup returns undefined

Expression Value
date Invalid Date
date.getUTCDay() NaN
IMFDays[date.getUTCDay()] undefined
date.getUTCMonth() NaN
date.getUTCFullYear() NaN

The call stack at this point is toIMFDate -> stringify (util.js:321, which is inside the if body) -> setCookie. That confirms the check let the value through rather than skipping it.

Which values are affected

expires: 0 (valid)           => Space=Cat; Expires=Thu, 01 Jan 1970 00:00:00 GMT
expires: 8.64e15 (max)       => Space=Cat; Expires=Sat, 13 Sep 275760 00:00:00 GMT
expires: 1e16 (out of range) => Space=Cat; Expires=undefined, undefined undefined NaN undefined:undefined:undefined GMT
expires: new Date(NaN)       => Space=Cat

output for valid, boundary, out-of-range and invalid Date values

Only an out-of-range number is broken. 0 works, the largest valid value works, and an invalid Date is skipped correctly. So the problem is specific to the number form.

Suggested fix

Check the number itself instead of its text, and leave the Date behaviour alone:

if (cookie.expires != null) {
  const expiresMs = cookie.expires instanceof Date
    ? cookie.expires.getTime()
    : cookie.expires

  if (typeof expiresMs === 'number' && Number.isFinite(expiresMs) && Math.abs(expiresMs) <= 8640000000000000) {
    out.push(`Expires=${toIMFDate(cookie.expires)}`)
  }
}

8640000000000000 is the largest value a Date can hold, so anything that passes the check is safe to format. This matches how the neighbouring Max-Age branch already works - it checks the type rather than a converted string.

One thing worth noting: Infinity, -Infinity and NaN never reach this code as-is, because the converter in lib/web/cookies/index.js turns them into 0 first, so they still serialize as the epoch. That is a separate issue and this change does not touch it.

Environment

  • undici main at 6933139 (undici@8.10.2)
  • Node.js v26.8.1, macOS

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

The affected logic is in lib/web/cookies/util.js; start by reproducing the setCookie example and inspecting the existing expires guard. Done means out-of-range numeric expires values no longer produce an Expires attribute, while 0, the maximum valid value, and invalid Date behavior remain unchanged.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, nodejs
Domain
api
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
87/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.