@aptos-labs/aptos-client
Version:
Client package for accessing the Aptos network API.
197 lines (174 loc) • 6.05 kB
text/typescript
/** 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;
}