std/http/cookie.ts

461 lines
12 KiB
TypeScript

// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
// Structured similarly to Go's cookie.go
// https://github.com/golang/go/blob/master/src/net/http/cookie.go
// This module is browser compatible.
/**
* Represents an HTTP Cookie.
*
* @see {@link https://www.rfc-editor.org/rfc/rfc6265.html#section-4.2.1}
*/
export interface Cookie {
/** Name of the cookie. */
name: string;
/** Value of the cookie. */
value: string;
/**
* The cookie's `Expires` attribute, either as an explicit date or UTC
* milliseconds. If `undefined`, the cookie will expire when the client's
* session ends.
*
* @example <caption>Explicit date:</caption>
*
* ```ts
* import { Cookie } from "@std/http/cookie";
* const cookie: Cookie = {
* name: 'name',
* value: 'value',
* // expires on Fri Dec 30 2022
* expires: new Date('2022-12-31')
* }
* ```
*
* @example <caption>UTC milliseconds</caption>
*
* ```ts
* import { Cookie } from "@std/http/cookie";
* const cookie: Cookie = {
* name: 'name',
* value: 'value',
* // expires 10 seconds from now
* expires: Date.now() + 10000
* }
* ```
*/
expires?: Date | number;
/**
* The cookie's `Max-Age` attribute, in seconds. Must be a non-negative
* integer. A cookie with a `maxAge` of `0` expires immediately.
*/
maxAge?: number;
/**
* The cookie's `Domain` attribute. Specifies those hosts to which the cookie
* will be sent.
*/
domain?: string;
/**
* The cookie's `Path` attribute. A cookie with a path will only be included
* in the `Cookie` request header if the requested URL matches that path.
*/
path?: string;
/**
* The cookie's `Secure` attribute. If `true`, the cookie will only be
* included in the `Cookie` request header if the connection uses SSL and
* HTTPS.
*
* @default {false}
*/
secure?: boolean;
/**
* The cookie's `HTTPOnly` attribute. If `true`, the cookie cannot be accessed via JavaScript.
*
* @default {false}
*/
httpOnly?: boolean;
/**
* The cookie's `Partitioned` attribute.
* If `true`, the cookie will be only be included in the `Cookie` request header if
* the domain it is embedded by matches the domain the cookie was originally set from.
*
* Warning: This is an attribute that has not been fully standardized yet.
* It may change in the future without following the semver semantics of the package.
* Clients may ignore the attribute until they understand it.
*
* @default {false}
*/
partitioned?: boolean;
/**
* Allows servers to assert that a cookie ought not to
* be sent along with cross-site requests.
*/
sameSite?: "Strict" | "Lax" | "None";
/**
* Additional key value pairs with the form "key=value".
*
* @default {[]}
*/
unparsed?: string[];
}
const FIELD_CONTENT_REGEXP = /^(?=[\x20-\x7E]*$)[^()@<>,;:\\"\[\]?={}\s]+$/;
function toString(cookie: Cookie): string {
if (!cookie.name) {
return "";
}
const out: string[] = [];
validateName(cookie.name);
validateValue(cookie.name, cookie.value);
out.push(`${cookie.name}=${cookie.value}`);
// Fallback for invalid Set-Cookie
// ref: https://www.rfc-editor.org/rfc/rfc6265.html#section-3.1
if (cookie.name.startsWith("__Secure")) {
cookie.secure = true;
}
if (cookie.name.startsWith("__Host")) {
cookie.path = "/";
cookie.secure = true;
delete cookie.domain;
}
if (cookie.secure) {
out.push("Secure");
}
if (cookie.httpOnly) {
out.push("HttpOnly");
}
if (cookie.partitioned) {
out.push("Partitioned");
}
if (typeof cookie.maxAge === "number" && Number.isInteger(cookie.maxAge)) {
if (cookie.maxAge < 0) {
throw new RangeError(
`Cannot serialize cookie as Max-Age must be >= 0: received ${cookie.maxAge}`,
);
}
out.push(`Max-Age=${cookie.maxAge}`);
}
if (cookie.domain) {
validateDomain(cookie.domain);
out.push(`Domain=${cookie.domain}`);
}
if (cookie.sameSite) {
out.push(`SameSite=${cookie.sameSite}`);
}
if (cookie.path) {
validatePath(cookie.path);
out.push(`Path=${cookie.path}`);
}
if (cookie.expires) {
const { expires } = cookie;
const date = typeof expires === "number" ? new Date(expires) : expires;
out.push(`Expires=${date.toUTCString()}`);
}
if (cookie.unparsed) {
out.push(cookie.unparsed.join("; "));
}
return out.join("; ");
}
/**
* Validate Cookie Name.
* @param name Cookie name.
*/
function validateName(name: string | undefined | null) {
if (name && !FIELD_CONTENT_REGEXP.test(name)) {
throw new SyntaxError(`Invalid cookie name: "${name}"`);
}
}
/**
* Validate Path Value.
* See {@link https://www.rfc-editor.org/rfc/rfc6265.html#section-4.1.2.4}.
* @param path Path value.
*/
function validatePath(path: string | null) {
if (path === null) {
return;
}
for (let i = 0; i < path.length; i++) {
const c = path.charAt(i);
if (
c < String.fromCharCode(0x20) || c > String.fromCharCode(0x7E) ||
c === ";"
) {
throw new SyntaxError(
`Cookie path "${path}" contains invalid character: "${c}"`,
);
}
}
}
/**
* Validate Cookie Value.
* See {@link https://www.rfc-editor.org/rfc/rfc6265.html#section-4.1}.
* @param value Cookie value.
*/
function validateValue(name: string, value: string | null) {
if (value === null) return;
for (let i = 0; i < value.length; i++) {
const c = value.charAt(i);
if (
c < String.fromCharCode(0x21) || c === String.fromCharCode(0x22) ||
c === String.fromCharCode(0x2c) || c === String.fromCharCode(0x3b) ||
c === String.fromCharCode(0x5c) || c === String.fromCharCode(0x7f)
) {
throw new SyntaxError(
"RFC2616 cookie '" + name + "' cannot contain character '" + c + "'",
);
}
if (c > String.fromCharCode(0x80)) {
throw new SyntaxError(
"RFC2616 cookie '" + name +
"' can only have US-ASCII chars as value: It contains 0x" +
c.charCodeAt(0).toString(16),
);
}
}
}
/**
* Validate Cookie Domain.
* See {@link https://www.rfc-editor.org/rfc/rfc6265.html#section-4.1.2.3}.
* @param domain Cookie domain.
*/
function validateDomain(domain: string) {
const char1 = domain.charAt(0);
const charN = domain.charAt(domain.length - 1);
if (char1 === "-" || charN === "." || charN === "-") {
throw new SyntaxError(
"Invalid first/last char in cookie domain: " + domain,
);
}
}
/**
* Parse cookies of a header
*
* @example Usage
* ```ts
* import { getCookies } from "@std/http/cookie";
* import { assertEquals } from "@std/assert";
*
* const headers = new Headers();
* headers.set("Cookie", "full=of; tasty=chocolate");
*
* const cookies = getCookies(headers);
* assertEquals(cookies, { full: "of", tasty: "chocolate" });
* ```
*
* @param headers The headers instance to get cookies from
* @return Object with cookie names as keys
*/
export function getCookies(headers: Headers): Record<string, string> {
const cookie = headers.get("Cookie");
if (cookie !== null) {
const out: Record<string, string> = {};
const c = cookie.split(";");
for (const kv of c) {
const [cookieKey, ...cookieVal] = kv.split("=");
if (cookieKey === undefined) {
throw new SyntaxError("Cookie cannot start with '='");
}
const key = cookieKey.trim();
out[key] = cookieVal.join("=");
}
return out;
}
return {};
}
/**
* Set the cookie header properly in the headers
*
* @example Usage
* ```ts
* import { Cookie, setCookie } from "@std/http/cookie";
* import { assertEquals } from "@std/assert";
*
* const headers = new Headers();
* const cookie: Cookie = { name: "Space", value: "Cat" };
* setCookie(headers, cookie);
*
* const cookieHeader = headers.get("set-cookie");
*
* assertEquals(cookieHeader, "Space=Cat");
* ```
*
* @param headers The headers instance to set the cookie to
* @param cookie Cookie to set
*/
export function setCookie(headers: Headers, cookie: Cookie) {
// Parsing cookie headers to make consistent set-cookie header
// ref: https://www.rfc-editor.org/rfc/rfc6265.html#section-4.1.1
const v = toString(cookie);
if (v) {
headers.append("Set-Cookie", v);
}
}
/**
* Set the cookie header with empty value in the headers to delete it.
*
* The attributes (`path`, `domain`, `secure`, `httpOnly`, `partitioned`) need
* to match the values when the cookie was set.
*
* > Note: Deleting a `Cookie` will set its expiration date before now. Forcing
* > the browser to delete it.
*
* @example Usage
* ```ts
* import { deleteCookie } from "@std/http/cookie";
* import { assertEquals } from "@std/assert";
*
* const headers = new Headers();
* deleteCookie(headers, "deno");
*
* const cookieHeader = headers.get("set-cookie");
*
* assertEquals(cookieHeader, "deno=; Expires=Thu, 01 Jan 1970 00:00:00 GMT");
* ```
*
* @param headers The headers instance to delete the cookie from
* @param name Name of cookie
* @param attributes Additional cookie attributes
*/
export function deleteCookie(
headers: Headers,
name: string,
attributes?: Pick<
Cookie,
"path" | "domain" | "secure" | "httpOnly" | "partitioned"
>,
) {
setCookie(headers, {
name: name,
value: "",
expires: new Date(0),
...attributes,
});
}
function parseSetCookie(value: string): Cookie | null {
const attrs = value
.split(";")
.map((attr) => {
const [key, ...values] = attr.trim().split("=");
return [key!, values.join("=")] as const;
});
if (!attrs[0]) {
return null;
}
const cookie: Cookie = {
name: attrs[0][0],
value: attrs[0][1],
};
for (const [key, value] of attrs.slice(1)) {
switch (key.toLocaleLowerCase()) {
case "expires":
cookie.expires = new Date(value);
break;
case "max-age":
cookie.maxAge = Number(value);
if (cookie.maxAge < 0) {
console.warn(
"Max-Age must be an integer superior or equal to 0. Cookie ignored.",
);
return null;
}
break;
case "domain":
cookie.domain = value;
break;
case "path":
cookie.path = value;
break;
case "secure":
cookie.secure = true;
break;
case "httponly":
cookie.httpOnly = true;
break;
case "samesite":
cookie.sameSite = value as NonNullable<Cookie["sameSite"]>;
break;
default:
if (!Array.isArray(cookie.unparsed)) {
cookie.unparsed = [];
}
cookie.unparsed.push([key, value].join("="));
}
}
if (cookie.name.startsWith("__Secure-")) {
/** This requirement is mentioned in https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie but not the RFC. */
if (!cookie.secure) {
console.warn(
"Cookies with names starting with `__Secure-` must be set with the secure flag. Cookie ignored.",
);
return null;
}
}
if (cookie.name.startsWith("__Host-")) {
if (!cookie.secure) {
console.warn(
"Cookies with names starting with `__Host-` must be set with the secure flag. Cookie ignored.",
);
return null;
}
if (cookie.domain !== undefined) {
console.warn(
"Cookies with names starting with `__Host-` must not have a domain specified. Cookie ignored.",
);
return null;
}
if (cookie.path !== "/") {
console.warn(
"Cookies with names starting with `__Host-` must have path be `/`. Cookie has been ignored.",
);
return null;
}
}
return cookie;
}
/**
* Parse set-cookies of a header
*
* @example Usage
* ```ts
* import { getSetCookies } from "@std/http/cookie";
* import { assertEquals } from "@std/assert";
*
* const headers = new Headers([
* ["Set-Cookie", "lulu=meow; Secure; Max-Age=3600"],
* ["Set-Cookie", "booya=kasha; HttpOnly; Path=/"],
* ]);
*
* const cookies = getSetCookies(headers);
*
* assertEquals(cookies[0], {
* name: "lulu",
* value: "meow",
* secure: true,
* maxAge: 3600
* });
* ```
*
* @param headers The headers instance to get set-cookies from
* @return List of cookies
*/
export function getSetCookies(headers: Headers): Cookie[] {
return headers.getSetCookie()
/** Parse each `set-cookie` header separately */
.map(parseSetCookie)
/** Skip empty cookies */
.filter(Boolean) as Cookie[];
}