Skip to content

Enforce __Secure-/__Host- cookie name prefix rules on the write path - #33459

Closed
robobun wants to merge 3 commits into
mainfrom
farm/80f145b8/cookie-name-prefixes
Closed

robobun wants to merge 3 commits into
mainfrom
farm/80f145b8/cookie-name-prefixes

Conversation

@robobun

@robobun robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator

Repro

console.log(new Bun.Cookie("__Host-a", "1").toString());
console.log(new Bun.Cookie("__Host-a", "1", { secure: true, domain: "example.com" }).toString());

const map = new Bun.CookieMap();
map.set("__Host-s", "v");
console.log(map.toSetCookieHeaders());

using server = Bun.serve({
  port: 0,
  routes: {
    "/": req => {
      req.cookies.set("__Host-sid", "s3cret");
      return new Response("ok");
    },
  },
});
console.log((await fetch(server.url)).headers.getSetCookie());
__Host-a=1; Path=/; SameSite=Lax                               <- no Secure
__Host-a=1; Domain=example.com; Path=/; Secure; SameSite=Lax   <- __Host- forbids Domain
[ "__Host-s=v; Path=/; SameSite=Lax" ]
[ "__Host-sid=s3cret; Path=/; SameSite=Lax" ]

Every one of those headers is ignored by every browser, so a __Host- session cookie set through the documented API silently never sticks, with no error anywhere.

Cause

RFC 6265bis 4.1.3 requires a user agent to ignore a cookie whose name begins with __Secure- unless it is Secure, or with __Host- unless it is Secure, has no Domain, and has Path=/. Nothing on the write path checked this: Cookie::create, CookieMap::set, and the secure / domain / path setters accepted any combination. CookieMap::remove already forced Secure onto the deletion cookie, so the rule was half-known.

A related case: path: "x" was accepted and serialized as Path=x, which a user agent ignores (RFC 6265 5.2.4) and which Bun's own Cookie.parse throws away, so the cookie could not round-trip through Bun.

Fix

Cookie::validateNamePrefix holds the rule, split per attribute since a cookie's name is immutable but its attributes are not. It runs at every point where a cookie can reach the wire:

  • Cookie::create(CookieInit), the "set a cookie" entry behind new Bun.Cookie, Cookie.from, and CookieMap.set
  • CookieMap::set(Cookie), which catches a cookie that came from Cookie.parse without being repaired
  • CookieMap::remove, which now builds its expiring cookie through the same entry
  • the secure, domain and path setters, since CookieMap.set keeps a reference to the Cookie object and the cookie could otherwise be mutated into an invalid state afterwards

Violations throw a TypeError, matching the CookieStore "set a cookie" algorithm that Bun.CookieMap models.

Cookie.parse still reports what was on the wire without throwing; it is a read path, and a cookie parsed from a broken upstream header can be repaired (cookie.secure = true) and then set.

Two smaller things fall out of this:

  • a rejected set or delete no longer drops the cookie that was already in the map (the removal now happens after the fallible step, not before)
  • a path that does not start with / is a TypeError. This changes two inputs in the cookie package parity suite ("../foo/", "./") from serialized to rejected; they are moved into a test asserting the throw.

Verification

$ bun bd test test/js/bun/cookie/ test/js/bun/util/cookie.test.js test/js/bun/http/bun-serve-cookies.test.ts
 315 pass, 0 fail

With src/ reverted to main, 19 of the new tests fail.

After the fix
__Host-a=1; Path=/; Secure; SameSite=Lax                    (secure: true)
TypeError: Invalid cookie name: "__Host-" prefix requires secure: true
TypeError: Invalid cookie name: "__Host-" prefix does not allow a domain
TypeError: Invalid cookie name: "__Host-" prefix requires path: "/"
TypeError: Invalid cookie name: "__Secure-" prefix requires secure: true
TypeError: Invalid cookie path: must start with "/"

@robobun
robobun requested a review from alii as a code owner July 6, 2026 11:02
@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR enforces browser __Secure-/__Host- cookie name prefix rules and stricter cookie path validation (must start with /) in Bun's Cookie/CookieMap C++ bindings, propagates resulting exceptions through JS bindings, updates cookie.serialize path checks, and adds corresponding tests and documentation.

Changes

Cookie prefix and path validation

