Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion docs/runtime/cookies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,17 @@ const cookie = new Bun.Cookie("visited", "true");
cookies.set(cookie);
```

Cookie names are allowed to carry the `__Secure-` and `__Host-` prefixes, which browsers only honor when the cookie meets the prefix's requirements. Bun throws a `TypeError` instead of emitting a cookie every browser would ignore:

- `__Secure-` requires `secure: true`.
- `__Host-` requires `secure: true`, no `domain`, and a `path` of `"/"`.

```ts title="prefixed-cookie.ts" icon="/icons/typescript.svg"
cookies.set("__Host-session", "abc123", { secure: true });

cookies.set("__Host-session", "abc123"); // TypeError
```

Comment thread
coderabbitai[bot] marked this conversation as resolved.
#### `delete(name: string): void`

#### `delete(options: CookieStoreDeleteOptions): void`
Expand All @@ -131,6 +142,8 @@ cookies.delete({
});
```

The expiring cookie has to satisfy the name's prefix requirements too, so `delete()` throws a `TypeError` for the same combinations `set()` rejects. Deleting a `__Host-` cookie with a `domain`, or with a `path` other than `"/"`, would emit a cookie the browser ignores, leaving the original cookie in place.

#### `toJSON(): Record<string, string>`

Converts the cookie map to a serializable format.
Expand Down Expand Up @@ -379,10 +392,15 @@ const cookie = Bun.Cookie.from("session", "abc123", {

```ts title="types.ts" icon="/icons/typescript.svg"
interface CookieInit {
/**
* A name starting with `__Secure-` requires `secure`, and a name starting with `__Host-`
* requires `secure`, no `domain`, and a `path` of `/`. Browsers ignore cookies that use
* one of these prefixes without meeting its requirements.
*/
name?: string;
value?: string;
domain?: string;
/** Defaults to '/'. To allow the browser to set the path, use an empty string. */
/** Defaults to '/'. Must start with '/'. To allow the browser to set the path, use an empty string. */
path?: string;
expires?: number | Date | string;
secure?: boolean;
Expand Down
7 changes: 6 additions & 1 deletion packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9508,10 +9508,15 @@ declare module "bun" {
| [pkg: string, info: Pick<BunLockFileBasePackageInfo, "bin" | "binDir">];

interface CookieInit {
/**
* A name starting with `__Secure-` requires `secure`, and a name starting with `__Host-`
* requires `secure`, no `domain`, and a `path` of `/`. Browsers ignore cookies that use
* one of these prefixes without meeting its requirements.
*/
name?: string;
value?: string;
domain?: string;
/** Defaults to '/'. To allow the browser to set the path, use an empty string. */
/** Defaults to '/'. Must start with '/'. To allow the browser to set the path, use an empty string. */
Comment thread
robobun marked this conversation as resolved.
path?: string;
expires?: number | Date | string;
secure?: boolean;
Expand Down
100 changes: 93 additions & 7 deletions src/jsc/bindings/Cookie.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -34,21 +34,94 @@ Cookie::Cookie(const String& name, const String& value,
{
}

static ExceptionOr<void> validateCookieAttributes(const String& name, const String& domain, const String& path)
{
if (!Cookie::isValidCookieName(name)) {
return Exception { TypeError, "Invalid cookie name: contains invalid characters"_s };
}
if (auto validation = Cookie::validateCookiePath(path); validation.hasException()) {
return validation.releaseException();
}
if (!Cookie::isValidCookieDomain(domain)) {
return Exception { TypeError, "Invalid cookie domain: contains invalid characters"_s };
}
return {};
}

ExceptionOr<Ref<Cookie>> Cookie::create(const String& name, const String& value,
const String& domain, const String& path,
int64_t expires, bool secure, CookieSameSite sameSite,
bool httpOnly, double maxAge, bool partitioned)
{
if (!isValidCookieName(name)) {
return Exception { TypeError, "Invalid cookie name: contains invalid characters"_s };
if (auto validation = validateCookieAttributes(name, domain, path); validation.hasException()) {
return validation.releaseException();
}
if (!isValidCookiePath(path)) {
return Exception { TypeError, "Invalid cookie path: contains invalid characters"_s };
return adoptRef(*new Cookie(name, value, domain, path, expires, secure, sameSite, httpOnly, maxAge, partitioned));
}

ExceptionOr<Ref<Cookie>> Cookie::create(const CookieInit& init)
{
if (auto validation = validateCookieAttributes(init.name, init.domain, init.path); validation.hasException()) {
return validation.releaseException();
}
if (!isValidCookieDomain(domain)) {
return Exception { TypeError, "Invalid cookie domain: contains invalid characters"_s };
if (auto validation = validateNamePrefix(init.name, init.domain, init.path, init.secure); validation.hasException()) {
return validation.releaseException();
}
return adoptRef(*new Cookie(name, value, domain, path, expires, secure, sameSite, httpOnly, maxAge, partitioned));
return adoptRef(*new Cookie(init.name, init.value, init.domain, init.path, init.expires, init.secure, init.sameSite, init.httpOnly, init.maxAge, init.partitioned));
}

// RFC 6265bis 4.1.3: a user agent ignores a cookie whose name carries one of these prefixes
// unless the cookie satisfies the prefix's requirements. Browsers match the prefix
// case-insensitively.
bool Cookie::hasHostPrefix(const String& name)
{
return name.startsWithIgnoringASCIICase("__Host-"_s);
}

bool Cookie::hasSecurePrefix(const String& name)
{
return name.startsWithIgnoringASCIICase("__Secure-"_s);
}

ExceptionOr<void> Cookie::validatePrefixSecure(const String& name, bool secure)
{
if (secure) {
return {};
}
if (hasHostPrefix(name)) {
return Exception { TypeError, "Invalid cookie name: \"__Host-\" prefix requires secure: true"_s };
}
if (hasSecurePrefix(name)) {
return Exception { TypeError, "Invalid cookie name: \"__Secure-\" prefix requires secure: true"_s };
}
return {};
}

ExceptionOr<void> Cookie::validatePrefixDomain(const String& name, const String& domain)
{
if (!domain.isEmpty() && hasHostPrefix(name)) {
return Exception { TypeError, "Invalid cookie name: \"__Host-\" prefix does not allow a domain"_s };
}
return {};
}

ExceptionOr<void> Cookie::validatePrefixPath(const String& name, const String& path)
{
if (path != "/"_s && hasHostPrefix(name)) {
return Exception { TypeError, "Invalid cookie name: \"__Host-\" prefix requires path: \"/\""_s };
}
return {};
}

ExceptionOr<void> Cookie::validateNamePrefix(const String& name, const String& domain, const String& path, bool secure)
{
if (auto validation = validatePrefixSecure(name, secure); validation.hasException()) {
return validation.releaseException();
}
if (auto validation = validatePrefixDomain(name, domain); validation.hasException()) {
return validation.releaseException();
}
return validatePrefixPath(name, path);
}

String Cookie::serialize(JSC::VM& vm, const std::span<const Ref<Cookie>> cookies)
Expand Down Expand Up @@ -230,6 +303,19 @@ bool Cookie::isValidCookiePath(const String& path)
return true;
}

ExceptionOr<void> Cookie::validateCookiePath(const String& path)
{
if (!isValidCookiePath(path)) {
return Exception { TypeError, "Invalid cookie path: contains invalid characters"_s };
}
// RFC 6265 5.2.4: a user agent ignores a Path attribute that does not start with "/" and
// scopes the cookie to the request's directory instead. An empty path omits the attribute.
if (!path.isEmpty() && !path.startsWith('/')) {
return Exception { TypeError, "Invalid cookie path: must start with \"/\""_s };
}
return {};
}

static inline bool isValidCharacterInCookieDomain(char16_t c)
{
return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '-';
Expand Down
47 changes: 30 additions & 17 deletions src/jsc/bindings/Cookie.h
Original file line number Diff line number Diff line change
Expand Up @@ -42,20 +42,8 @@ class Cookie : public RefCounted<Cookie> {
int64_t expires, bool secure, CookieSameSite sameSite,
bool httpOnly, double maxAge, bool partitioned);

static ExceptionOr<Ref<Cookie>> create(const CookieInit& init)
{
if (!isValidCookieName(init.name)) {
return Exception { TypeError, "Invalid cookie name: contains invalid characters"_s };
}
if (!isValidCookiePath(init.path)) {
return Exception { TypeError, "Invalid cookie path: contains invalid characters"_s };
}
if (!isValidCookieDomain(init.domain)) {
return Exception { TypeError, "Invalid cookie domain: contains invalid characters"_s };
}

return create(init.name, init.value, init.domain, init.path, init.expires, init.secure, init.sameSite, init.httpOnly, init.maxAge, init.partitioned);
}
// "Set a cookie": additionally enforces the __Secure-/__Host- name prefix rules.
static ExceptionOr<Ref<Cookie>> create(const CookieInit& init);

static ExceptionOr<Ref<Cookie>> parse(StringView cookieString);

Expand All @@ -72,15 +60,21 @@ class Cookie : public RefCounted<Cookie> {
if (!isValidCookieDomain(domain)) {
return Exception { TypeError, "Invalid cookie domain: contains invalid characters"_s };
}
if (auto validation = validatePrefixDomain(m_name, domain); validation.hasException()) {
return validation.releaseException();
}
m_domain = domain;
return {};
}

const String& path() const { return m_path; }
ExceptionOr<void> setPath(const String& path)
{
if (!isValidCookiePath(path)) {
return Exception { TypeError, "Invalid cookie path: contains invalid characters"_s };
if (auto validation = validateCookiePath(path); validation.hasException()) {
return validation.releaseException();
}
if (auto validation = validatePrefixPath(m_name, path); validation.hasException()) {
return validation.releaseException();
}
m_path = path;
return {};
Expand All @@ -91,7 +85,14 @@ class Cookie : public RefCounted<Cookie> {
bool hasExpiry() const { return m_expires != emptyExpiresAtValue; }

bool secure() const { return m_secure; }
void setSecure(bool secure) { m_secure = secure; }
ExceptionOr<void> setSecure(bool secure)
{
if (auto validation = validatePrefixSecure(m_name, secure); validation.hasException()) {
return validation.releaseException();
}
m_secure = secure;
return {};
}

CookieSameSite sameSite() const { return m_sameSite; }
void setSameSite(CookieSameSite sameSite) { m_sameSite = sameSite; }
Expand All @@ -117,7 +118,19 @@ class Cookie : public RefCounted<Cookie> {
static bool isValidCookiePath(const String& path);
static bool isValidCookieDomain(const String& domain);

static bool hasHostPrefix(const String& name);
static bool hasSecurePrefix(const String& name);

static ExceptionOr<void> validateCookiePath(const String& path);
static ExceptionOr<void> validateNamePrefix(const String& name, const String& domain, const String& path, bool secure);

private:
// A cookie's name is immutable, so each attribute the name's prefix constrains is checked
// on its own: at creation, when it is set on a CookieMap, and whenever it is mutated.
static ExceptionOr<void> validatePrefixSecure(const String& name, bool secure);
static ExceptionOr<void> validatePrefixDomain(const String& name, const String& domain);
static ExceptionOr<void> validatePrefixPath(const String& name, const String& path);

Cookie(const String& name, const String& value,
const String& domain, const String& path,
int64_t expires, bool secure, CookieSameSite sameSite,
Expand Down
26 changes: 16 additions & 10 deletions src/jsc/bindings/CookieMap.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -180,29 +180,35 @@ void CookieMap::removeInternal(const String& name)
});
}

void CookieMap::set(Ref<Cookie> cookie)
ExceptionOr<void> CookieMap::set(Ref<Cookie> cookie)
{
// A Cookie can also reach here straight from Cookie.parse(), which reports what was on
// the wire rather than enforcing the prefix rules, so re-check before it is emitted.
if (auto validation = Cookie::validateNamePrefix(cookie->name(), cookie->domain(), cookie->path(), cookie->secure()); validation.hasException()) {
return validation.releaseException();
}

removeInternal(cookie->name());
// Add the new cookie
m_modifiedCookies.append(WTF::move(cookie));
return {};
}

ExceptionOr<void> CookieMap::remove(const CookieStoreDeleteOptions& options)
{
removeInternal(options.name);

String name = options.name;
String domain = options.domain;
String path = options.path;
bool secure = name.startsWithIgnoringASCIICase("__Secure-"_s) || name.startsWithIgnoringASCIICase("__Host-"_s);
// The expiring cookie has to satisfy the prefix rules too, or the user agent ignores it
// and the cookie stays in the browser.
bool secure = Cookie::hasSecurePrefix(name) || Cookie::hasHostPrefix(name);
CookieInit init { name, ""_s, options.domain, options.path, 1, secure, CookieSameSite::Lax, false, std::numeric_limits<double>::quiet_NaN(), false };

// Add the new cookie
auto cookie_exception = Cookie::create(name, ""_s, domain, path, 1, secure, CookieSameSite::Lax, false, std::numeric_limits<double>::quiet_NaN(), false);
auto cookie_exception = Cookie::create(init);
if (cookie_exception.hasException()) {
return cookie_exception.releaseException();
}
auto cookie = cookie_exception.releaseReturnValue();
m_modifiedCookies.append(WTF::move(cookie));

removeInternal(name);
m_modifiedCookies.append(cookie_exception.releaseReturnValue());
return {};
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand Down
2 changes: 1 addition & 1 deletion src/jsc/bindings/CookieMap.h
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class CookieMap : public RefCounted<CookieMap> {

bool has(const String& name) const;

void set(Ref<Cookie>);
ExceptionOr<void> set(Ref<Cookie>);

Ref<CookieMap> clone();

Expand Down
2 changes: 1 addition & 1 deletion src/jsc/bindings/webcore/JSCookie.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -776,7 +776,7 @@ JSC_DEFINE_CUSTOM_SETTER(jsCookiePrototypeSetter_secure, (JSGlobalObject * lexic
auto& impl = thisObject->wrapped();
auto value = convert<IDLBoolean>(*lexicalGlobalObject, JSValue::decode(encodedValue));
RETURN_IF_EXCEPTION(throwScope, false);
impl.setSecure(value);
WebCore::propagateException(*lexicalGlobalObject, throwScope, impl.setSecure(value));
return true;
}

Expand Down
10 changes: 5 additions & 5 deletions src/jsc/bindings/webcore/JSCookieMap.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -379,10 +379,9 @@ static inline JSC::EncodedJSValue jsCookieMapPrototypeFunction_setBody(JSC::JSGl
CookieInit cookieInit = {};

// Check if we're setting with a Cookie object directly
if (arg0.isObject() && JSCookie::toWrapped(vm, arg0)) {
auto* cookieImpl = JSCookie::toWrapped(vm, arg0);
if (cookieImpl)
impl.set(Ref<Cookie>(*cookieImpl));
if (auto* cookieImpl = arg0.isObject() ? JSCookie::toWrapped(vm, arg0) : nullptr) {
WebCore::propagateException(*lexicalGlobalObject, throwScope, impl.set(Ref<Cookie>(*cookieImpl)));
RETURN_IF_EXCEPTION(throwScope, {});
return JSValue::encode(jsUndefined());
} else if (arg0.isObject()) {
auto* obj = arg0.getObject();
Expand Down Expand Up @@ -418,7 +417,8 @@ static inline JSC::EncodedJSValue jsCookieMapPrototypeFunction_setBody(JSC::JSGl
}
auto cookie = cookie_exception.releaseReturnValue();

impl.set(WTF::move(cookie));
WebCore::propagateException(*lexicalGlobalObject, throwScope, impl.set(WTF::move(cookie)));
RETURN_IF_EXCEPTION(throwScope, {});

return JSValue::encode(jsUndefined());
}
Expand Down
Loading
Loading