UNPKG

@aptos-labs/aptos-client

Version:

Client package for accessing the Aptos network API.

197 lines (174 loc) 6.05 kB
/** A parsed cookie with optional attributes. */ export interface Cookie { name: string; value: string; expires?: Date; sameSite?: "Lax" | "None" | "Strict"; secure?: boolean; httpOnly?: boolean; } /** * Minimal, origin-scoped cookie jar used by the Node and fetch entry points. * * @remarks * Cookies are keyed by origin (scheme + host + port). Expired cookies are * filtered out lazily when {@link getCookies} is called. The browser entry * point delegates cookie handling to the browser engine and does not use * this class. * * **Note:** A single module-level `CookieJar` instance is shared across all * requests in the same process. In multi-tenant server-side environments, * create a separate instance and pass it via {@link AptosClientRequest.cookieJar} * to avoid cross-request cookie leakage. */ export class CookieJar { static readonly MAX_COOKIES_PER_ORIGIN = 50; /** RFC 6265 §6.1 recommends at least 4096 bytes per cookie. */ static readonly MAX_COOKIE_SIZE = 8192; constructor(private jar = new Map<string, Cookie[]>()) {} /** * Store a `Set-Cookie` header value for the given URL's origin. * * @param url - The URL the response was received from. * @param cookieStr - Raw `Set-Cookie` header string. */ setCookie(url: URL, cookieStr: string) { if (cookieStr.length > CookieJar.MAX_COOKIE_SIZE) { return; // Silently drop oversized cookies } let cookie: Cookie; try { cookie = CookieJar.parse(cookieStr); } catch { return; // Silently skip malformed cookies, matching browser behavior } // RFC 6265bis: SameSite=None requires the Secure attribute if (cookie.sameSite === "None" && !cookie.secure) { return; } const key = url.origin.toLowerCase(); if (!this.jar.has(key)) { this.jar.set(key, []); } const existing = this.jar.get(key)?.filter((c) => c.name !== cookie.name) || []; // Evict oldest cookies if we're at the per-origin cap while (existing.length >= CookieJar.MAX_COOKIES_PER_ORIGIN) { existing.shift(); } this.jar.set(key, [...existing, cookie]); } /** * Return all non-expired cookies for the given URL's origin. * * @param url - The URL to match cookies against. * @returns An array of {@link Cookie} objects (may be empty). */ getCookies(url: URL): Cookie[] { const key = url.origin.toLowerCase(); const cookies = this.jar.get(key); if (!cookies) { return []; } const now = new Date(); const isSecure = url.protocol === "https:"; const live = cookies.filter((cookie) => { return !(cookie.expires && cookie.expires <= now); }); // Write back to evict expired cookies from storage if (live.length !== cookies.length) { if (live.length === 0) { this.jar.delete(key); } else { this.jar.set(key, live); } } return isSecure ? live : live.filter((cookie) => !cookie.secure); } /** Remove all stored cookies. Useful for test isolation. */ clear() { this.jar.clear(); } /** * Parse a raw `Set-Cookie` header string into a {@link Cookie} object. * * @param str - Raw `Set-Cookie` header value. * @returns Parsed cookie. * @throws If the cookie is malformed or contains control characters. */ static parse(str: string): Cookie { const parts = str.split(";").map((part) => part.trim()); let cookie: Cookie; if (parts.length > 0) { const eqIdx = parts[0].indexOf("="); if (eqIdx < 1) { throw new Error("Invalid cookie"); } const name = parts[0].slice(0, eqIdx); const value = parts[0].slice(eqIdx + 1); // RFC 6265 §4.1.1: cookie-name must be a valid RFC 7230 token if (!isValidTokenName(name)) { throw new Error("Invalid cookie: name contains invalid characters"); } // Reject control characters in value that could enable header injection if (hasControlChars(value)) { throw new Error("Invalid cookie: value contains control characters"); } cookie = { name, value, }; } else { throw new Error("Invalid cookie"); } parts.slice(1).forEach((part) => { const attrEqIdx = part.indexOf("="); const name = attrEqIdx === -1 ? part : part.slice(0, attrEqIdx); const value = attrEqIdx === -1 ? undefined : part.slice(attrEqIdx + 1); if (!name.trim()) { throw new Error("Invalid cookie"); } const nameLow = name.toLowerCase(); // Only strip quotes when both opening and closing characters match let val = value; if (value && value.length >= 2) { const first = value.charAt(0); const last = value.charAt(value.length - 1); if ((first === '"' || first === "'") && first === last) { val = value.slice(1, -1); } } if (nameLow === "expires" && val) { const date = new Date(val); if (!Number.isNaN(date.getTime())) { cookie.expires = date; } } if (nameLow === "samesite") { const normalized = val?.toLowerCase(); if (normalized === "lax") cookie.sameSite = "Lax"; else if (normalized === "none") cookie.sameSite = "None"; else if (normalized === "strict") cookie.sameSite = "Strict"; } if (nameLow === "secure") { cookie.secure = true; } if (nameLow === "httponly") { cookie.httpOnly = true; } }); return cookie; } } /** RFC 7230 token — rejects CTL, space, and separator characters. @internal */ const TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; function isValidTokenName(name: string): boolean { return TOKEN_RE.test(name); } /** Check if a string contains CTL characters per RFC 6265 (0x00-0x1F and 0x7F). @internal */ function hasControlChars(str: string): boolean { for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i); if (code <= 0x1f || code === 0x7f) return true; } return false; }