Layer / File(s) Summary
Cookie core validation helpers
src/jsc/bindings/Cookie.cpp, src/jsc/bindings/Cookie.h
Adds validateCookieAttributes, validateCookiePath (rejects paths not starting with /), validateNamePrefix, and prefix-specific validators; Cookie::create and setters (setDomain, setPath, setSecure) now perform exception-based validation.
CookieMap set/remove enforcement
src/jsc/bindings/CookieMap.cpp, src/jsc/bindings/CookieMap.h
CookieMap::set now returns ExceptionOr<void> and re-validates prefixes; CookieMap::remove builds a CookieInit, creates the expiring cookie via Cookie::create, and only removes existing cookies after successful creation.
JS binding exception propagation
src/jsc/bindings/webcore/JSCookie.cpp, src/jsc/bindings/webcore/JSCookieMap.cpp
Propagates ExceptionOr failures from setSecure and CookieMap::set into the JS throwScope instead of ignoring them.
cookie.serialize path validation and tests
test/js/bun/util/cookie.test.js
Updates valid path fixtures and adds a test verifying cookie.serialize throws for relative paths with a specific error message.
CookieMap prefix and path tests
test/js/bun/cookie/cookie-map.test.ts
Updates expiring Set-Cookie header expectations and adds tests validating set/delete prefix and path enforcement for __Host-/__Secure- cookies.
Cookie prefix/path tests and documentation
test/js/bun/cookie/cookie.test.ts, docs/runtime/cookies.mdx, packages/bun-types/bun.d.ts
Adds test suites for prefix and path validation behavior and updates docs/type definitions describing the new prefix and path rules.

Sequence Diagram(s)

sequenceDiagram
  participant JS as JS Caller
  participant JSCookieMap as JSCookieMap.cpp
  participant CookieMap as CookieMap::set
  participant Cookie as Cookie validation

  JS->>JSCookieMap: cookieMap.set(...)
  JSCookieMap->>CookieMap: impl.set(cookie)
  CookieMap->>Cookie: validateNamePrefix(name, ...)
  Cookie-->>CookieMap: ExceptionOr<void>
  alt validation fails
    CookieMap-->>JSCookieMap: Exception
    JSCookieMap-->>JS: propagateException + throw
  else validation succeeds
    CookieMap->>CookieMap: removeInternal + append
    CookieMap-->>JSCookieMap: {}
    JSCookieMap-->>JS: success
  end
Loading

Related PRs: None identified.

Suggested labels: javascript, needs-tests-changes, bun:api

Suggested reviewers: Jarred-Sumner, cirospaciari

🐰

A cookie with a name that starts secure,
Must prove its path and domain are pure,
No __Host- may roam,
Without / as its home—
Now Bun throws instead of a lure.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: enforcing __Secure-/__Host- cookie prefix rules on the write path.
Description check ✅ Passed It covers the PR purpose, root cause, fix, and verification, so the required template content is present even with different headings.

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Jul 6, 2026
@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:54 AM PT - Jul 6th, 2026

