// 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 Explicit date: * * ```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 UTC milliseconds * * ```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 { const cookie = headers.get("Cookie"); if (cookie !== null) { const out: Record = {}; 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.toLowerCase()) { case "expires": cookie.expires = new Date(value); break; case "max-age": cookie.maxAge = Number(value); if (cookie.maxAge < 0) { // deno-lint-ignore no-console 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; 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) { // deno-lint-ignore no-console 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) { // deno-lint-ignore no-console console.warn( "Cookies with names starting with `__Host-` must be set with the secure flag. Cookie ignored.", ); return null; } if (cookie.domain !== undefined) { // deno-lint-ignore no-console console.warn( "Cookies with names starting with `__Host-` must not have a domain specified. Cookie ignored.", ); return null; } if (cookie.path !== "/") { // deno-lint-ignore no-console 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[]; }