From 128a0a43a3def6e73c139a2a370c97c3a7e5f7b7 Mon Sep 17 00:00:00 2001 From: Will Harney Date: Wed, 23 Aug 2023 14:12:27 -0400 Subject: [PATCH] Split giant cookie.ts into multiple files. --- lib/__tests__/canonicalDomain.spec.ts | 2 +- lib/__tests__/cookie.spec.ts | 2 +- lib/__tests__/cookieJar.spec.ts | 14 +- lib/__tests__/cookiePrefixes.spec.ts | 3 +- lib/__tests__/cookieSorting.spec.ts | 4 +- lib/__tests__/cookieToAndFromJson.spec.ts | 2 +- lib/__tests__/cookieToString.spec.ts | 2 +- lib/__tests__/date.spec.ts | 2 +- lib/__tests__/defaultPath.spec.ts | 2 +- lib/__tests__/domainMatch.spec.ts | 2 +- lib/__tests__/ietf.spec.ts | 3 +- lib/__tests__/jarSerialization.spec.ts | 16 +- lib/__tests__/lifetime.spec.ts | 2 +- lib/__tests__/memoryCookieStore.spec.ts | 3 +- lib/__tests__/nodeUtilFallback.spec.ts | 7 +- lib/__tests__/parse.spec.ts | 2 +- lib/__tests__/pathMatch.spec.ts | 2 +- lib/__tests__/permuteDomain.spec.ts | 2 +- lib/__tests__/permutePath.spec.ts | 3 +- lib/__tests__/regression.spec.ts | 3 +- lib/__tests__/removeAll.spec.ts | 24 +- lib/__tests__/sameSite.spec.ts | 3 +- lib/cookie.ts | 2414 --------------------- lib/cookie/canonicalDomain.ts | 22 + lib/cookie/constants.ts | 36 + lib/cookie/cookie.ts | 733 +++++++ lib/cookie/cookieCompare.ts | 47 + lib/cookie/cookieJar.ts | 1137 ++++++++++ lib/cookie/defaultPath.ts | 30 + lib/cookie/domainMatch.ts | 71 + lib/cookie/formatDate.ts | 8 + lib/cookie/index.ts | 24 + lib/cookie/parseDate.ts | 254 +++ lib/cookie/permutePath.ts | 23 + lib/memstore.ts | 18 +- lib/store.ts | 3 +- lib/utils.ts | 65 + 37 files changed, 2505 insertions(+), 2485 deletions(-) delete mode 100644 lib/cookie.ts create mode 100644 lib/cookie/canonicalDomain.ts create mode 100644 lib/cookie/constants.ts create mode 100644 lib/cookie/cookie.ts create mode 100644 lib/cookie/cookieCompare.ts create mode 100644 lib/cookie/cookieJar.ts create mode 100644 lib/cookie/defaultPath.ts create mode 100644 lib/cookie/domainMatch.ts create mode 100644 lib/cookie/formatDate.ts create mode 100644 lib/cookie/index.ts create mode 100644 lib/cookie/parseDate.ts create mode 100644 lib/cookie/permutePath.ts diff --git a/lib/__tests__/canonicalDomain.spec.ts b/lib/__tests__/canonicalDomain.spec.ts index 3e5fba65..caadc82a 100644 --- a/lib/__tests__/canonicalDomain.spec.ts +++ b/lib/__tests__/canonicalDomain.spec.ts @@ -1,4 +1,4 @@ -import { canonicalDomain } from '../cookie' +import { canonicalDomain } from '../cookie/canonicalDomain' // port of tests/domain_and_path_test.js (canonicalDomain tests for domain normalization) describe('canonicalDomain', () => { diff --git a/lib/__tests__/cookie.spec.ts b/lib/__tests__/cookie.spec.ts index a058c769..5f497bf2 100644 --- a/lib/__tests__/cookie.spec.ts +++ b/lib/__tests__/cookie.spec.ts @@ -29,7 +29,7 @@ * POSSIBILITY OF SUCH DAMAGE. */ -import { Cookie } from '../cookie' +import { Cookie } from '../cookie/cookie' jest.useFakeTimers() diff --git a/lib/__tests__/cookieJar.spec.ts b/lib/__tests__/cookieJar.spec.ts index 9fc503a8..52c240f3 100644 --- a/lib/__tests__/cookieJar.spec.ts +++ b/lib/__tests__/cookieJar.spec.ts @@ -29,14 +29,12 @@ * POSSIBILITY OF SUCH DAMAGE. */ -import { - Cookie, - CookieJar, - MemoryCookieStore, - ParameterError, - SerializedCookieJar, - Store, -} from '../cookie' +import { Cookie } from '../cookie/cookie' +import { CookieJar } from '../cookie/cookieJar' +import type { SerializedCookieJar } from '../cookie/constants' +import { MemoryCookieStore } from '../memstore' +import { Store } from '../store' +import { ParameterError } from '../validators' jest.useFakeTimers() diff --git a/lib/__tests__/cookiePrefixes.spec.ts b/lib/__tests__/cookiePrefixes.spec.ts index e68b316a..770f53a9 100644 --- a/lib/__tests__/cookiePrefixes.spec.ts +++ b/lib/__tests__/cookiePrefixes.spec.ts @@ -1,4 +1,5 @@ -import { CookieJar, PrefixSecurityEnum } from '../cookie' +import { PrefixSecurityEnum } from '../cookie/constants' +import { CookieJar } from '../cookie/cookieJar' let cookieJar: CookieJar const insecureUrl = 'http://www.example.com' diff --git a/lib/__tests__/cookieSorting.spec.ts b/lib/__tests__/cookieSorting.spec.ts index cd2cc4f1..99cecdc5 100644 --- a/lib/__tests__/cookieSorting.spec.ts +++ b/lib/__tests__/cookieSorting.spec.ts @@ -1,4 +1,6 @@ -import { Cookie, cookieCompare, CookieJar } from '../cookie' +import { Cookie } from '../cookie/cookie' +import { cookieCompare } from '../cookie/cookieCompare' +import { CookieJar } from '../cookie/cookieJar' jest.useFakeTimers() diff --git a/lib/__tests__/cookieToAndFromJson.spec.ts b/lib/__tests__/cookieToAndFromJson.spec.ts index a29004ec..373a53e1 100644 --- a/lib/__tests__/cookieToAndFromJson.spec.ts +++ b/lib/__tests__/cookieToAndFromJson.spec.ts @@ -1,4 +1,4 @@ -import { Cookie } from '../cookie' +import { Cookie } from '../cookie/cookie' jest.useFakeTimers() diff --git a/lib/__tests__/cookieToString.spec.ts b/lib/__tests__/cookieToString.spec.ts index 1b9dc46a..74e37a2b 100644 --- a/lib/__tests__/cookieToString.spec.ts +++ b/lib/__tests__/cookieToString.spec.ts @@ -1,4 +1,4 @@ -import { Cookie } from '../cookie' +import { Cookie } from '../cookie/cookie' describe('Cookie.toString()', () => { const parse = (cookieString: string): Cookie => { diff --git a/lib/__tests__/date.spec.ts b/lib/__tests__/date.spec.ts index a3c554e7..535ba3f0 100644 --- a/lib/__tests__/date.spec.ts +++ b/lib/__tests__/date.spec.ts @@ -37,7 +37,7 @@ type EquivalenceDateParsingTestCase = { [key: string]: string } -import { parseDate } from '../cookie' +import { parseDate } from '../cookie/parseDate' const dateTests: DateParsingTestCase = { 'Wed, 09 Jun 2021 10:18:14 GMT': true, diff --git a/lib/__tests__/defaultPath.spec.ts b/lib/__tests__/defaultPath.spec.ts index 178aaee9..0fc55bae 100644 --- a/lib/__tests__/defaultPath.spec.ts +++ b/lib/__tests__/defaultPath.spec.ts @@ -1,4 +1,4 @@ -import { defaultPath } from '../cookie' +import { defaultPath } from '../cookie/defaultPath' // port of tests/domain_and_path_test.js (default path tests) describe('defaultPath', () => { diff --git a/lib/__tests__/domainMatch.spec.ts b/lib/__tests__/domainMatch.spec.ts index 9b6e0585..bfc3afbe 100644 --- a/lib/__tests__/domainMatch.spec.ts +++ b/lib/__tests__/domainMatch.spec.ts @@ -1,4 +1,4 @@ -import { domainMatch } from '../cookie' +import { domainMatch } from '../cookie/domainMatch' // port of tests/domain_and_path_test.js (domain match tests) describe('domainMatch', () => { diff --git a/lib/__tests__/ietf.spec.ts b/lib/__tests__/ietf.spec.ts index 1a058097..7d523663 100644 --- a/lib/__tests__/ietf.spec.ts +++ b/lib/__tests__/ietf.spec.ts @@ -29,11 +29,12 @@ * POSSIBILITY OF SUCH DAMAGE. */ -import { CookieJar, parseDate } from '../cookie' import url from 'url' import parserData from './data/parser' import bsdExampleDates from './data/dates/bsd-examples' import exampleDates from './data/dates/examples' +import { CookieJar } from '../cookie/cookieJar' +import { parseDate } from '../cookie/parseDate' describe('IETF http state tests', () => { describe('Set/get cookie tests', () => { diff --git a/lib/__tests__/jarSerialization.spec.ts b/lib/__tests__/jarSerialization.spec.ts index 9650048a..cd9c3a33 100644 --- a/lib/__tests__/jarSerialization.spec.ts +++ b/lib/__tests__/jarSerialization.spec.ts @@ -28,16 +28,12 @@ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE * POSSIBILITY OF SUCH DAMAGE. */ - -import { - Cookie, - CookieJar, - MemoryCookieStore, - SerializedCookie, - SerializedCookieJar, - Store, - version, -} from '../cookie' +import { Cookie } from '../cookie/cookie' +import { CookieJar } from '../cookie/cookieJar' +import type { SerializedCookie, SerializedCookieJar } from '../cookie/constants' +import { MemoryCookieStore } from '../memstore' +import { Store } from '../store' +import { version } from '../version' jest.useFakeTimers() diff --git a/lib/__tests__/lifetime.spec.ts b/lib/__tests__/lifetime.spec.ts index 5f2a7aef..025ad26c 100644 --- a/lib/__tests__/lifetime.spec.ts +++ b/lib/__tests__/lifetime.spec.ts @@ -1,4 +1,4 @@ -import { Cookie } from '../cookie' +import { Cookie } from '../cookie/cookie' jest.useFakeTimers() diff --git a/lib/__tests__/memoryCookieStore.spec.ts b/lib/__tests__/memoryCookieStore.spec.ts index fad701eb..242b8588 100644 --- a/lib/__tests__/memoryCookieStore.spec.ts +++ b/lib/__tests__/memoryCookieStore.spec.ts @@ -1,4 +1,5 @@ -import { Cookie, MemoryCookieStore } from '../cookie' +import { Cookie } from '../cookie/cookie' +import { MemoryCookieStore } from '../memstore' describe('MemoryCookieStore', () => { it('should have no static methods', () => { diff --git a/lib/__tests__/nodeUtilFallback.spec.ts b/lib/__tests__/nodeUtilFallback.spec.ts index 8a959cdb..5a977fdf 100644 --- a/lib/__tests__/nodeUtilFallback.spec.ts +++ b/lib/__tests__/nodeUtilFallback.spec.ts @@ -1,7 +1,8 @@ -import { getCustomInspectSymbol, getUtilInspect } from '../utilHelper' import util from 'util' -import { Cookie, CookieJar, MemoryCookieStore } from '../cookie' -import { inspectFallback } from '../memstore' +import { Cookie } from '../cookie/cookie' +import { CookieJar } from '../cookie/cookieJar' +import { MemoryCookieStore, inspectFallback } from '../memstore' +import { getCustomInspectSymbol, getUtilInspect } from '../utilHelper' jest.useFakeTimers() diff --git a/lib/__tests__/parse.spec.ts b/lib/__tests__/parse.spec.ts index bb6f117d..34c679b9 100644 --- a/lib/__tests__/parse.spec.ts +++ b/lib/__tests__/parse.spec.ts @@ -1,4 +1,4 @@ -import { Cookie } from '../cookie' +import { Cookie } from '../cookie/cookie' import { performance } from 'node:perf_hooks' describe('Cookie.parse', () => { diff --git a/lib/__tests__/pathMatch.spec.ts b/lib/__tests__/pathMatch.spec.ts index 1d42f5cd..aa1ee063 100644 --- a/lib/__tests__/pathMatch.spec.ts +++ b/lib/__tests__/pathMatch.spec.ts @@ -1,4 +1,4 @@ -import { pathMatch } from '../cookie' +import { pathMatch } from '../pathMatch' // port of tests/domain_and_path_test.js (path match tests) describe('pathMatch', () => { diff --git a/lib/__tests__/permuteDomain.spec.ts b/lib/__tests__/permuteDomain.spec.ts index 206dcd01..a3479057 100644 --- a/lib/__tests__/permuteDomain.spec.ts +++ b/lib/__tests__/permuteDomain.spec.ts @@ -1,4 +1,4 @@ -import { permuteDomain } from '../cookie' +import { permuteDomain } from '../permuteDomain' // port of tests/domain_and_path_test.js (permute domain tests) describe('permuteDomain', () => { diff --git a/lib/__tests__/permutePath.spec.ts b/lib/__tests__/permutePath.spec.ts index ee05ff34..756dec98 100644 --- a/lib/__tests__/permutePath.spec.ts +++ b/lib/__tests__/permutePath.spec.ts @@ -1,4 +1,5 @@ -import { pathMatch, permutePath } from '../cookie' +import { permutePath } from '../cookie/permutePath' +import { pathMatch } from '../pathMatch' // port of tests/domain_and_path_test.js (permute path tests) describe('permutePath', () => { diff --git a/lib/__tests__/regression.spec.ts b/lib/__tests__/regression.spec.ts index 5fd06556..daf26a2e 100644 --- a/lib/__tests__/regression.spec.ts +++ b/lib/__tests__/regression.spec.ts @@ -1,4 +1,5 @@ -import { Cookie, CookieJar } from '../cookie' +import type { Cookie } from '../cookie/cookie' +import { CookieJar } from '../cookie/cookieJar' const url = 'http://www.example.com' diff --git a/lib/__tests__/removeAll.spec.ts b/lib/__tests__/removeAll.spec.ts index 3389cb0d..1237df7b 100644 --- a/lib/__tests__/removeAll.spec.ts +++ b/lib/__tests__/removeAll.spec.ts @@ -1,12 +1,8 @@ -import { - Callback, - Cookie, - CookieJar, - MemoryCookieStore, - Store, -} from '../cookie' -import spyOn = jest.spyOn -import SpyInstance = jest.SpyInstance +import type { Cookie } from '../cookie/cookie' +import { CookieJar } from '../cookie/cookieJar' +import { MemoryCookieStore } from '../memstore' +import { Store } from '../store' +import type { Callback } from '../utils' const url = 'http://example.com/index.html' @@ -36,10 +32,7 @@ describe('store removeAllCookies API', () => { // replace remove cookie behavior to throw an error on the 4th invocation const _removeCookie = store.removeCookie.bind(store) - const spy: SpyInstance> = spyOn( - store, - 'removeCookie', - ) + const spy = jest.spyOn(store, 'removeCookie') spy.mockImplementationOnce( (domain: string, path: string, key: string, callback: Callback) => _removeCookie.call(store, domain, path, key, callback), @@ -79,10 +72,7 @@ describe('store removeAllCookies API', () => { // replace remove cookie behavior to throw an error on the 4th invocation const _removeCookie = store.removeCookie.bind(store) - const spy: SpyInstance> = spyOn( - store, - 'removeCookie', - ) + const spy = jest.spyOn(store, 'removeCookie') spy.mockImplementation( ( domain: string, diff --git a/lib/__tests__/sameSite.spec.ts b/lib/__tests__/sameSite.spec.ts index ce1540ad..8259f8ae 100644 --- a/lib/__tests__/sameSite.spec.ts +++ b/lib/__tests__/sameSite.spec.ts @@ -1,4 +1,5 @@ -import { Cookie, CookieJar } from '../cookie' +import { Cookie } from '../cookie/cookie' +import { CookieJar } from '../cookie/cookieJar' const url = 'http://example.com/index.html' diff --git a/lib/cookie.ts b/lib/cookie.ts deleted file mode 100644 index 7d873043..00000000 --- a/lib/cookie.ts +++ /dev/null @@ -1,2414 +0,0 @@ -/*! - * Copyright (c) 2015-2020, Salesforce.com, Inc. - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * 3. Neither the name of Salesforce.com nor the names of its contributors may - * be used to endorse or promote products derived from this software without - * specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE - * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR - * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF - * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS - * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN - * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) - * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE - * POSSIBILITY OF SUCH DAMAGE. - */ - -import * as punycode from 'punycode/' -import urlParse from 'url-parse' -import * as pubsuffix from './pubsuffix-psl' -import { Store } from './store' -import { MemoryCookieStore } from './memstore' -import { pathMatch } from './pathMatch' -import * as validators from './validators' -import { version } from './version' -import { permuteDomain } from './permuteDomain' -import { getCustomInspectSymbol } from './utilHelper' -import { ErrorCallback, safeToString } from './utils' - -// From RFC6265 S4.1.1 -// note that it excludes \x3B ";" -const COOKIE_OCTETS = /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]+$/ - -// eslint-disable-next-line no-control-regex -const CONTROL_CHARS = /[\x00-\x1F]/ - -// From Chromium // '\r', '\n' and '\0' should be treated as a terminator in -// the "relaxed" mode, see: -// https://github.com/ChromiumWebApps/chromium/blob/b3d3b4da8bb94c1b2e061600df106d590fda3620/net/cookies/parsed_cookie.cc#L60 -const TERMINATORS = ['\n', '\r', '\0'] - -// RFC6265 S4.1.1 defines path value as 'any CHAR except CTLs or ";"' -// Note ';' is \x3B -const PATH_VALUE = /[\x20-\x3A\x3C-\x7E]+/ - -// date-time parsing constants (RFC6265 S5.1.1) - -// eslint-disable-next-line no-control-regex -const DATE_DELIM = /[\x09\x20-\x2F\x3B-\x40\x5B-\x60\x7B-\x7E]/ - -const MONTH_TO_NUM = { - jan: 0, - feb: 1, - mar: 2, - apr: 3, - may: 4, - jun: 5, - jul: 6, - aug: 7, - sep: 8, - oct: 9, - nov: 10, - dec: 11, -} - -const MAX_TIME = 2147483647000 // 31-bit max -const SAME_SITE_CONTEXT_VAL_ERR = - 'Invalid sameSiteContext option for getCookies(); expected one of "strict", "lax", or "none"' - -function checkSameSiteContext(value: string) { - validators.validate(validators.isNonEmptyString(value), value) - const context = String(value).toLowerCase() - if (context === 'none' || context === 'lax' || context === 'strict') { - return context - } else { - return null - } -} - -const PrefixSecurityEnum = Object.freeze({ - SILENT: 'silent', - STRICT: 'strict', - DISABLED: 'unsafe-disabled', -}) - -// Dumped from ip-regex@4.0.0, with the following changes: -// * all capturing groups converted to non-capturing -- "(?:)" -// * support for IPv6 Scoped Literal ("%eth1") removed -// * lowercase hexadecimal only -const IP_REGEX_LOWERCASE = - /(?:^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}$)|(?:^(?:(?:[a-f\d]{1,4}:){7}(?:[a-f\d]{1,4}|:)|(?:[a-f\d]{1,4}:){6}(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|:[a-f\d]{1,4}|:)|(?:[a-f\d]{1,4}:){5}(?::(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-f\d]{1,4}){1,2}|:)|(?:[a-f\d]{1,4}:){4}(?:(?::[a-f\d]{1,4}){0,1}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-f\d]{1,4}){1,3}|:)|(?:[a-f\d]{1,4}:){3}(?:(?::[a-f\d]{1,4}){0,2}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-f\d]{1,4}){1,4}|:)|(?:[a-f\d]{1,4}:){2}(?:(?::[a-f\d]{1,4}){0,3}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-f\d]{1,4}){1,5}|:)|(?:[a-f\d]{1,4}:){1}(?:(?::[a-f\d]{1,4}){0,4}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-f\d]{1,4}){1,6}|:)|(?::(?:(?::[a-f\d]{1,4}){0,5}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-f\d]{1,4}){1,7}|:)))$)/ -const IP_V6_REGEX = ` -\\[?(?: -(?:[a-fA-F\\d]{1,4}:){7}(?:[a-fA-F\\d]{1,4}|:)| -(?:[a-fA-F\\d]{1,4}:){6}(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|:[a-fA-F\\d]{1,4}|:)| -(?:[a-fA-F\\d]{1,4}:){5}(?::(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,2}|:)| -(?:[a-fA-F\\d]{1,4}:){4}(?:(?::[a-fA-F\\d]{1,4}){0,1}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,3}|:)| -(?:[a-fA-F\\d]{1,4}:){3}(?:(?::[a-fA-F\\d]{1,4}){0,2}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,4}|:)| -(?:[a-fA-F\\d]{1,4}:){2}(?:(?::[a-fA-F\\d]{1,4}){0,3}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,5}|:)| -(?:[a-fA-F\\d]{1,4}:){1}(?:(?::[a-fA-F\\d]{1,4}){0,4}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,6}|:)| -(?::(?:(?::[a-fA-F\\d]{1,4}){0,5}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,7}|:)) -)(?:%[0-9a-zA-Z]{1,})?\\]? -` - .replace(/\s*\/\/.*$/gm, '') - .replace(/\n/g, '') - .trim() -const IP_V6_REGEX_OBJECT = new RegExp(`^${IP_V6_REGEX}$`) - -/* - * Parses a Natural number (i.e., non-negative integer) with either the - * *DIGIT ( non-digit *OCTET ) - * or - * *DIGIT - * grammar (RFC6265 S5.1.1). - * - * The "trailingOK" boolean controls if the grammar accepts a - * "( non-digit *OCTET )" trailer. - */ -function parseDigits( - token: string, - minDigits: number, - maxDigits: number, - trailingOK: boolean, -) { - let count = 0 - while (count < token.length) { - const c = token.charCodeAt(count) - // "non-digit = %x00-2F / %x3A-FF" - if (c <= 0x2f || c >= 0x3a) { - break - } - count++ - } - - // constrain to a minimum and maximum number of digits. - if (count < minDigits || count > maxDigits) { - return null - } - - if (!trailingOK && count != token.length) { - return null - } - - return parseInt(token.substr(0, count), 10) -} - -function parseTime(token: string) { - const parts = token.split(':') - const result = [0, 0, 0] - - /* RF6256 S5.1.1: - * time = hms-time ( non-digit *OCTET ) - * hms-time = time-field ":" time-field ":" time-field - * time-field = 1*2DIGIT - */ - - if (parts.length !== 3) { - return null - } - - for (let i = 0; i < 3; i++) { - // "time-field" must be strictly "1*2DIGIT", HOWEVER, "hms-time" can be - // followed by "( non-digit *OCTET )" therefore the last time-field can - // have a trailer - const trailingOK = i == 2 - const numPart = parts[i] - if (numPart == null) { - return null - } - const num = parseDigits(numPart, 1, 2, trailingOK) - if (num === null) { - return null - } - result[i] = num - } - - return result -} - -function parseMonth(token: string) { - token = String(token).substr(0, 3).toLowerCase() - switch (token) { - case 'jan': - return MONTH_TO_NUM.jan - case 'feb': - return MONTH_TO_NUM.feb - case 'mar': - return MONTH_TO_NUM.mar - case 'apr': - return MONTH_TO_NUM.apr - case 'may': - return MONTH_TO_NUM.may - case 'jun': - return MONTH_TO_NUM.jun - case 'jul': - return MONTH_TO_NUM.jul - case 'aug': - return MONTH_TO_NUM.aug - case 'sep': - return MONTH_TO_NUM.sep - case 'oct': - return MONTH_TO_NUM.oct - case 'nov': - return MONTH_TO_NUM.nov - case 'dec': - return MONTH_TO_NUM.dec - default: - return null - } -} - -/* - * RFC6265 S5.1.1 date parser (see RFC for full grammar) - */ -function parseDate(str: string | undefined | null): Date | undefined { - if (!str) { - return undefined - } - - /* RFC6265 S5.1.1: - * 2. Process each date-token sequentially in the order the date-tokens - * appear in the cookie-date - */ - const tokens = str.split(DATE_DELIM) - if (!tokens) { - return undefined - } - - let hour = null - let minute = null - let second = null - let dayOfMonth = null - let month = null - let year = null - - for (let i = 0; i < tokens.length; i++) { - const token = (tokens[i] ?? '').trim() - if (!token.length) { - continue - } - - let result - - /* 2.1. If the found-time flag is not set and the token matches the time - * production, set the found-time flag and set the hour- value, - * minute-value, and second-value to the numbers denoted by the digits in - * the date-token, respectively. Skip the remaining sub-steps and continue - * to the next date-token. - */ - if (second === null) { - result = parseTime(token) - if (result) { - hour = result[0] - minute = result[1] - second = result[2] - continue - } - } - - /* 2.2. If the found-day-of-month flag is not set and the date-token matches - * the day-of-month production, set the found-day-of- month flag and set - * the day-of-month-value to the number denoted by the date-token. Skip - * the remaining sub-steps and continue to the next date-token. - */ - if (dayOfMonth === null) { - // "day-of-month = 1*2DIGIT ( non-digit *OCTET )" - result = parseDigits(token, 1, 2, true) - if (result !== null) { - dayOfMonth = result - continue - } - } - - /* 2.3. If the found-month flag is not set and the date-token matches the - * month production, set the found-month flag and set the month-value to - * the month denoted by the date-token. Skip the remaining sub-steps and - * continue to the next date-token. - */ - if (month === null) { - result = parseMonth(token) - if (result !== null) { - month = result - continue - } - } - - /* 2.4. If the found-year flag is not set and the date-token matches the - * year production, set the found-year flag and set the year-value to the - * number denoted by the date-token. Skip the remaining sub-steps and - * continue to the next date-token. - */ - if (year === null) { - // "year = 2*4DIGIT ( non-digit *OCTET )" - result = parseDigits(token, 2, 4, true) - if (result !== null) { - year = result - /* From S5.1.1: - * 3. If the year-value is greater than or equal to 70 and less - * than or equal to 99, increment the year-value by 1900. - * 4. If the year-value is greater than or equal to 0 and less - * than or equal to 69, increment the year-value by 2000. - */ - if (year >= 70 && year <= 99) { - year += 1900 - } else if (year >= 0 && year <= 69) { - year += 2000 - } - } - } - } - - /* RFC 6265 S5.1.1 - * "5. Abort these steps and fail to parse the cookie-date if: - * * at least one of the found-day-of-month, found-month, found- - * year, or found-time flags is not set, - * * the day-of-month-value is less than 1 or greater than 31, - * * the year-value is less than 1601, - * * the hour-value is greater than 23, - * * the minute-value is greater than 59, or - * * the second-value is greater than 59. - * (Note that leap seconds cannot be represented in this syntax.)" - * - * So, in order as above: - */ - if ( - dayOfMonth === null || - month == null || - year == null || - hour == null || - minute == null || - second == null || - dayOfMonth < 1 || - dayOfMonth > 31 || - year < 1601 || - hour > 23 || - minute > 59 || - second > 59 - ) { - return undefined - } - - return new Date(Date.UTC(year, month, dayOfMonth, hour, minute, second)) -} - -function formatDate(date: Date) { - validators.validate(validators.isDate(date), safeToString(date)) - return date.toUTCString() -} - -// S5.1.2 Canonicalized Host Names -function canonicalDomain(str: string | null) { - if (str == null) { - return null - } - let _str = str.trim().replace(/^\./, '') // S4.1.2.3 & S5.2.3: ignore leading . - - if (IP_V6_REGEX_OBJECT.test(_str)) { - _str = _str.replace('[', '').replace(']', '') - } - - // convert to IDN if any non-ASCII characters - // eslint-disable-next-line no-control-regex - if (punycode && /[^\u0001-\u007f]/.test(_str)) { - _str = punycode.toASCII(_str) - } - - return _str.toLowerCase() -} - -// S5.1.3 Domain Matching -function domainMatch( - str?: string | null, - domStr?: string | null, - canonicalize?: boolean, -): boolean | null { - if (str == null || domStr == null) { - return null - } - - let _str: string | null - let _domStr: string | null - - if (canonicalize !== false) { - _str = canonicalDomain(str) - _domStr = canonicalDomain(domStr) - } else { - _str = str - _domStr = domStr - } - - if (_str == null || _domStr == null) { - return null - } - - /* - * S5.1.3: - * "A string domain-matches a given domain string if at least one of the - * following conditions hold:" - * - * " o The domain string and the string are identical. (Note that both the - * domain string and the string will have been canonicalized to lower case at - * this point)" - */ - if (_str == _domStr) { - return true - } - - /* " o All of the following [three] conditions hold:" */ - - /* "* The domain string is a suffix of the string" */ - const idx = _str.lastIndexOf(domStr) - if (idx <= 0) { - return false // it's a non-match (-1) or prefix (0) - } - - // next, check it's a proper suffix - // e.g., "a.b.c".indexOf("b.c") === 2 - // 5 === 3+2 - if (_str.length !== _domStr.length + idx) { - return false // it's not a suffix - } - - /* " * The last character of the string that is not included in the - * domain string is a %x2E (".") character." */ - if (_str.substr(idx - 1, 1) !== '.') { - return false // doesn't align on "." - } - - /* " * The string is a host name (i.e., not an IP address)." */ - return !IP_REGEX_LOWERCASE.test(_str) -} - -// RFC6265 S5.1.4 Paths and Path-Match - -/* - * "The user agent MUST use an algorithm equivalent to the following algorithm - * to compute the default-path of a cookie:" - * - * Assumption: the path (and not query part or absolute uri) is passed in. - */ -function defaultPath(path?: string | null): string { - // "2. If the uri-path is empty or if the first character of the uri-path is not - // a %x2F ("/") character, output %x2F ("/") and skip the remaining steps. - if (!path || path.substr(0, 1) !== '/') { - return '/' - } - - // "3. If the uri-path contains no more than one %x2F ("/") character, output - // %x2F ("/") and skip the remaining step." - if (path === '/') { - return path - } - - const rightSlash = path.lastIndexOf('/') - if (rightSlash === 0) { - return '/' - } - - // "4. Output the characters of the uri-path from the first character up to, - // but not including, the right-most %x2F ("/")." - return path.slice(0, rightSlash) -} - -function trimTerminator(str: string) { - if (validators.isEmptyString(str)) return str - for (let t = 0; t < TERMINATORS.length; t++) { - const terminator = TERMINATORS[t] - const terminatorIdx = terminator ? str.indexOf(terminator) : -1 - if (terminatorIdx !== -1) { - str = str.substr(0, terminatorIdx) - } - } - - return str -} - -function parseCookiePair(cookiePair: string, looseMode: boolean) { - cookiePair = trimTerminator(cookiePair) - validators.validate(validators.isString(cookiePair), cookiePair) - - let firstEq = cookiePair.indexOf('=') - if (looseMode) { - if (firstEq === 0) { - // '=' is immediately at start - cookiePair = cookiePair.substr(1) - firstEq = cookiePair.indexOf('=') // might still need to split on '=' - } - } else { - // non-loose mode - if (firstEq <= 0) { - // no '=' or is at start - return undefined // needs to have non-empty "cookie-name" - } - } - - let cookieName, cookieValue - if (firstEq <= 0) { - cookieName = '' - cookieValue = cookiePair.trim() - } else { - cookieName = cookiePair.substr(0, firstEq).trim() - cookieValue = cookiePair.substr(firstEq + 1).trim() - } - - if (CONTROL_CHARS.test(cookieName) || CONTROL_CHARS.test(cookieValue)) { - return undefined - } - - const c = new Cookie() - c.key = cookieName - c.value = cookieValue - return c -} - -function parse( - str: string, - options?: ParseCookieOptions, -): Cookie | undefined | null { - if (validators.isEmptyString(str) || !validators.isString(str)) { - return null - } - - str = str.trim() - - // We use a regex to parse the "name-value-pair" part of S5.2 - const firstSemi = str.indexOf(';') // S5.2 step 1 - const cookiePair = firstSemi === -1 ? str : str.substr(0, firstSemi) - const c = parseCookiePair(cookiePair, options?.loose ?? false) - if (!c) { - return undefined - } - - if (firstSemi === -1) { - return c - } - - // S5.2.3 "unparsed-attributes consist of the remainder of the set-cookie-string - // (including the %x3B (";") in question)." plus later on in the same section - // "discard the first ";" and trim". - const unparsed = str.slice(firstSemi + 1).trim() - - // "If the unparsed-attributes string is empty, skip the rest of these - // steps." - if (unparsed.length === 0) { - return c - } - - /* - * S5.2 says that when looping over the items "[p]rocess the attribute-name - * and attribute-value according to the requirements in the following - * subsections" for every item. Plus, for many of the individual attributes - * in S5.3 it says to use the "attribute-value of the last attribute in the - * cookie-attribute-list". Therefore, in this implementation, we overwrite - * the previous value. - */ - const cookie_avs = unparsed.split(';') - while (cookie_avs.length) { - const av = (cookie_avs.shift() ?? '').trim() - if (av.length === 0) { - // happens if ";;" appears - continue - } - const av_sep = av.indexOf('=') - let av_key, av_value - - if (av_sep === -1) { - av_key = av - av_value = null - } else { - av_key = av.substr(0, av_sep) - av_value = av.substr(av_sep + 1) - } - - av_key = av_key.trim().toLowerCase() - - if (av_value) { - av_value = av_value.trim() - } - - switch (av_key) { - case 'expires': // S5.2.1 - if (av_value) { - const exp = parseDate(av_value) - // "If the attribute-value failed to parse as a cookie date, ignore the - // cookie-av." - if (exp) { - // over and underflow not realistically a concern: V8's getTime() seems to - // store something larger than a 32-bit time_t (even with 32-bit node) - c.expires = exp - } - } - break - - case 'max-age': // S5.2.2 - if (av_value) { - // "If the first character of the attribute-value is not a DIGIT or a "-" - // character ...[or]... If the remainder of attribute-value contains a - // non-DIGIT character, ignore the cookie-av." - if (/^-?[0-9]+$/.test(av_value)) { - const delta = parseInt(av_value, 10) - // "If delta-seconds is less than or equal to zero (0), let expiry-time - // be the earliest representable date and time." - c.setMaxAge(delta) - } - } - break - - case 'domain': // S5.2.3 - // "If the attribute-value is empty, the behavior is undefined. However, - // the user agent SHOULD ignore the cookie-av entirely." - if (av_value) { - // S5.2.3 "Let cookie-domain be the attribute-value without the leading %x2E - // (".") character." - const domain = av_value.trim().replace(/^\./, '') - if (domain) { - // "Convert the cookie-domain to lower case." - c.domain = domain.toLowerCase() - } - } - break - - case 'path': // S5.2.4 - /* - * "If the attribute-value is empty or if the first character of the - * attribute-value is not %x2F ("/"): - * Let cookie-path be the default-path. - * Otherwise: - * Let cookie-path be the attribute-value." - * - * We'll represent the default-path as null since it depends on the - * context of the parsing. - */ - c.path = av_value && av_value[0] === '/' ? av_value : null - break - - case 'secure': // S5.2.5 - /* - * "If the attribute-name case-insensitively matches the string "Secure", - * the user agent MUST append an attribute to the cookie-attribute-list - * with an attribute-name of Secure and an empty attribute-value." - */ - c.secure = true - break - - case 'httponly': // S5.2.6 -- effectively the same as 'secure' - c.httpOnly = true - break - - case 'samesite': // RFC6265bis-02 S5.3.7 - switch (av_value ? av_value.toLowerCase() : '') { - case 'strict': - c.sameSite = 'strict' - break - case 'lax': - c.sameSite = 'lax' - break - case 'none': - c.sameSite = 'none' - break - default: - c.sameSite = undefined - break - } - break - - default: - c.extensions = c.extensions || [] - c.extensions.push(av) - break - } - } - - return c -} - -/** - * If the cookie-name begins with a case-sensitive match for the - * string "__Secure-", abort these steps and ignore the cookie - * entirely unless the cookie's secure-only-flag is true. - * @param cookie - * @returns boolean - */ -function isSecurePrefixConditionMet(cookie: Cookie) { - validators.validate(validators.isObject(cookie), safeToString(cookie)) - const startsWithSecurePrefix = - typeof cookie.key === 'string' && cookie.key.startsWith('__Secure-') - return !startsWithSecurePrefix || cookie.secure -} - -/** - * If the cookie-name begins with a case-sensitive match for the - * string "__Host-", abort these steps and ignore the cookie - * entirely unless the cookie meets all the following criteria: - * 1. The cookie's secure-only-flag is true. - * 2. The cookie's host-only-flag is true. - * 3. The cookie-attribute-list contains an attribute with an - * attribute-name of "Path", and the cookie's path is "/". - * @param cookie - * @returns boolean - */ -function isHostPrefixConditionMet(cookie: Cookie) { - validators.validate(validators.isObject(cookie)) - const startsWithHostPrefix = - typeof cookie.key === 'string' && cookie.key.startsWith('__Host-') - return ( - !startsWithHostPrefix || - (cookie.secure && - cookie.hostOnly && - cookie.path != null && - cookie.path === '/') - ) -} - -function fromJSON(str: string | SerializedCookie | null | undefined | unknown) { - if (!str || validators.isEmptyString(str)) { - return null - } - - let obj: unknown - if (typeof str === 'string') { - try { - obj = JSON.parse(str) - } catch (e) { - return null - } - } else { - // assume it's an Object - obj = str - } - - const c = new Cookie() - Cookie.serializableProperties.forEach((prop) => { - if (obj && typeof obj === 'object' && inOperator(prop, obj)) { - const val = obj[prop] - if (val === undefined) { - return - } - - if (inOperator(prop, cookieDefaults) && val === cookieDefaults[prop]) { - return - } - - switch (prop) { - case 'key': - case 'value': - case 'sameSite': - if (typeof val === 'string') { - c[prop] = val - } - break - case 'expires': - case 'creation': - case 'lastAccessed': - if ( - typeof val === 'number' || - typeof val === 'string' || - val instanceof Date - ) { - c[prop] = obj[prop] == 'Infinity' ? 'Infinity' : new Date(val) - } else if (val === null) { - c[prop] = null - } - break - case 'maxAge': - if ( - typeof val === 'number' || - val === 'Infinity' || - val === '-Infinity' - ) { - c[prop] = val - } - break - case 'domain': - case 'path': - if (typeof val === 'string' || val === null) { - c[prop] = val - } - break - case 'secure': - case 'httpOnly': - if (typeof val === 'boolean') { - c[prop] = val - } - break - case 'extensions': - if ( - Array.isArray(val) && - val.every((item) => typeof item === 'string') - ) { - c[prop] = val - } - break - case 'hostOnly': - case 'pathIsDefault': - if (typeof val === 'boolean' || val === null) { - c[prop] = val - } - break - } - } - }) - - return c -} - -/* Section 5.4 part 2: - * "* Cookies with longer paths are listed before cookies with - * shorter paths. - * - * * Among cookies that have equal-length path fields, cookies with - * earlier creation-times are listed before cookies with later - * creation-times." - */ - -function cookieCompare(a: Cookie, b: Cookie) { - validators.validate(validators.isObject(a), safeToString(a)) - validators.validate(validators.isObject(b), safeToString(b)) - let cmp: number - - // descending for length: b CMP a - const aPathLen = a.path ? a.path.length : 0 - const bPathLen = b.path ? b.path.length : 0 - cmp = bPathLen - aPathLen - if (cmp !== 0) { - return cmp - } - - // ascending for time: a CMP b - const aTime = - a.creation && a.creation instanceof Date ? a.creation.getTime() : MAX_TIME - const bTime = - b.creation && b.creation instanceof Date ? b.creation.getTime() : MAX_TIME - cmp = aTime - bTime - if (cmp !== 0) { - return cmp - } - - // break ties for the same millisecond (precision of JavaScript's clock) - cmp = (a.creationIndex ?? 0) - (b.creationIndex ?? 0) - - return cmp -} - -// Gives the permutation of all possible pathMatch()es of a given path. The -// array is in longest-to-shortest order. Handy for indexing. -function permutePath(path: string): string[] { - validators.validate(validators.isString(path)) - if (path === '/') { - return ['/'] - } - const permutations = [path] - while (path.length > 1) { - const lindex = path.lastIndexOf('/') - if (lindex === 0) { - break - } - path = path.substr(0, lindex) - permutations.push(path) - } - permutations.push('/') - return permutations -} - -function getCookieContext(url: string | URL) { - if (url instanceof URL && 'query' in url) { - return url - } - - if (typeof url === 'string') { - try { - return urlParse(decodeURI(url)) - } catch { - return urlParse(url) - } - } - - throw new Error('`url` argument is invalid') -} - -const cookieDefaults = { - // the order in which the RFC has them: - key: '', - value: '', - expires: 'Infinity', - maxAge: null, - domain: null, - path: null, - secure: false, - httpOnly: false, - extensions: null, - // set by the CookieJar: - hostOnly: null, - pathIsDefault: null, - creation: null, - lastAccessed: null, - sameSite: undefined, -} - -export type CreateCookieOptions = { - key?: string - value?: string - expires?: Date | 'Infinity' | null - maxAge?: number | 'Infinity' | '-Infinity' - domain?: string | null - path?: string | null - secure?: boolean - httpOnly?: boolean - extensions?: string[] | null - creation?: Date | 'Infinity' | null - creationIndex?: number - hostOnly?: boolean | null - pathIsDefault?: boolean | null - lastAccessed?: Date | 'Infinity' | null - sameSite?: string | undefined -} - -export class Cookie { - key: string | undefined - value: string | undefined - expires: Date | 'Infinity' | null | undefined - maxAge: number | 'Infinity' | '-Infinity' | undefined - domain: string | null | undefined - path: string | null | undefined - secure: boolean | undefined - httpOnly: boolean | undefined - extensions: string[] | null | undefined - creation: Date | 'Infinity' | null - creationIndex: number | undefined - hostOnly: boolean | null | undefined - pathIsDefault: boolean | null | undefined - lastAccessed: Date | 'Infinity' | null | undefined - sameSite: string | undefined - - constructor(options: CreateCookieOptions = {}) { - // supports inspect if that feature is available in the environment - const customInspectSymbol = getCustomInspectSymbol() - if (customInspectSymbol) { - Object.defineProperty(this, customInspectSymbol, { - value: this.inspect.bind(this), - enumerable: false, - writable: false, - configurable: false, - }) - } - - Object.assign(this, cookieDefaults, options) - this.creation = options.creation ?? cookieDefaults.creation ?? new Date() - - // used to break creation ties in cookieCompare(): - Object.defineProperty(this, 'creationIndex', { - configurable: false, - enumerable: false, // important for assert.deepEqual checks - writable: true, - value: ++Cookie.cookiesCreated, - }) - } - - inspect() { - const now = Date.now() - const hostOnly = this.hostOnly != null ? this.hostOnly.toString() : '?' - const createAge = - this.creation && this.creation !== 'Infinity' - ? `${now - this.creation.getTime()}ms` - : '?' - const accessAge = - this.lastAccessed && this.lastAccessed !== 'Infinity' - ? `${now - this.lastAccessed.getTime()}ms` - : '?' - return `Cookie="${this.toString()}; hostOnly=${hostOnly}; aAge=${accessAge}; cAge=${createAge}"` - } - - toJSON(): SerializedCookie { - const obj: SerializedCookie = {} - - for (const prop of Cookie.serializableProperties) { - const val = this[prop] - - if (val === cookieDefaults[prop]) { - continue // leave as prototype default - } - - switch (prop) { - case 'key': - case 'value': - case 'sameSite': - if (typeof val === 'string') { - obj[prop] = val - } - break - case 'expires': - case 'creation': - case 'lastAccessed': - if ( - typeof val === 'number' || - typeof val === 'string' || - val instanceof Date - ) { - obj[prop] = - val == 'Infinity' ? 'Infinity' : new Date(val).toISOString() - } else if (val === null) { - obj[prop] = null - } - break - case 'maxAge': - if ( - typeof val === 'number' || - val === 'Infinity' || - val === '-Infinity' - ) { - obj[prop] = val - } - break - case 'domain': - case 'path': - if (typeof val === 'string' || val === null) { - obj[prop] = val - } - break - case 'secure': - case 'httpOnly': - if (typeof val === 'boolean') { - obj[prop] = val - } - break - case 'extensions': - if (Array.isArray(val)) { - obj[prop] = val - } - break - case 'hostOnly': - case 'pathIsDefault': - if (typeof val === 'boolean' || val === null) { - obj[prop] = val - } - break - } - } - - return obj - } - - clone() { - return fromJSON(this.toJSON()) - } - - validate() { - if (this.value == null || !COOKIE_OCTETS.test(this.value)) { - return false - } - if ( - this.expires != 'Infinity' && - !(this.expires instanceof Date) && - !parseDate(this.expires) - ) { - return false - } - if (this.maxAge != null && this.maxAge <= 0) { - return false // "Max-Age=" non-zero-digit *DIGIT - } - if (this.path != null && !PATH_VALUE.test(this.path)) { - return false - } - - const cdomain = this.cdomain() - if (cdomain) { - if (cdomain.match(/\.$/)) { - return false // S4.1.2.3 suggests that this is bad. domainMatch() tests confirm this - } - const suffix = pubsuffix.getPublicSuffix(cdomain) - if (suffix == null) { - // it's a public suffix - return false - } - } - return true - } - - setExpires(exp: string | Date) { - if (exp instanceof Date) { - this.expires = exp - } else { - this.expires = parseDate(exp) || 'Infinity' - } - } - - setMaxAge(age: number) { - if (age === Infinity) { - this.maxAge = 'Infinity' - } else if (age === -Infinity) { - this.maxAge = '-Infinity' - } else { - this.maxAge = age - } - } - - cookieString() { - const val = this.value ?? '' - if (this.key) { - return `${this.key}=${val}` - } - return val - } - - // gives Set-Cookie header format - toString() { - let str = this.cookieString() - - if (this.expires != 'Infinity') { - if (this.expires instanceof Date) { - str += `; Expires=${formatDate(this.expires)}` - } - } - - if (this.maxAge != null && this.maxAge != Infinity) { - str += `; Max-Age=${this.maxAge}` - } - - if (this.domain && !this.hostOnly) { - str += `; Domain=${this.domain}` - } - if (this.path) { - str += `; Path=${this.path}` - } - - if (this.secure) { - str += '; Secure' - } - if (this.httpOnly) { - str += '; HttpOnly' - } - if (this.sameSite && this.sameSite !== 'none') { - if ( - this.sameSite.toLowerCase() === - Cookie.sameSiteCanonical.lax.toLowerCase() - ) { - str += `; SameSite=${Cookie.sameSiteCanonical.lax}` - } else if ( - this.sameSite.toLowerCase() === - Cookie.sameSiteCanonical.strict.toLowerCase() - ) { - str += `; SameSite=${Cookie.sameSiteCanonical.strict}` - } else { - str += `; SameSite=${this.sameSite}` - } - } - if (this.extensions) { - this.extensions.forEach((ext) => { - str += `; ${ext}` - }) - } - - return str - } - - // TTL() partially replaces the "expiry-time" parts of S5.3 step 3 (setCookie() - // elsewhere) - // S5.3 says to give the "latest representable date" for which we use Infinity - // For "expired" we use 0 - TTL(now: number = Date.now()): number { - /* RFC6265 S4.1.2.2 If a cookie has both the Max-Age and the Expires - * attribute, the Max-Age attribute has precedence and controls the - * expiration date of the cookie. - * (Concurs with S5.3 step 3) - */ - if (this.maxAge != null && typeof this.maxAge === 'number') { - return this.maxAge <= 0 ? 0 : this.maxAge * 1000 - } - - const expires = this.expires - if (expires === 'Infinity') { - return Infinity - } - - return (expires?.getTime() ?? now) - (now || Date.now()) - } - - // expiryTime() replaces the "expiry-time" parts of S5.3 step 3 (setCookie() - // elsewhere) - expiryTime(now?: Date): number | undefined { - if (this.maxAge != null) { - const relativeTo = now || this.creation || new Date() - const maxAge = typeof this.maxAge === 'number' ? this.maxAge : -Infinity - const age = maxAge <= 0 ? -Infinity : maxAge * 1000 - if (relativeTo === 'Infinity') { - return Infinity - } - return relativeTo.getTime() + age - } - - if (this.expires == 'Infinity') { - return Infinity - } - - return this.expires ? this.expires.getTime() : undefined - } - - // This replaces the "persistent-flag" parts of S5.3 step 3 - isPersistent(): boolean { - return this.maxAge != null || this.expires != 'Infinity' - } - - // Mostly S5.1.2 and S5.2.3: - canonicalizedDomain() { - if (this.domain == null) { - return null - } - return canonicalDomain(this.domain) - } - - cdomain() { - return this.canonicalizedDomain() - } - - static parse( - cookieString: string, - options?: ParseCookieOptions, - ): Cookie | undefined | null { - return parse(cookieString, options) - } - - static fromJSON(jsonString: string | null | undefined): Cookie | null { - return fromJSON(jsonString) - } - - static cookiesCreated = 0 - - static sameSiteLevel = { - strict: 3, - lax: 2, - none: 1, - } as const - - static sameSiteCanonical = { - strict: 'Strict', - lax: 'Lax', - } as const - - static serializableProperties = [ - 'key', - 'value', - 'expires', - 'maxAge', - 'domain', - 'path', - 'secure', - 'httpOnly', - 'extensions', - 'hostOnly', - 'pathIsDefault', - 'creation', - 'lastAccessed', - 'sameSite', - ] as const -} - -function getNormalizedPrefixSecurity(prefixSecurity: string) { - if (prefixSecurity != null) { - const normalizedPrefixSecurity = prefixSecurity.toLowerCase() - /* The three supported options */ - switch (normalizedPrefixSecurity) { - case PrefixSecurityEnum.STRICT: - case PrefixSecurityEnum.SILENT: - case PrefixSecurityEnum.DISABLED: - return normalizedPrefixSecurity - } - } - /* Default is SILENT */ - return PrefixSecurityEnum.SILENT -} - -const defaultSetCookieOptions: SetCookieOptions = { - loose: false, - sameSiteContext: undefined, - ignoreError: false, - http: true, -} - -const defaultGetCookieOptions: GetCookiesOptions = { - http: true, - expire: true, - allPaths: false, - sameSiteContext: undefined, - sort: undefined, -} - -export function createPromiseCallback(args: IArguments): PromiseCallback { - let callback: (error: Error | null | undefined, result: T | undefined) => void - let resolve: (result: T | undefined) => void - let reject: (error: Error | null) => void - - const promise = new Promise((_resolve, _reject) => { - resolve = _resolve - reject = _reject - }) - - const cb = args[args.length - 1] as unknown - if (typeof cb === 'function') { - callback = (err, result) => { - try { - cb(err, result) - } catch (e) { - reject(e instanceof Error ? e : new Error()) - } - } - } else { - callback = (err, result) => { - try { - err ? reject(err) : resolve(result) - } catch (e) { - reject(e instanceof Error ? e : new Error()) - } - } - } - - return { - promise, - callback, - resolve: (value: T | undefined) => { - callback(null, value) - return promise - }, - reject: (error: Error | null | undefined) => { - callback(error, undefined) - return promise - }, - } -} - -export type CreateCookieJarOptions = { - rejectPublicSuffixes?: boolean | undefined - looseMode?: boolean | undefined - prefixSecurity?: 'strict' | 'silent' | 'unsafe-disabled' | undefined - allowSpecialUseDomain?: boolean | undefined -} - -export class CookieJar { - readonly store: Store - private readonly rejectPublicSuffixes: boolean - private readonly enableLooseMode: boolean - private readonly allowSpecialUseDomain: boolean - readonly prefixSecurity: string - - constructor( - store?: Store | null | undefined, - options?: CreateCookieJarOptions | boolean, - ) { - if (typeof options === 'boolean') { - options = { rejectPublicSuffixes: options } - } - this.rejectPublicSuffixes = options?.rejectPublicSuffixes ?? true - this.enableLooseMode = options?.looseMode ?? false - this.allowSpecialUseDomain = options?.allowSpecialUseDomain ?? true - this.prefixSecurity = getNormalizedPrefixSecurity( - options?.prefixSecurity ?? 'silent', - ) - this.store = store ?? new MemoryCookieStore() - } - - private callSync(fn: (callback: Callback) => void): T | undefined { - if (!this.store.synchronous) { - throw new Error( - 'CookieJar store is not synchronous; use async API instead.', - ) - } - let syncErr: Error | undefined - let syncResult: T | undefined = undefined - fn.call(this, (error, result) => { - syncErr = error - syncResult = result - }) - if (syncErr) { - throw syncErr - } - - return syncResult - } - - setCookie( - cookie: string | Cookie, - url: string, - callback: Callback, - ): void - setCookie( - cookie: string | Cookie, - url: string, - options: SetCookieOptions, - callback: Callback, - ): void - setCookie(cookie: string | Cookie, url: string): Promise - setCookie( - cookie: string | Cookie, - url: string, - options: SetCookieOptions, - ): Promise - setCookie( - cookie: string | Cookie, - url: string, - options: SetCookieOptions | Callback, - callback?: Callback, - ): unknown - setCookie( - cookie: string | Cookie, - url: string, - options?: SetCookieOptions | Callback, - callback?: Callback, - ): unknown { - const promiseCallback = createPromiseCallback(arguments) - const cb = promiseCallback.callback - - validators.validate( - validators.isNonEmptyString(url), - callback, - safeToString(options), - ) - let err - - if (typeof url === 'function') { - return promiseCallback.reject(new Error('No URL was specified')) - } - - const context = getCookieContext(url) - if (typeof options === 'function') { - options = defaultSetCookieOptions - } - - validators.validate(typeof cb === 'function', cb) - - if ( - !validators.isNonEmptyString(cookie) && - !validators.isObject(cookie) && - cookie instanceof String && - cookie.length == 0 - ) { - return promiseCallback.reject(null) - } - - const host = canonicalDomain(context.hostname) - const loose = options?.loose || this.enableLooseMode - - let sameSiteContext = null - if (options?.sameSiteContext) { - sameSiteContext = checkSameSiteContext(options.sameSiteContext) - if (!sameSiteContext) { - return promiseCallback.reject(new Error(SAME_SITE_CONTEXT_VAL_ERR)) - } - } - - // S5.3 step 1 - if (typeof cookie === 'string' || cookie instanceof String) { - const parsedCookie = Cookie.parse(cookie.toString(), { loose: loose }) - if (!parsedCookie) { - err = new Error('Cookie failed to parse') - return promiseCallback.reject(options?.ignoreError ? null : err) - } - cookie = parsedCookie - } else if (!(cookie instanceof Cookie)) { - // If you're seeing this error, and are passing in a Cookie object, - // it *might* be a Cookie object from another loaded version of tough-cookie. - err = new Error( - 'First argument to setCookie must be a Cookie object or string', - ) - return promiseCallback.reject(options?.ignoreError ? null : err) - } - - // S5.3 step 2 - const now = options?.now || new Date() // will assign later to save effort in the face of errors - - // S5.3 step 3: NOOP; persistent-flag and expiry-time is handled by getCookie() - - // S5.3 step 4: NOOP; domain is null by default - - // S5.3 step 5: public suffixes - if (this.rejectPublicSuffixes && cookie.domain) { - try { - const cdomain = cookie.cdomain() - const suffix = - typeof cdomain === 'string' - ? pubsuffix.getPublicSuffix(cdomain, { - allowSpecialUseDomain: this.allowSpecialUseDomain, - ignoreError: options?.ignoreError, - }) - : null - if (suffix == null && !IP_V6_REGEX_OBJECT.test(cookie.domain)) { - // e.g. "com" - err = new Error('Cookie has domain set to a public suffix') - return promiseCallback.reject(options?.ignoreError ? null : err) - } - } catch (err) { - if (options?.ignoreError) { - return promiseCallback.reject(null) - } else { - if (err instanceof Error) { - return promiseCallback.reject(err) - } else { - return promiseCallback.reject(null) - } - } - } - } - - // S5.3 step 6: - if (cookie.domain) { - if ( - !domainMatch(host ?? undefined, cookie.cdomain() ?? undefined, false) - ) { - err = new Error( - `Cookie not in this host's domain. Cookie:${ - cookie.cdomain() ?? 'null' - } Request:${host ?? 'null'}`, - ) - return promiseCallback.reject(options?.ignoreError ? null : err) - } - - if (cookie.hostOnly == null) { - // don't reset if already set - cookie.hostOnly = false - } - } else { - cookie.hostOnly = true - cookie.domain = host - } - - //S5.2.4 If the attribute-value is empty or if the first character of the - //attribute-value is not %x2F ("/"): - //Let cookie-path be the default-path. - if (!cookie.path || cookie.path[0] !== '/') { - cookie.path = defaultPath(context.pathname ?? undefined) - cookie.pathIsDefault = true - } - - // S5.3 step 8: NOOP; secure attribute - // S5.3 step 9: NOOP; httpOnly attribute - - // S5.3 step 10 - if (options?.http === false && cookie.httpOnly) { - err = new Error("Cookie is HttpOnly and this isn't an HTTP API") - return promiseCallback.reject(options?.ignoreError ? null : err) - } - - // 6252bis-02 S5.4 Step 13 & 14: - if ( - cookie.sameSite !== 'none' && - cookie.sameSite !== undefined && - sameSiteContext - ) { - // "If the cookie's "same-site-flag" is not "None", and the cookie - // is being set from a context whose "site for cookies" is not an - // exact match for request-uri's host's registered domain, then - // abort these steps and ignore the newly created cookie entirely." - if (sameSiteContext === 'none') { - err = new Error('Cookie is SameSite but this is a cross-origin request') - return promiseCallback.reject(options?.ignoreError ? null : err) - } - } - - /* 6265bis-02 S5.4 Steps 15 & 16 */ - const ignoreErrorForPrefixSecurity = - this.prefixSecurity === PrefixSecurityEnum.SILENT - const prefixSecurityDisabled = - this.prefixSecurity === PrefixSecurityEnum.DISABLED - /* If prefix checking is not disabled ...*/ - if (!prefixSecurityDisabled) { - let errorFound = false - let errorMsg - /* Check secure prefix condition */ - if (!isSecurePrefixConditionMet(cookie)) { - errorFound = true - errorMsg = 'Cookie has __Secure prefix but Secure attribute is not set' - } else if (!isHostPrefixConditionMet(cookie)) { - /* Check host prefix condition */ - errorFound = true - errorMsg = - "Cookie has __Host prefix but either Secure or HostOnly attribute is not set or Path is not '/'" - } - if (errorFound) { - return promiseCallback.reject( - options?.ignoreError || ignoreErrorForPrefixSecurity - ? null - : new Error(errorMsg), - ) - } - } - - const store = this.store - - if (!store.updateCookie) { - store.updateCookie = function ( - _oldCookie: Cookie, - newCookie: Cookie, - cb?: Callback, - ): Promise { - return this.putCookie(newCookie).then( - () => { - if (cb) { - cb(undefined, undefined) - } - }, - (error: Error) => { - if (cb) { - cb(error, undefined) - } - }, - ) - } - } - - function withCookie( - err: Error | undefined, - oldCookie: Cookie | undefined | null, - ): void { - if (err) { - cb(err) - return - } - - const next = function (err: Error | undefined): void { - if (err || typeof cookie === 'string') { - cb(err) - } else { - cb(null, cookie) - } - } - - if (oldCookie) { - // S5.3 step 11 - "If the cookie store contains a cookie with the same name, - // domain, and path as the newly created cookie:" - if ( - options && - 'http' in options && - options.http === false && - oldCookie.httpOnly - ) { - // step 11.2 - err = new Error("old Cookie is HttpOnly and this isn't an HTTP API") - cb(options.ignoreError ? null : err) - return - } - if (cookie instanceof Cookie) { - cookie.creation = oldCookie.creation - // step 11.3 - cookie.creationIndex = oldCookie.creationIndex - // preserve tie-breaker - cookie.lastAccessed = now - // Step 11.4 (delete cookie) is implied by just setting the new one: - store.updateCookie(oldCookie, cookie, next) // step 12 - } - } else { - if (cookie instanceof Cookie) { - cookie.creation = cookie.lastAccessed = now - store.putCookie(cookie, next) // step 12 - } - } - } - - store.findCookie(cookie.domain, cookie.path, cookie.key, withCookie) - return promiseCallback.promise - } - setCookieSync( - cookie: string | Cookie, - url: string, - options?: SetCookieOptions, - ): Cookie | undefined { - const setCookieFn = this.setCookie.bind( - this, - cookie, - url, - options as SetCookieOptions, - ) - return this.callSync(setCookieFn) - } - - // RFC6365 S5.4 - getCookies(url: string, callback: Callback): void - getCookies( - url: string, - options: GetCookiesOptions | undefined, - callback: Callback, - ): void - getCookies(url: string): Promise - getCookies( - url: string, - options: GetCookiesOptions | undefined, - ): Promise - getCookies( - url: string, - options: GetCookiesOptions | undefined | Callback, - callback?: Callback, - ): unknown - getCookies( - url: string, - options?: GetCookiesOptions | Callback, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _callback?: Callback, - ): unknown { - const promiseCallback = createPromiseCallback(arguments) - const cb = promiseCallback.callback - - validators.validate(validators.isNonEmptyString(url), cb, url) - const context = getCookieContext(url) - if (typeof options === 'function' || options === undefined) { - options = defaultGetCookieOptions - } - validators.validate(validators.isObject(options), cb, safeToString(options)) - validators.validate(typeof cb === 'function', cb) - - const host = canonicalDomain(context.hostname) - const path = context.pathname || '/' - - const secure = - context.protocol && - (context.protocol == 'https:' || context.protocol == 'wss:') - - let sameSiteLevel = 0 - if (options?.sameSiteContext) { - const sameSiteContext = checkSameSiteContext(options.sameSiteContext) - if (sameSiteContext == null) { - return promiseCallback.reject(new Error(SAME_SITE_CONTEXT_VAL_ERR)) - } - sameSiteLevel = Cookie.sameSiteLevel[sameSiteContext] - if (!sameSiteLevel) { - return promiseCallback.reject(new Error(SAME_SITE_CONTEXT_VAL_ERR)) - } - } - - const http = options?.http ?? true - - const now = Date.now() - const expireCheck = options?.expire ?? true - const allPaths = options?.allPaths ?? false - const store = this.store - - function matchingCookie(c: Cookie) { - // "Either: - // The cookie's host-only-flag is true and the canonicalized - // request-host is identical to the cookie's domain. - // Or: - // The cookie's host-only-flag is false and the canonicalized - // request-host domain-matches the cookie's domain." - if (c.hostOnly) { - if (c.domain != host) { - return false - } - } else { - if (!domainMatch(host ?? undefined, c.domain ?? undefined, false)) { - return false - } - } - - // "The request-uri's path path-matches the cookie's path." - if (!allPaths && typeof c.path === 'string' && !pathMatch(path, c.path)) { - return false - } - - // "If the cookie's secure-only-flag is true, then the request-uri's - // scheme must denote a "secure" protocol" - if (c.secure && !secure) { - return false - } - - // "If the cookie's http-only-flag is true, then exclude the cookie if the - // cookie-string is being generated for a "non-HTTP" API" - if (c.httpOnly && !http) { - return false - } - - // RFC6265bis-02 S5.3.7 - if (sameSiteLevel) { - let cookieLevel: number - if (c.sameSite === 'lax') { - cookieLevel = Cookie.sameSiteLevel.lax - } else if (c.sameSite === 'strict') { - cookieLevel = Cookie.sameSiteLevel.strict - } else { - cookieLevel = Cookie.sameSiteLevel.none - } - if (cookieLevel > sameSiteLevel) { - // only allow cookies at or below the request level - return false - } - } - - // deferred from S5.3 - // non-RFC: allow retention of expired cookies by choice - const expiryTime = c.expiryTime() - if (expireCheck && expiryTime && expiryTime <= now) { - // eslint-disable-next-line @typescript-eslint/no-empty-function - store.removeCookie(c.domain, c.path, c.key, () => {}) // result ignored - return false - } - - return true - } - - store.findCookies( - host, - allPaths ? null : path, - this.allowSpecialUseDomain, - (err, cookies): void => { - if (err) { - cb(err) - return - } - - if (cookies == null) { - cb(undefined, []) - return - } - - cookies = cookies.filter(matchingCookie) - - // sorting of S5.4 part 2 - if (options && 'sort' in options && options.sort !== false) { - cookies = cookies.sort(cookieCompare) - } - - // S5.4 part 3 - const now = new Date() - for (const cookie of cookies) { - cookie.lastAccessed = now - } - // TODO persist lastAccessed - - cb(null, cookies) - }, - ) - - return promiseCallback.promise - } - getCookiesSync(url: string, options?: GetCookiesOptions): Cookie[] { - return ( - this.callSync(this.getCookies.bind(this, url, options)) ?? [] - ) - } - - getCookieString( - url: string, - options: GetCookiesOptions, - callback: Callback, - ): void - getCookieString(url: string, callback: Callback): void - getCookieString(url: string): Promise - getCookieString(url: string, options: GetCookiesOptions): Promise - getCookieString( - url: string, - options: GetCookiesOptions | Callback, - callback?: Callback, - ): unknown - getCookieString( - url: string, - options?: GetCookiesOptions | Callback, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _callback?: Callback, - ): unknown { - const promiseCallback = createPromiseCallback(arguments) - - if (typeof options === 'function') { - options = undefined - } - - const next: Callback = function ( - err: Error | undefined, - cookies: Cookie[] | undefined, - ) { - if (err || cookies === undefined) { - promiseCallback.callback(err) - } else { - promiseCallback.callback( - undefined, - cookies - .sort(cookieCompare) - .map((c) => c.cookieString()) - .join('; '), - ) - } - } - - this.getCookies(url, options, next) - return promiseCallback.promise - } - getCookieStringSync(url: string, options?: GetCookiesOptions): string { - return ( - this.callSync( - this.getCookieString.bind(this, url, options as GetCookiesOptions), - ) ?? '' - ) - } - - getSetCookieStrings(url: string, callback: Callback): void - getSetCookieStrings( - url: string, - options: GetCookiesOptions, - callback: Callback, - ): void - getSetCookieStrings(url: string): Promise - getSetCookieStrings( - url: string, - options: GetCookiesOptions, - ): Promise - getSetCookieStrings( - url: string, - options: GetCookiesOptions, - callback?: Callback, - ): unknown - getSetCookieStrings( - url: string, - options?: GetCookiesOptions | Callback, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _callback?: Callback, - ): unknown { - const promiseCallback = createPromiseCallback(arguments) - - if (typeof options === 'function') { - options = undefined - } - - const next: Callback = function ( - err: Error | undefined, - cookies: Cookie[] | undefined, - ) { - if (err || cookies === undefined) { - promiseCallback.callback(err) - } else { - promiseCallback.callback( - null, - cookies.map((c) => { - return c.toString() - }), - ) - } - } - - this.getCookies(url, options, next) - return promiseCallback.promise - } - getSetCookieStringsSync( - url: string, - options: GetCookiesOptions = {}, - ): string[] { - return ( - this.callSync( - this.getSetCookieStrings.bind(this, url, options), - ) ?? [] - ) - } - - serialize(callback: Callback): void - serialize(): Promise - serialize(callback?: Callback): unknown - // eslint-disable-next-line @typescript-eslint/no-unused-vars - serialize(_callback?: Callback): unknown { - const promiseCallback = - createPromiseCallback(arguments) - const cb = promiseCallback.callback - - validators.validate(typeof cb === 'function', cb) - let type: string | null = this.store.constructor.name - if (validators.isObject(type)) { - type = null - } - - // update README.md "Serialization Format" if you change this, please! - const serialized: SerializedCookieJar = { - // The version of tough-cookie that serialized this jar. Generally a good - // practice since future versions can make data import decisions based on - // known past behavior. When/if this matters, use `semver`. - version: `tough-cookie@${version}`, - - // add the store type, to make humans happy: - storeType: type, - - // CookieJar configuration: - rejectPublicSuffixes: this.rejectPublicSuffixes, - enableLooseMode: this.enableLooseMode, - allowSpecialUseDomain: this.allowSpecialUseDomain, - prefixSecurity: getNormalizedPrefixSecurity(this.prefixSecurity), - - // this gets filled from getAllCookies: - cookies: [], - } - - if ( - !( - this.store.getAllCookies && - typeof this.store.getAllCookies === 'function' - ) - ) { - return promiseCallback.reject( - new Error( - 'store does not support getAllCookies and cannot be serialized', - ), - ) - } - - this.store.getAllCookies((err, cookies) => { - if (err) { - promiseCallback.callback(err) - return - } - - if (cookies == null) { - promiseCallback.callback(undefined, serialized) - return - } - - serialized.cookies = cookies.map((cookie) => { - // convert to serialized 'raw' cookies - const serializedCookie = cookie.toJSON() - - // Remove the index so new ones get assigned during deserialization - delete serializedCookie.creationIndex - - return serializedCookie - }) - - promiseCallback.callback(undefined, serialized) - }) - - return promiseCallback.promise - } - serializeSync(): SerializedCookieJar | undefined { - return this.callSync((callback) => { - this.serialize(callback) - }) - } - - toJSON() { - return this.serializeSync() - } - - // use the class method CookieJar.deserialize instead of calling this directly - _importCookies(serialized: unknown, callback: Callback) { - let cookies: unknown[] | undefined = undefined - - if ( - serialized && - typeof serialized === 'object' && - inOperator('cookies', serialized) && - Array.isArray(serialized.cookies) - ) { - cookies = serialized.cookies - } - - if (!cookies) { - return callback( - new Error('serialized jar has no cookies array'), - undefined, - ) - } - - cookies = cookies.slice() // do not modify the original - - const putNext = (err?: Error): void => { - if (err) { - return callback(err, undefined) - } - - if (Array.isArray(cookies)) { - if (!cookies.length) { - return callback(err, this) - } - - let cookie - try { - cookie = fromJSON(cookies.shift()) - } catch (e) { - return callback(e instanceof Error ? e : new Error(), undefined) - } - - if (cookie === null) { - return putNext(undefined) // skip this cookie - } - - this.store.putCookie(cookie, putNext) - } - } - - putNext() - } - - _importCookiesSync(serialized: unknown): void { - this.callSync(this._importCookies.bind(this, serialized)) - } - - clone(callback: Callback): void - clone(newStore: Store, callback: Callback): void - clone(): Promise - clone(newStore: Store): Promise - clone( - newStore?: Store | Callback, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _callback?: Callback, - ): unknown { - if (typeof newStore === 'function') { - newStore = undefined - } - - const promiseCallback = createPromiseCallback(arguments) - const cb = promiseCallback.callback - - this.serialize((err, serialized) => { - if (err) { - return promiseCallback.reject(err) - } - return CookieJar.deserialize(serialized ?? '', newStore, cb) - }) - - return promiseCallback.promise - } - - _cloneSync(newStore?: Store): CookieJar | undefined { - const cloneFn = - newStore && typeof newStore !== 'function' - ? this.clone.bind(this, newStore) - : this.clone.bind(this) - return this.callSync((callback) => cloneFn(callback)) - } - - cloneSync(newStore?: Store): CookieJar | undefined { - if (!newStore) { - return this._cloneSync() - } - if (!newStore.synchronous) { - throw new Error( - 'CookieJar clone destination store is not synchronous; use async API instead.', - ) - } - return this._cloneSync(newStore) - } - - removeAllCookies(callback: ErrorCallback): void - removeAllCookies(): Promise - removeAllCookies(callback?: ErrorCallback): unknown - // eslint-disable-next-line @typescript-eslint/no-unused-vars - removeAllCookies(_callback?: ErrorCallback): unknown { - const promiseCallback = createPromiseCallback(arguments) - const cb = promiseCallback.callback - - const store = this.store - - // Check that the store implements its own removeAllCookies(). The default - // implementation in Store will immediately call the callback with a "not - // implemented" Error. - if ( - typeof store.removeAllCookies === 'function' && - store.removeAllCookies !== Store.prototype.removeAllCookies - ) { - store.removeAllCookies(cb) - return promiseCallback.promise - } - - store.getAllCookies((err, cookies): void => { - if (err) { - cb(err) - return - } - - if (!cookies) { - cookies = [] - } - - if (cookies.length === 0) { - cb(null) - return - } - - let completedCount = 0 - const removeErrors: Error[] = [] - - function removeCookieCb(removeErr: Error | undefined) { - if (removeErr) { - removeErrors.push(removeErr) - } - - completedCount++ - - if (completedCount === cookies?.length) { - cb(removeErrors.length ? removeErrors[0] : null) - return - } - } - - cookies.forEach((cookie) => { - store.removeCookie( - cookie.domain, - cookie.path, - cookie.key, - removeCookieCb, - ) - }) - }) - - return promiseCallback.promise - } - removeAllCookiesSync(): void { - return this.callSync((callback) => this.removeAllCookies(callback)) - } - - static deserialize( - strOrObj: string | object, - callback: Callback, - ): void - static deserialize( - strOrObj: string | object, - store: Store, - callback: Callback, - ): void - static deserialize(strOrObj: string | object): Promise - static deserialize( - strOrObj: string | object, - store: Store, - ): Promise - static deserialize( - strOrObj: string | object, - store?: Store | Callback, - callback?: Callback, - ): unknown - static deserialize( - strOrObj: string | object, - store?: Store | Callback, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _callback?: Callback, - ): unknown { - if (typeof store === 'function') { - store = undefined - } - - const promiseCallback = createPromiseCallback(arguments) - - let serialized: unknown - if (typeof strOrObj === 'string') { - try { - serialized = JSON.parse(strOrObj) - } catch (e) { - return promiseCallback.reject(e instanceof Error ? e : new Error()) - } - } else { - serialized = strOrObj - } - - const readSerializedProperty = (property: string): unknown | undefined => { - return serialized && - typeof serialized === 'object' && - inOperator(property, serialized) - ? serialized[property] - : undefined - } - - const readSerializedBoolean = (property: string): boolean | undefined => { - const value = readSerializedProperty(property) - return typeof value === 'boolean' ? value : undefined - } - - const readSerializedString = (property: string): string | undefined => { - const value = readSerializedProperty(property) - return typeof value === 'string' ? value : undefined - } - - const jar = new CookieJar(store, { - rejectPublicSuffixes: readSerializedBoolean('rejectPublicSuffixes'), - looseMode: readSerializedBoolean('enableLooseMode'), - allowSpecialUseDomain: readSerializedBoolean('allowSpecialUseDomain'), - prefixSecurity: getNormalizedPrefixSecurity( - readSerializedString('prefixSecurity') ?? 'silent', - ), - }) - - jar._importCookies(serialized, (err) => { - if (err) { - promiseCallback.callback(err) - return - } - promiseCallback.callback(undefined, jar) - }) - - return promiseCallback.promise - } - - static deserializeSync( - strOrObj: string | SerializedCookieJar, - store?: Store, - ): CookieJar { - const serialized: unknown = - typeof strOrObj === 'string' ? JSON.parse(strOrObj) : strOrObj - - const readSerializedProperty = (property: string): unknown | undefined => { - return serialized && - typeof serialized === 'object' && - inOperator(property, serialized) - ? serialized[property] - : undefined - } - - const readSerializedBoolean = (property: string): boolean | undefined => { - const value = readSerializedProperty(property) - return typeof value === 'boolean' ? value : undefined - } - - const readSerializedString = (property: string): string | undefined => { - const value = readSerializedProperty(property) - return typeof value === 'string' ? value : undefined - } - - const jar = new CookieJar(store, { - rejectPublicSuffixes: readSerializedBoolean('rejectPublicSuffixes'), - looseMode: readSerializedBoolean('enableLooseMode'), - allowSpecialUseDomain: readSerializedBoolean('allowSpecialUseDomain'), - prefixSecurity: getNormalizedPrefixSecurity( - readSerializedString('prefixSecurity') ?? 'silent', - ), - }) - - // catch this mistake early: - if (!jar.store.synchronous) { - throw new Error( - 'CookieJar store is not synchronous; use async API instead.', - ) - } - - jar._importCookiesSync(serialized) - return jar - } - - static fromJSON(jsonString: SerializedCookieJar, store?: Store): CookieJar { - return CookieJar.deserializeSync(jsonString, store) - } -} - -const getPublicSuffix = pubsuffix.getPublicSuffix -const ParameterError = validators.ParameterError - -export { version as version } -export { Store as Store } -export { MemoryCookieStore as MemoryCookieStore } -export { parseDate as parseDate } -export { formatDate as formatDate } -export { parse as parse } -export { fromJSON as fromJSON } -export { domainMatch as domainMatch } -export { defaultPath as defaultPath } -export { pathMatch as pathMatch } -export { getPublicSuffix as getPublicSuffix } -export { cookieCompare as cookieCompare } -export { permuteDomain as permuteDomain } -export { permutePath as permutePath } -export { canonicalDomain as canonicalDomain } -export { PrefixSecurityEnum as PrefixSecurityEnum } -export { ParameterError as ParameterError } - -type SetCookieOptions = { - loose?: boolean | undefined - sameSiteContext?: 'strict' | 'lax' | 'none' | undefined - ignoreError?: boolean | undefined - http?: boolean | undefined - now?: Date | undefined -} - -type GetCookiesOptions = { - http?: boolean | undefined - expire?: boolean | undefined - allPaths?: boolean | undefined - sameSiteContext?: 'none' | 'lax' | 'strict' | undefined - sort?: boolean | undefined -} - -type ParseCookieOptions = { - loose?: boolean | undefined -} - -interface PromiseCallback { - promise: Promise - callback: (error: Error | undefined | null, result?: T) => void - resolve: (value: T | undefined) => Promise - reject: (error: Error | undefined | null) => Promise -} - -export interface SerializedCookieJar { - version: string - storeType: string | null - rejectPublicSuffixes: boolean - [key: string]: unknown - cookies: SerializedCookie[] -} - -export interface SerializedCookie { - key?: string - value?: string - [key: string]: unknown -} - -export type Callback = ( - error: Error | undefined, - result: T | undefined, -) => void - -function inOperator( - k: K, - o: T, -): o is T & Record { - return k in o -} diff --git a/lib/cookie/canonicalDomain.ts b/lib/cookie/canonicalDomain.ts new file mode 100644 index 00000000..1db890d5 --- /dev/null +++ b/lib/cookie/canonicalDomain.ts @@ -0,0 +1,22 @@ +import * as punycode from 'punycode/' +import { IP_V6_REGEX_OBJECT } from './constants' + +// S5.1.2 Canonicalized Host Names +export function canonicalDomain(str: string | null) { + if (str == null) { + return null + } + let _str = str.trim().replace(/^\./, '') // S4.1.2.3 & S5.2.3: ignore leading . + + if (IP_V6_REGEX_OBJECT.test(_str)) { + _str = _str.replace('[', '').replace(']', '') + } + + // convert to IDN if any non-ASCII characters + // eslint-disable-next-line no-control-regex + if (/[^\u0001-\u007f]/.test(_str)) { + _str = punycode.toASCII(_str) + } + + return _str.toLowerCase() +} diff --git a/lib/cookie/constants.ts b/lib/cookie/constants.ts new file mode 100644 index 00000000..278449b3 --- /dev/null +++ b/lib/cookie/constants.ts @@ -0,0 +1,36 @@ +export const PrefixSecurityEnum = Object.freeze({ + SILENT: 'silent', + STRICT: 'strict', + DISABLED: 'unsafe-disabled', +}) + +const IP_V6_REGEX = ` +\\[?(?: +(?:[a-fA-F\\d]{1,4}:){7}(?:[a-fA-F\\d]{1,4}|:)| +(?:[a-fA-F\\d]{1,4}:){6}(?:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|:[a-fA-F\\d]{1,4}|:)| +(?:[a-fA-F\\d]{1,4}:){5}(?::(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,2}|:)| +(?:[a-fA-F\\d]{1,4}:){4}(?:(?::[a-fA-F\\d]{1,4}){0,1}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,3}|:)| +(?:[a-fA-F\\d]{1,4}:){3}(?:(?::[a-fA-F\\d]{1,4}){0,2}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,4}|:)| +(?:[a-fA-F\\d]{1,4}:){2}(?:(?::[a-fA-F\\d]{1,4}){0,3}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,5}|:)| +(?:[a-fA-F\\d]{1,4}:){1}(?:(?::[a-fA-F\\d]{1,4}){0,4}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,6}|:)| +(?::(?:(?::[a-fA-F\\d]{1,4}){0,5}:(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]\\d|\\d)){3}|(?::[a-fA-F\\d]{1,4}){1,7}|:)) +)(?:%[0-9a-zA-Z]{1,})?\\]? +` + .replace(/\s*\/\/.*$/gm, '') + .replace(/\n/g, '') + .trim() +export const IP_V6_REGEX_OBJECT = new RegExp(`^${IP_V6_REGEX}$`) + +export interface SerializedCookieJar { + version: string + storeType: string | null + rejectPublicSuffixes: boolean + [key: string]: unknown + cookies: SerializedCookie[] +} + +export interface SerializedCookie { + key?: string + value?: string + [key: string]: unknown +} diff --git a/lib/cookie/cookie.ts b/lib/cookie/cookie.ts new file mode 100644 index 00000000..15052309 --- /dev/null +++ b/lib/cookie/cookie.ts @@ -0,0 +1,733 @@ +/*! + * Copyright (c) 2015-2020, Salesforce.com, Inc. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of Salesforce.com nor the names of its contributors may + * be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +import * as pubsuffix from '../pubsuffix-psl' +import * as validators from '../validators' +import { getCustomInspectSymbol } from '../utilHelper' +import { inOperator } from '../utils' + +import { formatDate } from './formatDate' +import { parseDate } from './parseDate' +import { canonicalDomain } from './canonicalDomain' +import type { SerializedCookie } from './constants' + +// From RFC6265 S4.1.1 +// note that it excludes \x3B ";" +const COOKIE_OCTETS = /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]+$/ + +// RFC6265 S4.1.1 defines path value as 'any CHAR except CTLs or ";"' +// Note ';' is \x3B +const PATH_VALUE = /[\x20-\x3A\x3C-\x7E]+/ + +// eslint-disable-next-line no-control-regex +const CONTROL_CHARS = /[\x00-\x1F]/ + +// From Chromium // '\r', '\n' and '\0' should be treated as a terminator in +// the "relaxed" mode, see: +// https://github.com/ChromiumWebApps/chromium/blob/b3d3b4da8bb94c1b2e061600df106d590fda3620/net/cookies/parsed_cookie.cc#L60 +const TERMINATORS = ['\n', '\r', '\0'] + +function trimTerminator(str: string) { + if (validators.isEmptyString(str)) return str + for (let t = 0; t < TERMINATORS.length; t++) { + const terminator = TERMINATORS[t] + const terminatorIdx = terminator ? str.indexOf(terminator) : -1 + if (terminatorIdx !== -1) { + str = str.slice(0, terminatorIdx) + } + } + + return str +} + +function parseCookiePair(cookiePair: string, looseMode: boolean) { + cookiePair = trimTerminator(cookiePair) + validators.validate(validators.isString(cookiePair), cookiePair) + + let firstEq = cookiePair.indexOf('=') + if (looseMode) { + if (firstEq === 0) { + // '=' is immediately at start + cookiePair = cookiePair.substr(1) + firstEq = cookiePair.indexOf('=') // might still need to split on '=' + } + } else { + // non-loose mode + if (firstEq <= 0) { + // no '=' or is at start + return undefined // needs to have non-empty "cookie-name" + } + } + + let cookieName, cookieValue + if (firstEq <= 0) { + cookieName = '' + cookieValue = cookiePair.trim() + } else { + cookieName = cookiePair.slice(0, firstEq).trim() + cookieValue = cookiePair.slice(firstEq + 1).trim() + } + + if (CONTROL_CHARS.test(cookieName) || CONTROL_CHARS.test(cookieValue)) { + return undefined + } + + const c = new Cookie() + c.key = cookieName + c.value = cookieValue + return c +} + +type ParseCookieOptions = { + loose?: boolean | undefined +} + +function parse( + str: string, + options?: ParseCookieOptions, +): Cookie | undefined | null { + if (validators.isEmptyString(str) || !validators.isString(str)) { + return null + } + + str = str.trim() + + // We use a regex to parse the "name-value-pair" part of S5.2 + const firstSemi = str.indexOf(';') // S5.2 step 1 + const cookiePair = firstSemi === -1 ? str : str.slice(0, firstSemi) + const c = parseCookiePair(cookiePair, options?.loose ?? false) + if (!c) { + return undefined + } + + if (firstSemi === -1) { + return c + } + + // S5.2.3 "unparsed-attributes consist of the remainder of the set-cookie-string + // (including the %x3B (";") in question)." plus later on in the same section + // "discard the first ";" and trim". + const unparsed = str.slice(firstSemi + 1).trim() + + // "If the unparsed-attributes string is empty, skip the rest of these + // steps." + if (unparsed.length === 0) { + return c + } + + /* + * S5.2 says that when looping over the items "[p]rocess the attribute-name + * and attribute-value according to the requirements in the following + * subsections" for every item. Plus, for many of the individual attributes + * in S5.3 it says to use the "attribute-value of the last attribute in the + * cookie-attribute-list". Therefore, in this implementation, we overwrite + * the previous value. + */ + const cookie_avs = unparsed.split(';') + while (cookie_avs.length) { + const av = (cookie_avs.shift() ?? '').trim() + if (av.length === 0) { + // happens if ";;" appears + continue + } + const av_sep = av.indexOf('=') + let av_key, av_value + + if (av_sep === -1) { + av_key = av + av_value = null + } else { + av_key = av.slice(0, av_sep) + av_value = av.slice(av_sep + 1) + } + + av_key = av_key.trim().toLowerCase() + + if (av_value) { + av_value = av_value.trim() + } + + switch (av_key) { + case 'expires': // S5.2.1 + if (av_value) { + const exp = parseDate(av_value) + // "If the attribute-value failed to parse as a cookie date, ignore the + // cookie-av." + if (exp) { + // over and underflow not realistically a concern: V8's getTime() seems to + // store something larger than a 32-bit time_t (even with 32-bit node) + c.expires = exp + } + } + break + + case 'max-age': // S5.2.2 + if (av_value) { + // "If the first character of the attribute-value is not a DIGIT or a "-" + // character ...[or]... If the remainder of attribute-value contains a + // non-DIGIT character, ignore the cookie-av." + if (/^-?[0-9]+$/.test(av_value)) { + const delta = parseInt(av_value, 10) + // "If delta-seconds is less than or equal to zero (0), let expiry-time + // be the earliest representable date and time." + c.setMaxAge(delta) + } + } + break + + case 'domain': // S5.2.3 + // "If the attribute-value is empty, the behavior is undefined. However, + // the user agent SHOULD ignore the cookie-av entirely." + if (av_value) { + // S5.2.3 "Let cookie-domain be the attribute-value without the leading %x2E + // (".") character." + const domain = av_value.trim().replace(/^\./, '') + if (domain) { + // "Convert the cookie-domain to lower case." + c.domain = domain.toLowerCase() + } + } + break + + case 'path': // S5.2.4 + /* + * "If the attribute-value is empty or if the first character of the + * attribute-value is not %x2F ("/"): + * Let cookie-path be the default-path. + * Otherwise: + * Let cookie-path be the attribute-value." + * + * We'll represent the default-path as null since it depends on the + * context of the parsing. + */ + c.path = av_value && av_value[0] === '/' ? av_value : null + break + + case 'secure': // S5.2.5 + /* + * "If the attribute-name case-insensitively matches the string "Secure", + * the user agent MUST append an attribute to the cookie-attribute-list + * with an attribute-name of Secure and an empty attribute-value." + */ + c.secure = true + break + + case 'httponly': // S5.2.6 -- effectively the same as 'secure' + c.httpOnly = true + break + + case 'samesite': // RFC6265bis-02 S5.3.7 + switch (av_value ? av_value.toLowerCase() : '') { + case 'strict': + c.sameSite = 'strict' + break + case 'lax': + c.sameSite = 'lax' + break + case 'none': + c.sameSite = 'none' + break + default: + c.sameSite = undefined + break + } + break + + default: + c.extensions = c.extensions || [] + c.extensions.push(av) + break + } + } + + return c +} + +function fromJSON(str: string | SerializedCookie | null | undefined | unknown) { + if (!str || validators.isEmptyString(str)) { + return null + } + + let obj: unknown + if (typeof str === 'string') { + try { + obj = JSON.parse(str) + } catch (e) { + return null + } + } else { + // assume it's an Object + obj = str + } + + const c = new Cookie() + Cookie.serializableProperties.forEach((prop) => { + if (obj && typeof obj === 'object' && inOperator(prop, obj)) { + const val = obj[prop] + if (val === undefined) { + return + } + + if (inOperator(prop, cookieDefaults) && val === cookieDefaults[prop]) { + return + } + + switch (prop) { + case 'key': + case 'value': + case 'sameSite': + if (typeof val === 'string') { + c[prop] = val + } + break + case 'expires': + case 'creation': + case 'lastAccessed': + if ( + typeof val === 'number' || + typeof val === 'string' || + val instanceof Date + ) { + c[prop] = obj[prop] == 'Infinity' ? 'Infinity' : new Date(val) + } else if (val === null) { + c[prop] = null + } + break + case 'maxAge': + if ( + typeof val === 'number' || + val === 'Infinity' || + val === '-Infinity' + ) { + c[prop] = val + } + break + case 'domain': + case 'path': + if (typeof val === 'string' || val === null) { + c[prop] = val + } + break + case 'secure': + case 'httpOnly': + if (typeof val === 'boolean') { + c[prop] = val + } + break + case 'extensions': + if ( + Array.isArray(val) && + val.every((item) => typeof item === 'string') + ) { + c[prop] = val + } + break + case 'hostOnly': + case 'pathIsDefault': + if (typeof val === 'boolean' || val === null) { + c[prop] = val + } + break + } + } + }) + + return c +} + +const cookieDefaults = { + // the order in which the RFC has them: + key: '', + value: '', + expires: 'Infinity', + maxAge: null, + domain: null, + path: null, + secure: false, + httpOnly: false, + extensions: null, + // set by the CookieJar: + hostOnly: null, + pathIsDefault: null, + creation: null, + lastAccessed: null, + sameSite: undefined, +} + +type CreateCookieOptions = { + key?: string + value?: string + expires?: Date | 'Infinity' | null + maxAge?: number | 'Infinity' | '-Infinity' + domain?: string | null + path?: string | null + secure?: boolean + httpOnly?: boolean + extensions?: string[] | null + creation?: Date | 'Infinity' | null + creationIndex?: number + hostOnly?: boolean | null + pathIsDefault?: boolean | null + lastAccessed?: Date | 'Infinity' | null + sameSite?: string | undefined +} + +export class Cookie { + key: string | undefined + value: string | undefined + expires: Date | 'Infinity' | null | undefined + maxAge: number | 'Infinity' | '-Infinity' | undefined + domain: string | null | undefined + path: string | null | undefined + secure: boolean | undefined + httpOnly: boolean | undefined + extensions: string[] | null | undefined + creation: Date | 'Infinity' | null + creationIndex: number | undefined + hostOnly: boolean | null | undefined + pathIsDefault: boolean | null | undefined + lastAccessed: Date | 'Infinity' | null | undefined + sameSite: string | undefined + + constructor(options: CreateCookieOptions = {}) { + // supports inspect if that feature is available in the environment + const customInspectSymbol = getCustomInspectSymbol() + if (customInspectSymbol) { + Object.defineProperty(this, customInspectSymbol, { + value: this.inspect.bind(this), + enumerable: false, + writable: false, + configurable: false, + }) + } + + Object.assign(this, cookieDefaults, options) + this.creation = options.creation ?? cookieDefaults.creation ?? new Date() + + // used to break creation ties in cookieCompare(): + Object.defineProperty(this, 'creationIndex', { + configurable: false, + enumerable: false, // important for assert.deepEqual checks + writable: true, + value: ++Cookie.cookiesCreated, + }) + } + + inspect() { + const now = Date.now() + const hostOnly = this.hostOnly != null ? this.hostOnly.toString() : '?' + const createAge = + this.creation && this.creation !== 'Infinity' + ? `${now - this.creation.getTime()}ms` + : '?' + const accessAge = + this.lastAccessed && this.lastAccessed !== 'Infinity' + ? `${now - this.lastAccessed.getTime()}ms` + : '?' + return `Cookie="${this.toString()}; hostOnly=${hostOnly}; aAge=${accessAge}; cAge=${createAge}"` + } + + toJSON(): SerializedCookie { + const obj: SerializedCookie = {} + + for (const prop of Cookie.serializableProperties) { + const val = this[prop] + + if (val === cookieDefaults[prop]) { + continue // leave as prototype default + } + + switch (prop) { + case 'key': + case 'value': + case 'sameSite': + if (typeof val === 'string') { + obj[prop] = val + } + break + case 'expires': + case 'creation': + case 'lastAccessed': + if ( + typeof val === 'number' || + typeof val === 'string' || + val instanceof Date + ) { + obj[prop] = + val == 'Infinity' ? 'Infinity' : new Date(val).toISOString() + } else if (val === null) { + obj[prop] = null + } + break + case 'maxAge': + if ( + typeof val === 'number' || + val === 'Infinity' || + val === '-Infinity' + ) { + obj[prop] = val + } + break + case 'domain': + case 'path': + if (typeof val === 'string' || val === null) { + obj[prop] = val + } + break + case 'secure': + case 'httpOnly': + if (typeof val === 'boolean') { + obj[prop] = val + } + break + case 'extensions': + if (Array.isArray(val)) { + obj[prop] = val + } + break + case 'hostOnly': + case 'pathIsDefault': + if (typeof val === 'boolean' || val === null) { + obj[prop] = val + } + break + } + } + + return obj + } + + clone() { + return fromJSON(this.toJSON()) + } + + validate() { + if (this.value == null || !COOKIE_OCTETS.test(this.value)) { + return false + } + if ( + this.expires != 'Infinity' && + !(this.expires instanceof Date) && + !parseDate(this.expires) + ) { + return false + } + if (this.maxAge != null && this.maxAge <= 0) { + return false // "Max-Age=" non-zero-digit *DIGIT + } + if (this.path != null && !PATH_VALUE.test(this.path)) { + return false + } + + const cdomain = this.cdomain() + if (cdomain) { + if (cdomain.match(/\.$/)) { + return false // S4.1.2.3 suggests that this is bad. domainMatch() tests confirm this + } + const suffix = pubsuffix.getPublicSuffix(cdomain) + if (suffix == null) { + // it's a public suffix + return false + } + } + return true + } + + setExpires(exp: string | Date) { + if (exp instanceof Date) { + this.expires = exp + } else { + this.expires = parseDate(exp) || 'Infinity' + } + } + + setMaxAge(age: number) { + if (age === Infinity) { + this.maxAge = 'Infinity' + } else if (age === -Infinity) { + this.maxAge = '-Infinity' + } else { + this.maxAge = age + } + } + + cookieString() { + const val = this.value ?? '' + if (this.key) { + return `${this.key}=${val}` + } + return val + } + + // gives Set-Cookie header format + toString() { + let str = this.cookieString() + + if (this.expires != 'Infinity') { + if (this.expires instanceof Date) { + str += `; Expires=${formatDate(this.expires)}` + } + } + + if (this.maxAge != null && this.maxAge != Infinity) { + str += `; Max-Age=${this.maxAge}` + } + + if (this.domain && !this.hostOnly) { + str += `; Domain=${this.domain}` + } + if (this.path) { + str += `; Path=${this.path}` + } + + if (this.secure) { + str += '; Secure' + } + if (this.httpOnly) { + str += '; HttpOnly' + } + if (this.sameSite && this.sameSite !== 'none') { + if ( + this.sameSite.toLowerCase() === + Cookie.sameSiteCanonical.lax.toLowerCase() + ) { + str += `; SameSite=${Cookie.sameSiteCanonical.lax}` + } else if ( + this.sameSite.toLowerCase() === + Cookie.sameSiteCanonical.strict.toLowerCase() + ) { + str += `; SameSite=${Cookie.sameSiteCanonical.strict}` + } else { + str += `; SameSite=${this.sameSite}` + } + } + if (this.extensions) { + this.extensions.forEach((ext) => { + str += `; ${ext}` + }) + } + + return str + } + + // TTL() partially replaces the "expiry-time" parts of S5.3 step 3 (setCookie() + // elsewhere) + // S5.3 says to give the "latest representable date" for which we use Infinity + // For "expired" we use 0 + TTL(now: number = Date.now()): number { + /* RFC6265 S4.1.2.2 If a cookie has both the Max-Age and the Expires + * attribute, the Max-Age attribute has precedence and controls the + * expiration date of the cookie. + * (Concurs with S5.3 step 3) + */ + if (this.maxAge != null && typeof this.maxAge === 'number') { + return this.maxAge <= 0 ? 0 : this.maxAge * 1000 + } + + const expires = this.expires + if (expires === 'Infinity') { + return Infinity + } + + return (expires?.getTime() ?? now) - (now || Date.now()) + } + + // expiryTime() replaces the "expiry-time" parts of S5.3 step 3 (setCookie() + // elsewhere) + expiryTime(now?: Date): number | undefined { + if (this.maxAge != null) { + const relativeTo = now || this.creation || new Date() + const maxAge = typeof this.maxAge === 'number' ? this.maxAge : -Infinity + const age = maxAge <= 0 ? -Infinity : maxAge * 1000 + if (relativeTo === 'Infinity') { + return Infinity + } + return relativeTo.getTime() + age + } + + if (this.expires == 'Infinity') { + return Infinity + } + + return this.expires ? this.expires.getTime() : undefined + } + + // This replaces the "persistent-flag" parts of S5.3 step 3 + isPersistent(): boolean { + return this.maxAge != null || this.expires != 'Infinity' + } + + // Mostly S5.1.2 and S5.2.3: + canonicalizedDomain() { + if (this.domain == null) { + return null + } + return canonicalDomain(this.domain) + } + + cdomain() { + return this.canonicalizedDomain() + } + + static parse = parse + + static fromJSON = fromJSON + + static cookiesCreated = 0 + + static sameSiteLevel = { + strict: 3, + lax: 2, + none: 1, + } as const + + static sameSiteCanonical = { + strict: 'Strict', + lax: 'Lax', + } as const + + static serializableProperties = [ + 'key', + 'value', + 'expires', + 'maxAge', + 'domain', + 'path', + 'secure', + 'httpOnly', + 'extensions', + 'hostOnly', + 'pathIsDefault', + 'creation', + 'lastAccessed', + 'sameSite', + ] as const +} diff --git a/lib/cookie/cookieCompare.ts b/lib/cookie/cookieCompare.ts new file mode 100644 index 00000000..dd793f4e --- /dev/null +++ b/lib/cookie/cookieCompare.ts @@ -0,0 +1,47 @@ +import { safeToString } from '../utils' +import * as validators from '../validators' +import type { Cookie } from './cookie' +/* Section 5.4 part 2: + * "* Cookies with longer paths are listed before cookies with + * shorter paths. + * + * * Among cookies that have equal-length path fields, cookies with + * earlier creation-times are listed before cookies with later + * creation-times." + */ + +/** + * The maximum timestamp a cookie, in milliseconds. The value is (2^31 - 1) seconds since the Unix + * epoch, corresponding to 2038-01-19. + */ +const MAX_TIME = 2147483647000 + +/** Compares two cookies for sorting. */ +export function cookieCompare(a: Cookie, b: Cookie) { + validators.validate(validators.isObject(a), safeToString(a)) + validators.validate(validators.isObject(b), safeToString(b)) + let cmp: number + + // descending for length: b CMP a + const aPathLen = a.path ? a.path.length : 0 + const bPathLen = b.path ? b.path.length : 0 + cmp = bPathLen - aPathLen + if (cmp !== 0) { + return cmp + } + + // ascending for time: a CMP b + const aTime = + a.creation && a.creation instanceof Date ? a.creation.getTime() : MAX_TIME + const bTime = + b.creation && b.creation instanceof Date ? b.creation.getTime() : MAX_TIME + cmp = aTime - bTime + if (cmp !== 0) { + return cmp + } + + // break ties for the same millisecond (precision of JavaScript's clock) + cmp = (a.creationIndex ?? 0) - (b.creationIndex ?? 0) + + return cmp +} diff --git a/lib/cookie/cookieJar.ts b/lib/cookie/cookieJar.ts new file mode 100644 index 00000000..aff9c2be --- /dev/null +++ b/lib/cookie/cookieJar.ts @@ -0,0 +1,1137 @@ +import urlParse from 'url-parse' + +import * as pubsuffix from '../pubsuffix-psl' +import * as validators from '../validators' +import { Store } from '../store' +import { MemoryCookieStore } from '../memstore' +import { pathMatch } from '../pathMatch' +import { Cookie } from './cookie' +import { + Callback, + ErrorCallback, + createPromiseCallback, + inOperator, + safeToString, +} from '../utils' +import { canonicalDomain } from './canonicalDomain' +import { + IP_V6_REGEX_OBJECT, + PrefixSecurityEnum, + SerializedCookieJar, +} from './constants' +import { defaultPath } from './defaultPath' +import { domainMatch } from './domainMatch' +import { cookieCompare } from './cookieCompare' +import { version } from '../version' + +const defaultSetCookieOptions: SetCookieOptions = { + loose: false, + sameSiteContext: undefined, + ignoreError: false, + http: true, +} + +const defaultGetCookieOptions: GetCookiesOptions = { + http: true, + expire: true, + allPaths: false, + sameSiteContext: undefined, + sort: undefined, +} + +type SetCookieOptions = { + loose?: boolean | undefined + sameSiteContext?: 'strict' | 'lax' | 'none' | undefined + ignoreError?: boolean | undefined + http?: boolean | undefined + now?: Date | undefined +} + +type GetCookiesOptions = { + http?: boolean | undefined + expire?: boolean | undefined + allPaths?: boolean | undefined + sameSiteContext?: 'none' | 'lax' | 'strict' | undefined + sort?: boolean | undefined +} + +type CreateCookieJarOptions = { + rejectPublicSuffixes?: boolean | undefined + looseMode?: boolean | undefined + prefixSecurity?: 'strict' | 'silent' | 'unsafe-disabled' | undefined + allowSpecialUseDomain?: boolean | undefined +} + +const SAME_SITE_CONTEXT_VAL_ERR = + 'Invalid sameSiteContext option for getCookies(); expected one of "strict", "lax", or "none"' + +function getCookieContext(url: string | URL) { + if (url instanceof URL && 'query' in url) { + return url + } + + if (typeof url === 'string') { + try { + return urlParse(decodeURI(url)) + } catch { + return urlParse(url) + } + } + + throw new Error('`url` argument is invalid') +} + +function checkSameSiteContext(value: string) { + validators.validate(validators.isNonEmptyString(value), value) + const context = String(value).toLowerCase() + if (context === 'none' || context === 'lax' || context === 'strict') { + return context + } else { + return null + } +} + +/** + * If the cookie-name begins with a case-sensitive match for the + * string "__Secure-", abort these steps and ignore the cookie + * entirely unless the cookie's secure-only-flag is true. + * @param cookie + * @returns boolean + */ +function isSecurePrefixConditionMet(cookie: Cookie) { + validators.validate(validators.isObject(cookie), safeToString(cookie)) + const startsWithSecurePrefix = + typeof cookie.key === 'string' && cookie.key.startsWith('__Secure-') + return !startsWithSecurePrefix || cookie.secure +} + +/** + * If the cookie-name begins with a case-sensitive match for the + * string "__Host-", abort these steps and ignore the cookie + * entirely unless the cookie meets all the following criteria: + * 1. The cookie's secure-only-flag is true. + * 2. The cookie's host-only-flag is true. + * 3. The cookie-attribute-list contains an attribute with an + * attribute-name of "Path", and the cookie's path is "/". + * @param cookie + * @returns boolean + */ +function isHostPrefixConditionMet(cookie: Cookie) { + validators.validate(validators.isObject(cookie)) + const startsWithHostPrefix = + typeof cookie.key === 'string' && cookie.key.startsWith('__Host-') + return ( + !startsWithHostPrefix || + (cookie.secure && + cookie.hostOnly && + cookie.path != null && + cookie.path === '/') + ) +} + +function getNormalizedPrefixSecurity(prefixSecurity: string) { + if (prefixSecurity != null) { + const normalizedPrefixSecurity = prefixSecurity.toLowerCase() + /* The three supported options */ + switch (normalizedPrefixSecurity) { + case PrefixSecurityEnum.STRICT: + case PrefixSecurityEnum.SILENT: + case PrefixSecurityEnum.DISABLED: + return normalizedPrefixSecurity + } + } + /* Default is SILENT */ + return PrefixSecurityEnum.SILENT +} + +export class CookieJar { + readonly store: Store + private readonly rejectPublicSuffixes: boolean + private readonly enableLooseMode: boolean + private readonly allowSpecialUseDomain: boolean + readonly prefixSecurity: string + + constructor( + store?: Store | null | undefined, + options?: CreateCookieJarOptions | boolean, + ) { + if (typeof options === 'boolean') { + options = { rejectPublicSuffixes: options } + } + this.rejectPublicSuffixes = options?.rejectPublicSuffixes ?? true + this.enableLooseMode = options?.looseMode ?? false + this.allowSpecialUseDomain = options?.allowSpecialUseDomain ?? true + this.prefixSecurity = getNormalizedPrefixSecurity( + options?.prefixSecurity ?? 'silent', + ) + this.store = store ?? new MemoryCookieStore() + } + + private callSync(fn: (callback: Callback) => void): T | undefined { + if (!this.store.synchronous) { + throw new Error( + 'CookieJar store is not synchronous; use async API instead.', + ) + } + let syncErr: Error | undefined + let syncResult: T | undefined = undefined + fn.call(this, (error, result) => { + syncErr = error + syncResult = result + }) + if (syncErr) { + throw syncErr + } + + return syncResult + } + + setCookie( + cookie: string | Cookie, + url: string, + callback: Callback, + ): void + setCookie( + cookie: string | Cookie, + url: string, + options: SetCookieOptions, + callback: Callback, + ): void + setCookie(cookie: string | Cookie, url: string): Promise + setCookie( + cookie: string | Cookie, + url: string, + options: SetCookieOptions, + ): Promise + setCookie( + cookie: string | Cookie, + url: string, + options: SetCookieOptions | Callback, + callback?: Callback, + ): unknown + setCookie( + cookie: string | Cookie, + url: string, + options?: SetCookieOptions | Callback, + callback?: Callback, + ): unknown { + const promiseCallback = createPromiseCallback(arguments) + const cb = promiseCallback.callback + + validators.validate( + validators.isNonEmptyString(url), + callback, + safeToString(options), + ) + let err + + if (typeof url === 'function') { + return promiseCallback.reject(new Error('No URL was specified')) + } + + const context = getCookieContext(url) + if (typeof options === 'function') { + options = defaultSetCookieOptions + } + + validators.validate(typeof cb === 'function', cb) + + if ( + !validators.isNonEmptyString(cookie) && + !validators.isObject(cookie) && + cookie instanceof String && + cookie.length == 0 + ) { + return promiseCallback.reject(null) + } + + const host = canonicalDomain(context.hostname) + const loose = options?.loose || this.enableLooseMode + + let sameSiteContext = null + if (options?.sameSiteContext) { + sameSiteContext = checkSameSiteContext(options.sameSiteContext) + if (!sameSiteContext) { + return promiseCallback.reject(new Error(SAME_SITE_CONTEXT_VAL_ERR)) + } + } + + // S5.3 step 1 + if (typeof cookie === 'string' || cookie instanceof String) { + const parsedCookie = Cookie.parse(cookie.toString(), { loose: loose }) + if (!parsedCookie) { + err = new Error('Cookie failed to parse') + return promiseCallback.reject(options?.ignoreError ? null : err) + } + cookie = parsedCookie + } else if (!(cookie instanceof Cookie)) { + // If you're seeing this error, and are passing in a Cookie object, + // it *might* be a Cookie object from another loaded version of tough-cookie. + err = new Error( + 'First argument to setCookie must be a Cookie object or string', + ) + return promiseCallback.reject(options?.ignoreError ? null : err) + } + + // S5.3 step 2 + const now = options?.now || new Date() // will assign later to save effort in the face of errors + + // S5.3 step 3: NOOP; persistent-flag and expiry-time is handled by getCookie() + + // S5.3 step 4: NOOP; domain is null by default + + // S5.3 step 5: public suffixes + if (this.rejectPublicSuffixes && cookie.domain) { + try { + const cdomain = cookie.cdomain() + const suffix = + typeof cdomain === 'string' + ? pubsuffix.getPublicSuffix(cdomain, { + allowSpecialUseDomain: this.allowSpecialUseDomain, + ignoreError: options?.ignoreError, + }) + : null + if (suffix == null && !IP_V6_REGEX_OBJECT.test(cookie.domain)) { + // e.g. "com" + err = new Error('Cookie has domain set to a public suffix') + return promiseCallback.reject(options?.ignoreError ? null : err) + } + } catch (err) { + if (options?.ignoreError) { + return promiseCallback.reject(null) + } else { + if (err instanceof Error) { + return promiseCallback.reject(err) + } else { + return promiseCallback.reject(null) + } + } + } + } + + // S5.3 step 6: + if (cookie.domain) { + if ( + !domainMatch(host ?? undefined, cookie.cdomain() ?? undefined, false) + ) { + err = new Error( + `Cookie not in this host's domain. Cookie:${ + cookie.cdomain() ?? 'null' + } Request:${host ?? 'null'}`, + ) + return promiseCallback.reject(options?.ignoreError ? null : err) + } + + if (cookie.hostOnly == null) { + // don't reset if already set + cookie.hostOnly = false + } + } else { + cookie.hostOnly = true + cookie.domain = host + } + + //S5.2.4 If the attribute-value is empty or if the first character of the + //attribute-value is not %x2F ("/"): + //Let cookie-path be the default-path. + if (!cookie.path || cookie.path[0] !== '/') { + cookie.path = defaultPath(context.pathname ?? undefined) + cookie.pathIsDefault = true + } + + // S5.3 step 8: NOOP; secure attribute + // S5.3 step 9: NOOP; httpOnly attribute + + // S5.3 step 10 + if (options?.http === false && cookie.httpOnly) { + err = new Error("Cookie is HttpOnly and this isn't an HTTP API") + return promiseCallback.reject(options?.ignoreError ? null : err) + } + + // 6252bis-02 S5.4 Step 13 & 14: + if ( + cookie.sameSite !== 'none' && + cookie.sameSite !== undefined && + sameSiteContext + ) { + // "If the cookie's "same-site-flag" is not "None", and the cookie + // is being set from a context whose "site for cookies" is not an + // exact match for request-uri's host's registered domain, then + // abort these steps and ignore the newly created cookie entirely." + if (sameSiteContext === 'none') { + err = new Error('Cookie is SameSite but this is a cross-origin request') + return promiseCallback.reject(options?.ignoreError ? null : err) + } + } + + /* 6265bis-02 S5.4 Steps 15 & 16 */ + const ignoreErrorForPrefixSecurity = + this.prefixSecurity === PrefixSecurityEnum.SILENT + const prefixSecurityDisabled = + this.prefixSecurity === PrefixSecurityEnum.DISABLED + /* If prefix checking is not disabled ...*/ + if (!prefixSecurityDisabled) { + let errorFound = false + let errorMsg + /* Check secure prefix condition */ + if (!isSecurePrefixConditionMet(cookie)) { + errorFound = true + errorMsg = 'Cookie has __Secure prefix but Secure attribute is not set' + } else if (!isHostPrefixConditionMet(cookie)) { + /* Check host prefix condition */ + errorFound = true + errorMsg = + "Cookie has __Host prefix but either Secure or HostOnly attribute is not set or Path is not '/'" + } + if (errorFound) { + return promiseCallback.reject( + options?.ignoreError || ignoreErrorForPrefixSecurity + ? null + : new Error(errorMsg), + ) + } + } + + const store = this.store + + if (!store.updateCookie) { + store.updateCookie = function ( + _oldCookie: Cookie, + newCookie: Cookie, + cb?: Callback, + ): Promise { + return this.putCookie(newCookie).then( + () => { + if (cb) { + cb(undefined, undefined) + } + }, + (error: Error) => { + if (cb) { + cb(error, undefined) + } + }, + ) + } + } + + function withCookie( + err: Error | undefined, + oldCookie: Cookie | undefined | null, + ): void { + if (err) { + cb(err) + return + } + + const next = function (err: Error | undefined): void { + if (err || typeof cookie === 'string') { + cb(err) + } else { + cb(null, cookie) + } + } + + if (oldCookie) { + // S5.3 step 11 - "If the cookie store contains a cookie with the same name, + // domain, and path as the newly created cookie:" + if ( + options && + 'http' in options && + options.http === false && + oldCookie.httpOnly + ) { + // step 11.2 + err = new Error("old Cookie is HttpOnly and this isn't an HTTP API") + cb(options.ignoreError ? null : err) + return + } + if (cookie instanceof Cookie) { + cookie.creation = oldCookie.creation + // step 11.3 + cookie.creationIndex = oldCookie.creationIndex + // preserve tie-breaker + cookie.lastAccessed = now + // Step 11.4 (delete cookie) is implied by just setting the new one: + store.updateCookie(oldCookie, cookie, next) // step 12 + } + } else { + if (cookie instanceof Cookie) { + cookie.creation = cookie.lastAccessed = now + store.putCookie(cookie, next) // step 12 + } + } + } + + store.findCookie(cookie.domain, cookie.path, cookie.key, withCookie) + return promiseCallback.promise + } + setCookieSync( + cookie: string | Cookie, + url: string, + options?: SetCookieOptions, + ): Cookie | undefined { + const setCookieFn = this.setCookie.bind( + this, + cookie, + url, + options as SetCookieOptions, + ) + return this.callSync(setCookieFn) + } + + // RFC6365 S5.4 + getCookies(url: string, callback: Callback): void + getCookies( + url: string, + options: GetCookiesOptions | undefined, + callback: Callback, + ): void + getCookies(url: string): Promise + getCookies( + url: string, + options: GetCookiesOptions | undefined, + ): Promise + getCookies( + url: string, + options: GetCookiesOptions | undefined | Callback, + callback?: Callback, + ): unknown + getCookies( + url: string, + options?: GetCookiesOptions | Callback, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _callback?: Callback, + ): unknown { + const promiseCallback = createPromiseCallback(arguments) + const cb = promiseCallback.callback + + validators.validate(validators.isNonEmptyString(url), cb, url) + const context = getCookieContext(url) + if (typeof options === 'function' || options === undefined) { + options = defaultGetCookieOptions + } + validators.validate(validators.isObject(options), cb, safeToString(options)) + validators.validate(typeof cb === 'function', cb) + + const host = canonicalDomain(context.hostname) + const path = context.pathname || '/' + + const secure = + context.protocol && + (context.protocol == 'https:' || context.protocol == 'wss:') + + let sameSiteLevel = 0 + if (options?.sameSiteContext) { + const sameSiteContext = checkSameSiteContext(options.sameSiteContext) + if (sameSiteContext == null) { + return promiseCallback.reject(new Error(SAME_SITE_CONTEXT_VAL_ERR)) + } + sameSiteLevel = Cookie.sameSiteLevel[sameSiteContext] + if (!sameSiteLevel) { + return promiseCallback.reject(new Error(SAME_SITE_CONTEXT_VAL_ERR)) + } + } + + const http = options?.http ?? true + + const now = Date.now() + const expireCheck = options?.expire ?? true + const allPaths = options?.allPaths ?? false + const store = this.store + + function matchingCookie(c: Cookie) { + // "Either: + // The cookie's host-only-flag is true and the canonicalized + // request-host is identical to the cookie's domain. + // Or: + // The cookie's host-only-flag is false and the canonicalized + // request-host domain-matches the cookie's domain." + if (c.hostOnly) { + if (c.domain != host) { + return false + } + } else { + if (!domainMatch(host ?? undefined, c.domain ?? undefined, false)) { + return false + } + } + + // "The request-uri's path path-matches the cookie's path." + if (!allPaths && typeof c.path === 'string' && !pathMatch(path, c.path)) { + return false + } + + // "If the cookie's secure-only-flag is true, then the request-uri's + // scheme must denote a "secure" protocol" + if (c.secure && !secure) { + return false + } + + // "If the cookie's http-only-flag is true, then exclude the cookie if the + // cookie-string is being generated for a "non-HTTP" API" + if (c.httpOnly && !http) { + return false + } + + // RFC6265bis-02 S5.3.7 + if (sameSiteLevel) { + let cookieLevel: number + if (c.sameSite === 'lax') { + cookieLevel = Cookie.sameSiteLevel.lax + } else if (c.sameSite === 'strict') { + cookieLevel = Cookie.sameSiteLevel.strict + } else { + cookieLevel = Cookie.sameSiteLevel.none + } + if (cookieLevel > sameSiteLevel) { + // only allow cookies at or below the request level + return false + } + } + + // deferred from S5.3 + // non-RFC: allow retention of expired cookies by choice + const expiryTime = c.expiryTime() + if (expireCheck && expiryTime && expiryTime <= now) { + // eslint-disable-next-line @typescript-eslint/no-empty-function + store.removeCookie(c.domain, c.path, c.key, () => {}) // result ignored + return false + } + + return true + } + + store.findCookies( + host, + allPaths ? null : path, + this.allowSpecialUseDomain, + (err, cookies): void => { + if (err) { + cb(err) + return + } + + if (cookies == null) { + cb(undefined, []) + return + } + + cookies = cookies.filter(matchingCookie) + + // sorting of S5.4 part 2 + if (options && 'sort' in options && options.sort !== false) { + cookies = cookies.sort(cookieCompare) + } + + // S5.4 part 3 + const now = new Date() + for (const cookie of cookies) { + cookie.lastAccessed = now + } + // TODO persist lastAccessed + + cb(null, cookies) + }, + ) + + return promiseCallback.promise + } + getCookiesSync(url: string, options?: GetCookiesOptions): Cookie[] { + return ( + this.callSync(this.getCookies.bind(this, url, options)) ?? [] + ) + } + + getCookieString( + url: string, + options: GetCookiesOptions, + callback: Callback, + ): void + getCookieString(url: string, callback: Callback): void + getCookieString(url: string): Promise + getCookieString(url: string, options: GetCookiesOptions): Promise + getCookieString( + url: string, + options: GetCookiesOptions | Callback, + callback?: Callback, + ): unknown + getCookieString( + url: string, + options?: GetCookiesOptions | Callback, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _callback?: Callback, + ): unknown { + const promiseCallback = createPromiseCallback(arguments) + + if (typeof options === 'function') { + options = undefined + } + + const next: Callback = function ( + err: Error | undefined, + cookies: Cookie[] | undefined, + ) { + if (err || cookies === undefined) { + promiseCallback.callback(err) + } else { + promiseCallback.callback( + undefined, + cookies + .sort(cookieCompare) + .map((c) => c.cookieString()) + .join('; '), + ) + } + } + + this.getCookies(url, options, next) + return promiseCallback.promise + } + getCookieStringSync(url: string, options?: GetCookiesOptions): string { + return ( + this.callSync( + this.getCookieString.bind(this, url, options as GetCookiesOptions), + ) ?? '' + ) + } + + getSetCookieStrings(url: string, callback: Callback): void + getSetCookieStrings( + url: string, + options: GetCookiesOptions, + callback: Callback, + ): void + getSetCookieStrings(url: string): Promise + getSetCookieStrings( + url: string, + options: GetCookiesOptions, + ): Promise + getSetCookieStrings( + url: string, + options: GetCookiesOptions, + callback?: Callback, + ): unknown + getSetCookieStrings( + url: string, + options?: GetCookiesOptions | Callback, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _callback?: Callback, + ): unknown { + const promiseCallback = createPromiseCallback(arguments) + + if (typeof options === 'function') { + options = undefined + } + + const next: Callback = function ( + err: Error | undefined, + cookies: Cookie[] | undefined, + ) { + if (err || cookies === undefined) { + promiseCallback.callback(err) + } else { + promiseCallback.callback( + null, + cookies.map((c) => { + return c.toString() + }), + ) + } + } + + this.getCookies(url, options, next) + return promiseCallback.promise + } + getSetCookieStringsSync( + url: string, + options: GetCookiesOptions = {}, + ): string[] { + return ( + this.callSync( + this.getSetCookieStrings.bind(this, url, options), + ) ?? [] + ) + } + + serialize(callback: Callback): void + serialize(): Promise + serialize(callback?: Callback): unknown + // eslint-disable-next-line @typescript-eslint/no-unused-vars + serialize(_callback?: Callback): unknown { + const promiseCallback = + createPromiseCallback(arguments) + const cb = promiseCallback.callback + + validators.validate(typeof cb === 'function', cb) + let type: string | null = this.store.constructor.name + if (validators.isObject(type)) { + type = null + } + + // update README.md "Serialization Format" if you change this, please! + const serialized: SerializedCookieJar = { + // The version of tough-cookie that serialized this jar. Generally a good + // practice since future versions can make data import decisions based on + // known past behavior. When/if this matters, use `semver`. + version: `tough-cookie@${version}`, + + // add the store type, to make humans happy: + storeType: type, + + // CookieJar configuration: + rejectPublicSuffixes: this.rejectPublicSuffixes, + enableLooseMode: this.enableLooseMode, + allowSpecialUseDomain: this.allowSpecialUseDomain, + prefixSecurity: getNormalizedPrefixSecurity(this.prefixSecurity), + + // this gets filled from getAllCookies: + cookies: [], + } + + if ( + !( + this.store.getAllCookies && + typeof this.store.getAllCookies === 'function' + ) + ) { + return promiseCallback.reject( + new Error( + 'store does not support getAllCookies and cannot be serialized', + ), + ) + } + + this.store.getAllCookies((err, cookies) => { + if (err) { + promiseCallback.callback(err) + return + } + + if (cookies == null) { + promiseCallback.callback(undefined, serialized) + return + } + + serialized.cookies = cookies.map((cookie) => { + // convert to serialized 'raw' cookies + const serializedCookie = cookie.toJSON() + + // Remove the index so new ones get assigned during deserialization + delete serializedCookie.creationIndex + + return serializedCookie + }) + + promiseCallback.callback(undefined, serialized) + }) + + return promiseCallback.promise + } + serializeSync(): SerializedCookieJar | undefined { + return this.callSync((callback) => { + this.serialize(callback) + }) + } + + toJSON() { + return this.serializeSync() + } + + // use the class method CookieJar.deserialize instead of calling this directly + _importCookies(serialized: unknown, callback: Callback) { + let cookies: unknown[] | undefined = undefined + + if ( + serialized && + typeof serialized === 'object' && + inOperator('cookies', serialized) && + Array.isArray(serialized.cookies) + ) { + cookies = serialized.cookies + } + + if (!cookies) { + return callback( + new Error('serialized jar has no cookies array'), + undefined, + ) + } + + cookies = cookies.slice() // do not modify the original + + const putNext = (err?: Error): void => { + if (err) { + return callback(err, undefined) + } + + if (Array.isArray(cookies)) { + if (!cookies.length) { + return callback(err, this) + } + + let cookie + try { + cookie = Cookie.fromJSON(cookies.shift()) + } catch (e) { + return callback(e instanceof Error ? e : new Error(), undefined) + } + + if (cookie === null) { + return putNext(undefined) // skip this cookie + } + + this.store.putCookie(cookie, putNext) + } + } + + putNext() + } + + _importCookiesSync(serialized: unknown): void { + this.callSync(this._importCookies.bind(this, serialized)) + } + + clone(callback: Callback): void + clone(newStore: Store, callback: Callback): void + clone(): Promise + clone(newStore: Store): Promise + clone( + newStore?: Store | Callback, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _callback?: Callback, + ): unknown { + if (typeof newStore === 'function') { + newStore = undefined + } + + const promiseCallback = createPromiseCallback(arguments) + const cb = promiseCallback.callback + + this.serialize((err, serialized) => { + if (err) { + return promiseCallback.reject(err) + } + return CookieJar.deserialize(serialized ?? '', newStore, cb) + }) + + return promiseCallback.promise + } + + _cloneSync(newStore?: Store): CookieJar | undefined { + const cloneFn = + newStore && typeof newStore !== 'function' + ? this.clone.bind(this, newStore) + : this.clone.bind(this) + return this.callSync((callback) => cloneFn(callback)) + } + + cloneSync(newStore?: Store): CookieJar | undefined { + if (!newStore) { + return this._cloneSync() + } + if (!newStore.synchronous) { + throw new Error( + 'CookieJar clone destination store is not synchronous; use async API instead.', + ) + } + return this._cloneSync(newStore) + } + + removeAllCookies(callback: ErrorCallback): void + removeAllCookies(): Promise + removeAllCookies(callback?: ErrorCallback): unknown + // eslint-disable-next-line @typescript-eslint/no-unused-vars + removeAllCookies(_callback?: ErrorCallback): unknown { + const promiseCallback = createPromiseCallback(arguments) + const cb = promiseCallback.callback + + const store = this.store + + // Check that the store implements its own removeAllCookies(). The default + // implementation in Store will immediately call the callback with a "not + // implemented" Error. + if ( + typeof store.removeAllCookies === 'function' && + store.removeAllCookies !== Store.prototype.removeAllCookies + ) { + store.removeAllCookies(cb) + return promiseCallback.promise + } + + store.getAllCookies((err, cookies): void => { + if (err) { + cb(err) + return + } + + if (!cookies) { + cookies = [] + } + + if (cookies.length === 0) { + cb(null) + return + } + + let completedCount = 0 + const removeErrors: Error[] = [] + + function removeCookieCb(removeErr: Error | undefined) { + if (removeErr) { + removeErrors.push(removeErr) + } + + completedCount++ + + if (completedCount === cookies?.length) { + cb(removeErrors.length ? removeErrors[0] : null) + return + } + } + + cookies.forEach((cookie) => { + store.removeCookie( + cookie.domain, + cookie.path, + cookie.key, + removeCookieCb, + ) + }) + }) + + return promiseCallback.promise + } + removeAllCookiesSync(): void { + return this.callSync((callback) => this.removeAllCookies(callback)) + } + + static deserialize( + strOrObj: string | object, + callback: Callback, + ): void + static deserialize( + strOrObj: string | object, + store: Store, + callback: Callback, + ): void + static deserialize(strOrObj: string | object): Promise + static deserialize( + strOrObj: string | object, + store: Store, + ): Promise + static deserialize( + strOrObj: string | object, + store?: Store | Callback, + callback?: Callback, + ): unknown + static deserialize( + strOrObj: string | object, + store?: Store | Callback, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _callback?: Callback, + ): unknown { + if (typeof store === 'function') { + store = undefined + } + + const promiseCallback = createPromiseCallback(arguments) + + let serialized: unknown + if (typeof strOrObj === 'string') { + try { + serialized = JSON.parse(strOrObj) + } catch (e) { + return promiseCallback.reject(e instanceof Error ? e : new Error()) + } + } else { + serialized = strOrObj + } + + const readSerializedProperty = (property: string): unknown | undefined => { + return serialized && + typeof serialized === 'object' && + inOperator(property, serialized) + ? serialized[property] + : undefined + } + + const readSerializedBoolean = (property: string): boolean | undefined => { + const value = readSerializedProperty(property) + return typeof value === 'boolean' ? value : undefined + } + + const readSerializedString = (property: string): string | undefined => { + const value = readSerializedProperty(property) + return typeof value === 'string' ? value : undefined + } + + const jar = new CookieJar(store, { + rejectPublicSuffixes: readSerializedBoolean('rejectPublicSuffixes'), + looseMode: readSerializedBoolean('enableLooseMode'), + allowSpecialUseDomain: readSerializedBoolean('allowSpecialUseDomain'), + prefixSecurity: getNormalizedPrefixSecurity( + readSerializedString('prefixSecurity') ?? 'silent', + ), + }) + + jar._importCookies(serialized, (err) => { + if (err) { + promiseCallback.callback(err) + return + } + promiseCallback.callback(undefined, jar) + }) + + return promiseCallback.promise + } + + static deserializeSync( + strOrObj: string | SerializedCookieJar, + store?: Store, + ): CookieJar { + const serialized: unknown = + typeof strOrObj === 'string' ? JSON.parse(strOrObj) : strOrObj + + const readSerializedProperty = (property: string): unknown | undefined => { + return serialized && + typeof serialized === 'object' && + inOperator(property, serialized) + ? serialized[property] + : undefined + } + + const readSerializedBoolean = (property: string): boolean | undefined => { + const value = readSerializedProperty(property) + return typeof value === 'boolean' ? value : undefined + } + + const readSerializedString = (property: string): string | undefined => { + const value = readSerializedProperty(property) + return typeof value === 'string' ? value : undefined + } + + const jar = new CookieJar(store, { + rejectPublicSuffixes: readSerializedBoolean('rejectPublicSuffixes'), + looseMode: readSerializedBoolean('enableLooseMode'), + allowSpecialUseDomain: readSerializedBoolean('allowSpecialUseDomain'), + prefixSecurity: getNormalizedPrefixSecurity( + readSerializedString('prefixSecurity') ?? 'silent', + ), + }) + + // catch this mistake early: + if (!jar.store.synchronous) { + throw new Error( + 'CookieJar store is not synchronous; use async API instead.', + ) + } + + jar._importCookiesSync(serialized) + return jar + } + + static fromJSON(jsonString: SerializedCookieJar, store?: Store): CookieJar { + return CookieJar.deserializeSync(jsonString, store) + } +} diff --git a/lib/cookie/defaultPath.ts b/lib/cookie/defaultPath.ts new file mode 100644 index 00000000..1436d6e0 --- /dev/null +++ b/lib/cookie/defaultPath.ts @@ -0,0 +1,30 @@ +// RFC6265 S5.1.4 Paths and Path-Match + +/* + * "The user agent MUST use an algorithm equivalent to the following algorithm + * to compute the default-path of a cookie:" + * + * Assumption: the path (and not query part or absolute uri) is passed in. + */ +export function defaultPath(path?: string | null): string { + // "2. If the uri-path is empty or if the first character of the uri-path is not + // a %x2F ("/") character, output %x2F ("/") and skip the remaining steps. + if (!path || path.slice(0, 1) !== '/') { + return '/' + } + + // "3. If the uri-path contains no more than one %x2F ("/") character, output + // %x2F ("/") and skip the remaining step." + if (path === '/') { + return path + } + + const rightSlash = path.lastIndexOf('/') + if (rightSlash === 0) { + return '/' + } + + // "4. Output the characters of the uri-path from the first character up to, + // but not including, the right-most %x2F ("/")." + return path.slice(0, rightSlash) +} diff --git a/lib/cookie/domainMatch.ts b/lib/cookie/domainMatch.ts new file mode 100644 index 00000000..073f59b7 --- /dev/null +++ b/lib/cookie/domainMatch.ts @@ -0,0 +1,71 @@ +import { canonicalDomain } from './canonicalDomain' + +// Dumped from ip-regex@4.0.0, with the following changes: +// * all capturing groups converted to non-capturing -- "(?:)" +// * support for IPv6 Scoped Literal ("%eth1") removed +// * lowercase hexadecimal only +const IP_REGEX_LOWERCASE = + /(?:^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}$)|(?:^(?:(?:[a-f\d]{1,4}:){7}(?:[a-f\d]{1,4}|:)|(?:[a-f\d]{1,4}:){6}(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|:[a-f\d]{1,4}|:)|(?:[a-f\d]{1,4}:){5}(?::(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-f\d]{1,4}){1,2}|:)|(?:[a-f\d]{1,4}:){4}(?:(?::[a-f\d]{1,4}){0,1}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-f\d]{1,4}){1,3}|:)|(?:[a-f\d]{1,4}:){3}(?:(?::[a-f\d]{1,4}){0,2}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-f\d]{1,4}){1,4}|:)|(?:[a-f\d]{1,4}:){2}(?:(?::[a-f\d]{1,4}){0,3}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-f\d]{1,4}){1,5}|:)|(?:[a-f\d]{1,4}:){1}(?:(?::[a-f\d]{1,4}){0,4}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-f\d]{1,4}){1,6}|:)|(?::(?:(?::[a-f\d]{1,4}){0,5}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-f\d]{1,4}){1,7}|:)))$)/ + +// S5.1.3 Domain Matching +export function domainMatch( + str?: string | null, + domStr?: string | null, + canonicalize?: boolean, +): boolean | null { + if (str == null || domStr == null) { + return null + } + + let _str: string | null + let _domStr: string | null + + if (canonicalize !== false) { + _str = canonicalDomain(str) + _domStr = canonicalDomain(domStr) + } else { + _str = str + _domStr = domStr + } + + if (_str == null || _domStr == null) { + return null + } + + /* + * S5.1.3: + * "A string domain-matches a given domain string if at least one of the + * following conditions hold:" + * + * " o The domain string and the string are identical. (Note that both the + * domain string and the string will have been canonicalized to lower case at + * this point)" + */ + if (_str == _domStr) { + return true + } + + /* " o All of the following [three] conditions hold:" */ + + /* "* The domain string is a suffix of the string" */ + const idx = _str.lastIndexOf(domStr) + if (idx <= 0) { + return false // it's a non-match (-1) or prefix (0) + } + + // next, check it's a proper suffix + // e.g., "a.b.c".indexOf("b.c") === 2 + // 5 === 3+2 + if (_str.length !== _domStr.length + idx) { + return false // it's not a suffix + } + + /* " * The last character of the string that is not included in the + * domain string is a %x2E (".") character." */ + if (_str.substr(idx - 1, 1) !== '.') { + return false // doesn't align on "." + } + + /* " * The string is a host name (i.e., not an IP address)." */ + return !IP_REGEX_LOWERCASE.test(_str) +} diff --git a/lib/cookie/formatDate.ts b/lib/cookie/formatDate.ts new file mode 100644 index 00000000..c039c1da --- /dev/null +++ b/lib/cookie/formatDate.ts @@ -0,0 +1,8 @@ +import * as validators from '../validators' +import { safeToString } from '../utils' + +/** Converts a Date to a UTC string representation. */ +export function formatDate(date: Date) { + validators.validate(validators.isDate(date), safeToString(date)) + return date.toUTCString() +} diff --git a/lib/cookie/index.ts b/lib/cookie/index.ts new file mode 100644 index 00000000..16436026 --- /dev/null +++ b/lib/cookie/index.ts @@ -0,0 +1,24 @@ +// This file contains the exports from v4's lib/cookie.js, provided to minimize +// the need to update imports in dependent projects. + +export { MemoryCookieStore } from '../memstore' +export { pathMatch } from '../pathMatch' +export { permuteDomain } from '../permuteDomain' +export { getPublicSuffix } from '../pubsuffix-psl' +export { Store } from '../store' +export { ParameterError } from '../validators' +export { version } from '../version' + +export { canonicalDomain } from './canonicalDomain' +export { PrefixSecurityEnum } from './constants' +export { Cookie } from './cookie' +export { cookieCompare } from './cookieCompare' +export { CookieJar } from './cookieJar' +export { defaultPath } from './defaultPath' +export { domainMatch } from './domainMatch' +export { formatDate } from './formatDate' +export { parseDate } from './parseDate' +export { permutePath } from './permutePath' + +import { Cookie } from './cookie' +export const fromJSON = Cookie.fromJSON diff --git a/lib/cookie/parseDate.ts b/lib/cookie/parseDate.ts new file mode 100644 index 00000000..57b193bc --- /dev/null +++ b/lib/cookie/parseDate.ts @@ -0,0 +1,254 @@ +// date-time parsing constants (RFC6265 S5.1.1) +// eslint-disable-next-line no-control-regex +const DATE_DELIM = /[\x09\x20-\x2F\x3B-\x40\x5B-\x60\x7B-\x7E]/ + +const MONTH_TO_NUM = { + jan: 0, + feb: 1, + mar: 2, + apr: 3, + may: 4, + jun: 5, + jul: 6, + aug: 7, + sep: 8, + oct: 9, + nov: 10, + dec: 11, +} + +/* + * Parses a Natural number (i.e., non-negative integer) with either the + * *DIGIT ( non-digit *OCTET ) + * or + * *DIGIT + * grammar (RFC6265 S5.1.1). + * + * The "trailingOK" boolean controls if the grammar accepts a + * "( non-digit *OCTET )" trailer. + */ +function parseDigits( + token: string, + minDigits: number, + maxDigits: number, + trailingOK: boolean, +) { + let count = 0 + while (count < token.length) { + const c = token.charCodeAt(count) + // "non-digit = %x00-2F / %x3A-FF" + if (c <= 0x2f || c >= 0x3a) { + break + } + count++ + } + + // constrain to a minimum and maximum number of digits. + if (count < minDigits || count > maxDigits) { + return null + } + + if (!trailingOK && count != token.length) { + return null + } + + return parseInt(token.slice(0, count), 10) +} + +function parseTime(token: string) { + const parts = token.split(':') + const result = [0, 0, 0] + + /* RF6256 S5.1.1: + * time = hms-time ( non-digit *OCTET ) + * hms-time = time-field ":" time-field ":" time-field + * time-field = 1*2DIGIT + */ + + if (parts.length !== 3) { + return null + } + + for (let i = 0; i < 3; i++) { + // "time-field" must be strictly "1*2DIGIT", HOWEVER, "hms-time" can be + // followed by "( non-digit *OCTET )" therefore the last time-field can + // have a trailer + const trailingOK = i == 2 + const numPart = parts[i] + if (numPart == null) { + return null + } + const num = parseDigits(numPart, 1, 2, trailingOK) + if (num === null) { + return null + } + result[i] = num + } + + return result +} + +function parseMonth(token: string) { + token = String(token).slice(0, 3).toLowerCase() + switch (token) { + case 'jan': + return MONTH_TO_NUM.jan + case 'feb': + return MONTH_TO_NUM.feb + case 'mar': + return MONTH_TO_NUM.mar + case 'apr': + return MONTH_TO_NUM.apr + case 'may': + return MONTH_TO_NUM.may + case 'jun': + return MONTH_TO_NUM.jun + case 'jul': + return MONTH_TO_NUM.jul + case 'aug': + return MONTH_TO_NUM.aug + case 'sep': + return MONTH_TO_NUM.sep + case 'oct': + return MONTH_TO_NUM.oct + case 'nov': + return MONTH_TO_NUM.nov + case 'dec': + return MONTH_TO_NUM.dec + default: + return null + } +} + +/* + * RFC6265 S5.1.1 date parser (see RFC for full grammar) + */ +export function parseDate(str: string | undefined | null): Date | undefined { + if (!str) { + return undefined + } + + /* RFC6265 S5.1.1: + * 2. Process each date-token sequentially in the order the date-tokens + * appear in the cookie-date + */ + const tokens = str.split(DATE_DELIM) + if (!tokens) { + return undefined + } + + let hour = null + let minute = null + let second = null + let dayOfMonth = null + let month = null + let year = null + + for (let i = 0; i < tokens.length; i++) { + const token = (tokens[i] ?? '').trim() + if (!token.length) { + continue + } + + let result + + /* 2.1. If the found-time flag is not set and the token matches the time + * production, set the found-time flag and set the hour- value, + * minute-value, and second-value to the numbers denoted by the digits in + * the date-token, respectively. Skip the remaining sub-steps and continue + * to the next date-token. + */ + if (second === null) { + result = parseTime(token) + if (result) { + hour = result[0] + minute = result[1] + second = result[2] + continue + } + } + + /* 2.2. If the found-day-of-month flag is not set and the date-token matches + * the day-of-month production, set the found-day-of- month flag and set + * the day-of-month-value to the number denoted by the date-token. Skip + * the remaining sub-steps and continue to the next date-token. + */ + if (dayOfMonth === null) { + // "day-of-month = 1*2DIGIT ( non-digit *OCTET )" + result = parseDigits(token, 1, 2, true) + if (result !== null) { + dayOfMonth = result + continue + } + } + + /* 2.3. If the found-month flag is not set and the date-token matches the + * month production, set the found-month flag and set the month-value to + * the month denoted by the date-token. Skip the remaining sub-steps and + * continue to the next date-token. + */ + if (month === null) { + result = parseMonth(token) + if (result !== null) { + month = result + continue + } + } + + /* 2.4. If the found-year flag is not set and the date-token matches the + * year production, set the found-year flag and set the year-value to the + * number denoted by the date-token. Skip the remaining sub-steps and + * continue to the next date-token. + */ + if (year === null) { + // "year = 2*4DIGIT ( non-digit *OCTET )" + result = parseDigits(token, 2, 4, true) + if (result !== null) { + year = result + /* From S5.1.1: + * 3. If the year-value is greater than or equal to 70 and less + * than or equal to 99, increment the year-value by 1900. + * 4. If the year-value is greater than or equal to 0 and less + * than or equal to 69, increment the year-value by 2000. + */ + if (year >= 70 && year <= 99) { + year += 1900 + } else if (year >= 0 && year <= 69) { + year += 2000 + } + } + } + } + + /* RFC 6265 S5.1.1 + * "5. Abort these steps and fail to parse the cookie-date if: + * * at least one of the found-day-of-month, found-month, found- + * year, or found-time flags is not set, + * * the day-of-month-value is less than 1 or greater than 31, + * * the year-value is less than 1601, + * * the hour-value is greater than 23, + * * the minute-value is greater than 59, or + * * the second-value is greater than 59. + * (Note that leap seconds cannot be represented in this syntax.)" + * + * So, in order as above: + */ + if ( + dayOfMonth === null || + month == null || + year == null || + hour == null || + minute == null || + second == null || + dayOfMonth < 1 || + dayOfMonth > 31 || + year < 1601 || + hour > 23 || + minute > 59 || + second > 59 + ) { + return undefined + } + + return new Date(Date.UTC(year, month, dayOfMonth, hour, minute, second)) +} diff --git a/lib/cookie/permutePath.ts b/lib/cookie/permutePath.ts new file mode 100644 index 00000000..e8ccbf34 --- /dev/null +++ b/lib/cookie/permutePath.ts @@ -0,0 +1,23 @@ +import * as validators from '../validators' + +/** + * Gives the permutation of all possible `pathMatch`es of a given path. The + * array is in longest-to-shortest order. Handy for indexing. + */ +export function permutePath(path: string): string[] { + validators.validate(validators.isString(path)) + if (path === '/') { + return ['/'] + } + const permutations = [path] + while (path.length > 1) { + const lindex = path.lastIndexOf('/') + if (lindex === 0) { + break + } + path = path.slice(0, lindex) + permutations.push(path) + } + permutations.push('/') + return permutations +} diff --git a/lib/memstore.ts b/lib/memstore.ts index 05d959e0..2327c7ef 100644 --- a/lib/memstore.ts +++ b/lib/memstore.ts @@ -29,15 +29,12 @@ * POSSIBILITY OF SUCH DAMAGE. */ 'use strict' -import { - Callback, - Cookie, - createPromiseCallback, - pathMatch, - permuteDomain, -} from './cookie' +import type { Cookie } from './cookie/cookie' +import { pathMatch } from './pathMatch' +import { permuteDomain } from './permuteDomain' import { Store } from './store' import { getCustomInspectSymbol, getUtilInspect } from './utilHelper' +import { type Callback, createPromiseCallback, inOperator } from './utils' export type MemoryCookieStoreIndex = { [domain: string]: { @@ -444,10 +441,3 @@ function formatPath(pathName: string, pathValue: unknown) { return String(pathValue) } - -function inOperator( - k: K, - o: T, -): o is T & Record { - return k in o -} diff --git a/lib/store.ts b/lib/store.ts index 1f082a24..ed5d6b73 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -35,7 +35,8 @@ 'use strict' -import type { Callback, Cookie } from './cookie' +import type { Cookie } from './cookie/cookie' +import type { Callback } from './utils' export class Store { synchronous: boolean diff --git a/lib/utils.ts b/lib/utils.ts index c0825923..8834b140 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,3 +1,9 @@ +/** A callback function that accepts an error or a result. */ +export type Callback = ( + error: Error | undefined, + result: T | undefined, +) => void + /** Signature for a callback function that expects an error to be passed. */ export type ErrorCallback = (error: Error, result?: never) => void @@ -15,3 +21,62 @@ export const safeToString = (val: unknown) => { return objectToString(val) } } + +/** Utility object for promise/callback interop. */ +export interface PromiseCallback { + promise: Promise + callback: (error: Error | undefined | null, result?: T) => void + resolve: (value: T | undefined) => Promise + reject: (error: Error | undefined | null) => Promise +} + +/** Converts a callback into a utility object where either a callback or a promise can be used. */ +export function createPromiseCallback(args: IArguments): PromiseCallback { + let callback: (error: Error | null | undefined, result: T | undefined) => void + let resolve: (result: T | undefined) => void + let reject: (error: Error | null) => void + + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve + reject = _reject + }) + + const cb: unknown = args[args.length - 1] + if (typeof cb === 'function') { + callback = (err, result) => { + try { + cb(err, result) + } catch (e) { + reject(e instanceof Error ? e : new Error()) + } + } + } else { + callback = (err, result) => { + try { + err ? reject(err) : resolve(result) + } catch (e) { + reject(e instanceof Error ? e : new Error()) + } + } + } + + return { + promise, + callback, + resolve: (value: T | undefined) => { + callback(null, value) + return promise + }, + reject: (error: Error | null | undefined) => { + callback(error, undefined) + return promise + }, + } +} + +export function inOperator( + k: K, + o: T, +): o is T & Record { + return k in o +}