mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 23:53:17 -05:00
Merge branch 'main' into ping-maxwell/c-ping-maxwell/fix-error-link-apostrophe-f89a
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"better-auth": patch
|
||||
"@better-auth/electron": patch
|
||||
---
|
||||
|
||||
Cookie values containing characters outside the bare cookie-octet range (such as `;`, `"`, or `\`) are now percent-encoded into the `Cookie` header. They were previously dropped on re-serialization, which could break flows that store structured values in cookies.
|
||||
@@ -1,4 +1,5 @@
|
||||
function tryDecode(str: string): string {
|
||||
if (str.indexOf("%") === -1) return str;
|
||||
try {
|
||||
return decodeURIComponent(str);
|
||||
} catch {
|
||||
@@ -101,13 +102,13 @@ export function parseSetCookieHeader(
|
||||
const [nameValue, ...attributes] = parts;
|
||||
const [name, ...valueParts] = (nameValue || "").split("=");
|
||||
|
||||
const value = valueParts.join("=");
|
||||
const value = unquoteCookieValue(valueParts.join("="));
|
||||
|
||||
if (!name || value === undefined) {
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const decodedValue = value.includes("%") ? tryDecode(value) : value;
|
||||
const decodedValue = tryDecode(value);
|
||||
const attrObj: CookieAttributes = { value: decodedValue };
|
||||
|
||||
attributes.forEach((attribute) => {
|
||||
@@ -178,7 +179,7 @@ export function toCookieOptions(
|
||||
*
|
||||
* @see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6
|
||||
*/
|
||||
const cookieNameRegex = /^[\w!#$%&'*.^`|~+-]+$/;
|
||||
export const cookieNameRegex = /^[\w!#$%&'*.^`|~+-]+$/;
|
||||
|
||||
/**
|
||||
* Cookie-value char set per RFC 6265 §4.1.1, plus space and comma.
|
||||
@@ -188,6 +189,18 @@ const cookieNameRegex = /^[\w!#$%&'*.^`|~+-]+$/;
|
||||
*/
|
||||
const cookieValueRegex = /^[ !#-:<-[\]-~]*$/;
|
||||
|
||||
/**
|
||||
* Strip surrounding double-quotes per RFC 6265 §4.1.1 quoted-string form.
|
||||
*
|
||||
* @see https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1
|
||||
*/
|
||||
function unquoteCookieValue(value: string): string {
|
||||
if (value.length < 2 || !value.startsWith('"') || !value.endsWith('"')) {
|
||||
return value;
|
||||
}
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim leading/trailing OWS (space / horizontal tab) per RFC 7230 §3.2.3.
|
||||
* Narrower than `String.prototype.trim()`, which strips CR/LF and other
|
||||
@@ -225,12 +238,10 @@ export function parseCookies(cookie: string): Map<string, string> {
|
||||
const eq = chunk.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
const key = trimOWS(chunk.slice(0, eq));
|
||||
let val = trimOWS(chunk.slice(eq + 1));
|
||||
if (val.length >= 2 && val[0] === '"' && val[val.length - 1] === '"') {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
const val = unquoteCookieValue(trimOWS(chunk.slice(eq + 1)));
|
||||
// Validate wire-format octets, then decode to the semantic value.
|
||||
if (cookieNameRegex.test(key) && cookieValueRegex.test(val)) {
|
||||
cookieMap.set(key, val);
|
||||
cookieMap.set(key, tryDecode(val));
|
||||
}
|
||||
}
|
||||
return cookieMap;
|
||||
@@ -250,10 +261,14 @@ export function setRequestCookie(
|
||||
value: string,
|
||||
): void {
|
||||
const cookieMap = parseCookies(headers.get("cookie") || "");
|
||||
cookieMap.set(name, value);
|
||||
if (cookieNameRegex.test(name)) {
|
||||
cookieMap.set(name, value);
|
||||
}
|
||||
headers.set(
|
||||
"cookie",
|
||||
Array.from(cookieMap, ([k, v]) => `${k}=${v}`).join("; "),
|
||||
Array.from(cookieMap, ([k, v]) => `${k}=${encodeURIComponent(v)}`).join(
|
||||
"; ",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -272,12 +287,16 @@ export function applySetCookies(
|
||||
const cookieMap = parseCookies(target.get("cookie") || "");
|
||||
for (const setCookie of setCookieValues) {
|
||||
for (const [name, attr] of parseSetCookieHeader(setCookie)) {
|
||||
cookieMap.set(name, attr.value);
|
||||
if (cookieNameRegex.test(name)) {
|
||||
cookieMap.set(name, attr.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
target.set(
|
||||
"cookie",
|
||||
Array.from(cookieMap, ([k, v]) => `${k}=${v}`).join("; "),
|
||||
Array.from(cookieMap, ([k, v]) => `${k}=${encodeURIComponent(v)}`).join(
|
||||
"; ",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -411,6 +411,18 @@ describe("cookie-utils setRequestCookie", () => {
|
||||
"valid=1; locale=en; better-auth.session_token=abc",
|
||||
);
|
||||
});
|
||||
|
||||
it("percent-encodes reserved cookie-octet bytes when serializing", () => {
|
||||
const headers = new Headers({ cookie: "locale=en" });
|
||||
setRequestCookie(headers, "session", "foo;bar=baz");
|
||||
expect(headers.get("cookie")).toBe("locale=en; session=foo%3Bbar%3Dbaz");
|
||||
});
|
||||
|
||||
it("treats input as semantic and percent-encodes literal double-quotes", () => {
|
||||
const headers = new Headers();
|
||||
setRequestCookie(headers, "token", '"abc"');
|
||||
expect(headers.get("cookie")).toBe("token=%22abc%22");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSessionCookie", async () => {
|
||||
@@ -1574,6 +1586,18 @@ describe("applySetCookies", () => {
|
||||
applySetCookies(headers, ["a=new; Path=/"]);
|
||||
expect(headers.get("cookie")).toBe("a=new; b=keep");
|
||||
});
|
||||
|
||||
it("re-encodes Set-Cookie values containing reserved bytes on wire join", () => {
|
||||
const headers = new Headers({ cookie: "session=safe" });
|
||||
applySetCookies(headers, ["pref=foo%3Bbar=hello; Path=/"]);
|
||||
expect(headers.get("cookie")).toBe("session=safe; pref=foo%3Bbar%3Dhello");
|
||||
});
|
||||
|
||||
it("strips RFC 6265 quoted-string wrapping from Set-Cookie values", () => {
|
||||
const headers = new Headers();
|
||||
applySetCookies(headers, ['token="abc"; Path=/']);
|
||||
expect(headers.get("cookie")).toBe("token=abc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("expireCookie", () => {
|
||||
|
||||
@@ -70,21 +70,18 @@ export const bearer = (options?: BearerOptions | undefined) => {
|
||||
return;
|
||||
}
|
||||
|
||||
let signedToken: string;
|
||||
let decodedToken: string;
|
||||
|
||||
if (token.includes(".")) {
|
||||
const isEncoded = token.includes("%");
|
||||
signedToken = isEncoded ? token : encodeURIComponent(token);
|
||||
decodedToken = isEncoded ? tryDecode(token) : token;
|
||||
decodedToken = token.includes("%") ? tryDecode(token) : token;
|
||||
} else {
|
||||
if (options?.requireSignature) {
|
||||
return;
|
||||
}
|
||||
signedToken = (
|
||||
const signed = (
|
||||
await serializeSignedCookie("", token, c.context.secret)
|
||||
).replace("=", "");
|
||||
decodedToken = tryDecode(signedToken);
|
||||
decodedToken = tryDecode(signed);
|
||||
}
|
||||
try {
|
||||
const isValid = await createHMAC(
|
||||
@@ -109,7 +106,7 @@ export const bearer = (options?: BearerOptions | undefined) => {
|
||||
setRequestCookie(
|
||||
headers,
|
||||
c.context.authCookies.sessionToken.name,
|
||||
signedToken,
|
||||
decodedToken,
|
||||
);
|
||||
return {
|
||||
context: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseSetCookieHeader } from "better-auth/cookies";
|
||||
import { cookieNameRegex, parseSetCookieHeader } from "better-auth/cookies";
|
||||
|
||||
interface StoredCookie {
|
||||
value: string;
|
||||
@@ -40,13 +40,13 @@ export function getCookie(cookie: string) {
|
||||
try {
|
||||
parsed = JSON.parse(cookie) as Record<string, StoredCookie>;
|
||||
} catch (_e) {}
|
||||
const toSend = Object.entries(parsed).reduce((acc, [key, value]) => {
|
||||
if (value.expires && new Date(value.expires) < new Date()) {
|
||||
return acc;
|
||||
}
|
||||
return `${acc}; ${key}=${value.value}`;
|
||||
}, "");
|
||||
return toSend;
|
||||
const pairs: string[] = [];
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (value.expires && new Date(value.expires) < new Date()) continue;
|
||||
if (!cookieNameRegex.test(key)) continue;
|
||||
pairs.push(`${key}=${encodeURIComponent(value.value)}`);
|
||||
}
|
||||
return pairs.join("; ");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getMigrations } from "better-auth/db/migration";
|
||||
import { beforeEach, describe, expect, vi } from "vitest";
|
||||
import { authenticate, kElectron } from "../src/authenticate";
|
||||
import { electronClient } from "../src/client";
|
||||
import { getCookie } from "../src/cookies";
|
||||
import { ELECTRON_ERROR_CODES } from "../src/error-codes";
|
||||
import { electron } from "../src/index";
|
||||
import { fetchUserImage, normalizeUserOutput } from "../src/user";
|
||||
@@ -2223,3 +2224,43 @@ describe("Electron", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cookies getCookie", () => {
|
||||
it("serializes stored cookies into a Cookie header string", () => {
|
||||
const stored = JSON.stringify({
|
||||
"better-auth.session_token": { value: "abc", expires: null },
|
||||
});
|
||||
expect(getCookie(stored)).toBe("better-auth.session_token=abc");
|
||||
});
|
||||
|
||||
it("joins multiple stored cookies with `; ` without a leading separator", () => {
|
||||
const stored = JSON.stringify({
|
||||
a: { value: "1", expires: null },
|
||||
b: { value: "2", expires: null },
|
||||
});
|
||||
expect(getCookie(stored)).toBe("a=1; b=2");
|
||||
});
|
||||
|
||||
it("percent-encodes stored values containing reserved cookie-octet bytes", () => {
|
||||
const stored = JSON.stringify({
|
||||
session: { value: "safe", expires: null },
|
||||
pref: { value: "foo;bar=baz", expires: null },
|
||||
});
|
||||
expect(getCookie(stored)).toBe("session=safe; pref=foo%3Bbar%3Dbaz");
|
||||
});
|
||||
|
||||
it("skips stored entries whose name violates the cookie-name token", () => {
|
||||
const stored = JSON.stringify({
|
||||
session: { value: "safe", expires: null },
|
||||
"bad name": { value: "x", expires: null },
|
||||
});
|
||||
expect(getCookie(stored)).toBe("session=safe");
|
||||
});
|
||||
|
||||
it("skips expired entries", () => {
|
||||
const stored = JSON.stringify({
|
||||
session: { value: "abc", expires: new Date(0).toISOString() },
|
||||
});
|
||||
expect(getCookie(stored)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user