@robobun, your commit 59ce445 is building: #68973

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/runtime/cookies.mdx`:
- Around line 116-126: Update the CookieMap documentation to mention that
`delete()`/`remove()` can also throw when re-validating prefixed cookies, not
just `set()`. In the `delete()` section, note that `CookieMap.delete` rejects
invalid `__Secure-` and `__Host-` combinations using the same prefix rules, and
reference the existing `CookieMap.remove`/`delete()` behavior so readers know a
`TypeError` can occur during deletion as well.

In `@src/jsc/bindings/CookieMap.cpp`:
- Around line 197-213: The prefix handling in CookieMap::remove is duplicated
and should reuse the shared Cookie prefix logic instead of hard-coding
"__Secure-" and "__Host-". Expose or call the existing Cookie prefix helpers
(such as hasSecurePrefix and hasHostPrefix) from Cookie.cpp/Cookie.h, then use
them when building the expiring cookie in CookieMap::remove so the prefix rules
stay consistent with validateNamePrefix and future changes only need to happen
in one place.

In `@test/js/bun/cookie/cookie-map.test.ts`:
- Around line 466-472: The __Host- delete test in CookieMap is missing the same
post-throw header invariant checked by the sibling domain-invalid case. Update
the failing delete scenario in cookie-map.test.ts to also assert that the map
emits no Set-Cookie headers after the rejected delete, using the existing
CookieMap instance and its toSetCookieHeaders() behavior for consistency with
the other delete validation tests.
- Around line 475-513: The “a rejected set leaves the previous cookie in place”
test in cookie-map.test.ts uses a bare toThrow() on Bun.CookieMap.set, which is
too weak and can pass for unrelated failures. Update that assertion to match the
same exact prefixed-cookie error text used by the other __Host- and __Secure-
tests, so the test verifies the intended validation path rather than any throw.
Keep the rest of the CookieMap behavior checks unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 56b15fe0-dc85-42bc-a432-7ee75c62b32d

📥 Commits

Reviewing files that changed from the base of the PR and between 48ff9eb and f1afffb.

📒 Files selected for processing (11)
  • docs/runtime/cookies.mdx
  • packages/bun-types/bun.d.ts
  • src/jsc/bindings/Cookie.cpp
  • src/jsc/bindings/Cookie.h
  • src/jsc/bindings/CookieMap.cpp
  • src/jsc/bindings/CookieMap.h
  • src/jsc/bindings/webcore/JSCookie.cpp
  • src/jsc/bindings/webcore/JSCookieMap.cpp
  • test/js/bun/cookie/cookie-map.test.ts
  • test/js/bun/cookie/cookie.test.ts
  • test/js/bun/util/cookie.test.js

Comment thread docs/runtime/cookies.mdx
Comment thread src/jsc/bindings/CookieMap.cpp
Comment thread test/js/bun/cookie/cookie-map.test.ts
Comment thread test/js/bun/cookie/cookie-map.test.ts
Comment thread packages/bun-types/bun.d.ts
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review in 17e693c:

  • CookieMap::remove no longer repeats the prefix strings. hasSecurePrefix / hasHostPrefix are now public statics on Cookie, so the prefix set and its case handling live in one place.
  • docs/runtime/cookies.mdx: documented that delete() throws for the same combinations set() rejects, and synced the CookieInit mirror in the ## Types block with the .d.ts JSDoc.
  • test/js/bun/cookie/cookie-map.test.ts: pinned the error message in a rejected set leaves the previous cookie in place (no more bare toThrow()), and added the missing toSetCookieHeaders() assertion to the path-invalid delete test.
$ bun bd test test/js/bun/cookie/ test/js/bun/util/cookie.test.js test/js/bun/http/bun-serve-cookies.test.ts
 315 pass, 0 fail

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation looks solid and my earlier nit is addressed, but this introduces new throw conditions on previously-accepted input (__Secure-/__Host- without secure, and any non-/-leading path), so a maintainer should sign off on the API decision to throw rather than auto-repair.

Extended reasoning...

Overview

This PR enforces RFC 6265bis §4.1.3 cookie name prefix rules (__Secure-, __Host-) and RFC 6265 §5.2.4 path rules on Bun's cookie write path. It touches 11 files: native C++ (Cookie.{cpp,h}, CookieMap.{cpp,h}, JSCookie.cpp, JSCookieMap.cpp), docs, .d.ts, and three test files. Cookie::setSecure and CookieMap::set change from void to ExceptionOr<void>; Cookie::create(CookieInit) and the domain/path/secure setters now validate the prefix rules; CookieMap::remove reorders removeInternal after the fallible step so a rejected delete no longer drops the existing entry.

Security risks

None introduced — the change tightens validation to match browser enforcement, which is strictly safer than the status quo (where a __Host- session cookie set without secure was silently dropped by every browser). The prefix helpers are static string checks with no untrusted parsing.

Level of scrutiny

This warrants human review because it is a breaking API change: inputs that Bun previously accepted and serialized (e.g. new Bun.Cookie("__Host-a", "1"), { path: "../foo/" }) now throw TypeError. The PR moved two entries out of the cookie-package parity suite's valid-paths list into a throws-list. The design choice — throw vs. auto-set secure: true vs. warn — is defensible (it mirrors the CookieStore spec's "set a cookie" algorithm) but is the kind of user-facing API surface decision a maintainer should confirm. There's also a deliberate asymmetry between the two Cookie::create overloads (only the CookieInit one enforces prefixes, so Cookie.parse stays a pure read path) that's worth a second pair of eyes.

Other factors

Test coverage is thorough (constructor, Cookie.from, object form, setters, CookieMap.set/delete, Bun.serve end-to-end, case-insensitive match, negative cases, repair-after-parse). All prior review feedback (mine and CodeRabbit's four nits) was addressed in 17e693c: prefix helpers are now shared statics on Cookie, the docs ## Types mirror is synced, the bare toThrow() is pinned, and delete() throw behavior is documented. No bugs were found by the automated hunt. The C++ changes follow existing patterns (ExceptionOr<void> + propagateException) already used by setDomain/setPath.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Flagging the API decision for a maintainer, since the review asked for it. The short version of why this throws rather than auto-repairing:

__Host- + domain, and __Host- + non-/ path, cannot be auto-repaired. Silently dropping a domain the caller explicitly passed, or rewriting their path to /, is its own silent-wrong-behavior bug. Those two have to throw.

That leaves secure as the only attribute that could be auto-filled, which would give the rule two different shapes: "secure gets quietly added for you, but domain and path are hard errors." One rule is easier to document and to reason about, and throwing is what the CookieStore "set a cookie" algorithm does, which is the spec Bun.CookieMap already models down to CookieStoreDeleteOptions.

Nothing that works today stops working. A __Host- or __Secure- cookie that violates its prefix is ignored by every conforming UA, so the only behavior change for that input is a loud error where there used to be a session cookie that silently never stuck. Throwing is also the conservative direction: relaxing to auto-repair later is not breaking, the reverse is.

The one tightening that goes beyond that: path must now start with /. Before, path: "x" serialized as Path=x, which a UA discards (RFC 6265 5.2.4) and which Bun's own Cookie.parse discards, so the cookie could not round-trip through Bun. That moves two inputs in the cookie package parity suite ("../foo/", "./") from serialized to rejected.

If you would rather have secure default to true for prefixed names instead of throwing, say the word. It means teaching CookieInit to tell "unspecified" apart from secure: false (so an explicit secure: false can still be an error), which is a contained change, and the domain / path rules stay as they are either way.

robobun and others added 3 commits July 6, 2026 12:47
RFC 6265bis 4.1.3 requires a user agent to ignore a cookie whose name starts
with __Secure- unless it is Secure, or with __Host- unless it is Secure, has
no Domain, and has Path=/. Bun enforced none of this when creating a cookie,
so `cookies.set("__Host-sid", token)` put a header on the wire that every
browser drops.

Cookie::create(CookieInit) and CookieMap::set() now reject a cookie that
violates its name's prefix, and CookieMap::remove() builds its expiring
cookie through the same path. Cookie.parse() keeps reporting what was on the
wire. A rejected set or delete no longer drops the cookie already in the map.

Also reject a Path that does not start with "/": a user agent ignores such an
attribute, and Bun's own parser drops it, so the cookie could not round-trip.
CookieMap::remove derives the Secure flag from Cookie::hasSecurePrefix /
Cookie::hasHostPrefix instead of repeating the prefix strings, so the rule
lives in one place. Pin the error message in the rejected-set test, assert no
header is emitted after a rejected delete, document that delete() throws for
the same combinations set() rejects, and sync the CookieInit mirror in the docs.
@robobun
robobun force-pushed the farm/80f145b8/cookie-name-prefixes branch from 17e693c to 59ce445 Compare July 6, 2026 12:54
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

The red X on build 68973 is a Buildkite scheduling failure, not a test failure. No job in that build is in a failed state. The breakdown:

state count what it means
passed 57
expired 14 build jobs that were never assigned an agent (started_at: null, runnable for 52 minutes)
waiting_failed 215 test jobs that never ran, because the build job they depend on expired

The 14 expired jobs are build steps on every platform at once (darwin, linux, freebsd, windows, android, musl, asan). A source diff cannot cause build jobs to go unscheduled on all of them simultaneously.

The build and compile jobs that did get an agent all passed, which is the signal that matters for a C++ change: build-cpp and build-rust are green on darwin-aarch64, darwin-x64, linux-aarch64, linux-x64, and linux-x64-baseline. The only annotation on the build is a flaky test/cli/install/bun-install.test.ts retry on Windows x64-baseline, which has nothing to do with cookies.

A re-run should clear it. I have not pushed anything, to avoid stacking empty commits on the branch.

Where the PR stands

  • Rebased onto 48ff9eb2 so the three Expires expectations that cookie: update remaining cookie-map Expires assertions to IMF-fixdate #33425 landed are no longer duplicated here. MERGEABLE, no conflicts.
  • All review threads resolved; CodeRabbit's review of the rebased diff came back with no actionable comments.
  • Local: bun bd test test/js/bun/cookie/ test/js/bun/util/cookie.test.js test/js/bun/http/bun-serve-cookies.test.ts gives 315 pass, 0 fail. With src/ reverted to main, 19 of the new tests fail, so they exercise the fix rather than passing either way.

The one thing still open is a maintainer call, not a code change: whether a prefixed name missing secure should throw (what this PR does, matching the CookieStore "set a cookie" algorithm) or quietly default secure to true. Reasoning is in the comment above, and I am happy to switch it either way.

robobun added a commit that referenced this pull request Jul 27, 2026
Cookie::validateAttributes(name, domain, path, secure, sameSite, partitioned)
now holds every RFC 6265bis attribute combination browsers reject:
SameSite=None without Secure, Partitioned without Secure, and the
__Secure-/__Host- name-prefix rules. It runs from Cookie::create(CookieInit),
CookieMap::set, and the domain/path/secure/sameSite/partitioned setters.
Cookie.parse keeps reporting what was on the wire.

CookieMap::delete normalizes a __Host- tombstone (no Domain, Path=/) instead
of throwing, since the browser could only have stored that shape, and it now
validates before mutating so a rejected delete leaves the map untouched.

Supersedes #33459.
@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

The __Secure-/__Host- prefix rules are now folded into #36106 alongside the SameSite=None/Partitioned Secure requirement as a single Cookie::validateAttributes check. Closing this one in favour of that.

@robobun robobun closed this Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant