@resq-systems/security 2.1.0 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +1 -1
  2. package/lib/controls/address.d.mts +8 -8
  3. package/lib/controls/address.d.mts.map +1 -1
  4. package/lib/controls/address.mjs.map +1 -1
  5. package/lib/controls/csrf.d.mts +7 -7
  6. package/lib/controls/csrf.d.mts.map +1 -1
  7. package/lib/controls/csrf.mjs +5 -2
  8. package/lib/controls/csrf.mjs.map +1 -1
  9. package/lib/controls/origin.d.mts +6 -6
  10. package/lib/controls/origin.d.mts.map +1 -1
  11. package/lib/controls/origin.mjs +1 -0
  12. package/lib/controls/origin.mjs.map +1 -1
  13. package/lib/controls/payload.d.mts +4 -4
  14. package/lib/controls/payload.d.mts.map +1 -1
  15. package/lib/controls/payload.mjs.map +1 -1
  16. package/lib/controls/query.d.mts +8 -8
  17. package/lib/controls/query.d.mts.map +1 -1
  18. package/lib/controls/query.mjs +1 -0
  19. package/lib/controls/query.mjs.map +1 -1
  20. package/lib/controls/redirect.d.mts +5 -5
  21. package/lib/controls/redirect.d.mts.map +1 -1
  22. package/lib/controls/redirect.mjs.map +1 -1
  23. package/lib/controls/upload.d.mts +7 -7
  24. package/lib/controls/upload.d.mts.map +1 -1
  25. package/lib/controls/upload.mjs.map +1 -1
  26. package/lib/crypto.d.mts +20 -21
  27. package/lib/crypto.d.mts.map +1 -1
  28. package/lib/crypto.mjs +3 -1
  29. package/lib/crypto.mjs.map +1 -1
  30. package/lib/hash.d.mts +5 -5
  31. package/lib/hash.d.mts.map +1 -1
  32. package/lib/hash.mjs +1 -0
  33. package/lib/hash.mjs.map +1 -1
  34. package/lib/paths.d.mts +5 -5
  35. package/lib/paths.d.mts.map +1 -1
  36. package/lib/paths.mjs +1 -0
  37. package/lib/paths.mjs.map +1 -1
  38. package/lib/sanitize.d.mts +36 -37
  39. package/lib/sanitize.d.mts.map +1 -1
  40. package/lib/sanitize.mjs.map +1 -1
  41. package/lib/threats/capec.generated.d.mts +4 -4
  42. package/lib/threats/capec.generated.d.mts.map +1 -1
  43. package/lib/threats/capec.generated.mjs.map +1 -1
  44. package/lib/threats/engine.d.mts +4 -5
  45. package/lib/threats/engine.d.mts.map +1 -1
  46. package/lib/threats/engine.mjs +1 -0
  47. package/lib/threats/engine.mjs.map +1 -1
  48. package/lib/threats/rules/datastore.d.mts +4 -5
  49. package/lib/threats/rules/datastore.d.mts.map +1 -1
  50. package/lib/threats/rules/datastore.mjs.map +1 -1
  51. package/lib/threats/rules/index.d.mts +5 -5
  52. package/lib/threats/rules/index.d.mts.map +1 -1
  53. package/lib/threats/rules/index.mjs.map +1 -1
  54. package/lib/threats/rules/markup.d.mts +4 -5
  55. package/lib/threats/rules/markup.d.mts.map +1 -1
  56. package/lib/threats/rules/markup.mjs +2 -1
  57. package/lib/threats/rules/markup.mjs.map +1 -1
  58. package/lib/threats/rules/protocol.d.mts +4 -5
  59. package/lib/threats/rules/protocol.d.mts.map +1 -1
  60. package/lib/threats/rules/protocol.mjs.map +1 -1
  61. package/lib/threats/rules/system.d.mts +4 -5
  62. package/lib/threats/rules/system.d.mts.map +1 -1
  63. package/lib/threats/rules/system.mjs +4 -4
  64. package/lib/threats/rules/system.mjs.map +1 -1
  65. package/lib/threats/rules/web.d.mts +5 -6
  66. package/lib/threats/rules/web.d.mts.map +1 -1
  67. package/lib/threats/rules/web.mjs +1 -1
  68. package/lib/threats/rules/web.mjs.map +1 -1
  69. package/lib/threats/scoring.d.mts +5 -6
  70. package/lib/threats/scoring.d.mts.map +1 -1
  71. package/lib/threats/scoring.mjs +1 -0
  72. package/lib/threats/scoring.mjs.map +1 -1
  73. package/lib/threats/types.d.mts +18 -18
  74. package/lib/threats/types.d.mts.map +1 -1
  75. package/lib/threats/types.mjs.map +1 -1
  76. package/lib/threats/variants.d.mts +3 -4
  77. package/lib/threats/variants.d.mts.map +1 -1
  78. package/lib/threats/variants.mjs.map +1 -1
  79. package/lib/unicode/confusables.d.mts +5 -5
  80. package/lib/unicode/confusables.d.mts.map +1 -1
  81. package/lib/unicode/confusables.mjs +1 -0
  82. package/lib/unicode/confusables.mjs.map +1 -1
  83. package/lib/unicode/index.d.mts +11 -11
  84. package/lib/unicode/index.d.mts.map +1 -1
  85. package/lib/unicode/index.mjs +1 -0
  86. package/lib/unicode/index.mjs.map +1 -1
  87. package/lib/validators.d.mts +89 -39
  88. package/lib/validators.d.mts.map +1 -1
  89. package/lib/validators.mjs +285 -21
  90. package/lib/validators.mjs.map +1 -1
  91. package/package.json +7 -7
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
  # @resq-systems/security
18
18
 
19
19
  [![npm](https://img.shields.io/npm/v/%40resq-systems%2Fsecurity?style=flat-square)](https://www.npmjs.com/package/@resq-systems/security)
20
- [![License](https://img.shields.io/badge/license-Apache--2.0-blue?style=flat-square)](../../LICENSE.md)
20
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue?style=flat-square)](../../LICENSE)
21
21
 
22
22
  > Encryption, threat detection, input validation, PII sanitization, and Effect Schema validators.
23
23
 
@@ -1,6 +1,7 @@
1
1
  //#region src/controls/address.d.ts
2
2
  /**
3
3
  * Copyright 2026 ResQ Systems, Inc.
4
+ * SPDX-License-Identifier: Apache-2.0
4
5
  *
5
6
  * Licensed under the Apache License, Version 2.0 (the "License");
6
7
  * you may not use this file except in compliance with the License.
@@ -30,9 +31,9 @@
30
31
  * `public` means "in no special-purpose range" — routable on the internet. Every other
31
32
  * value is a reason not to fetch it from a server.
32
33
  */
33
- type AddressClassification = "unspecified" | "loopback" | "private" | "link_local" | "carrier_nat" | "multicast" | "broadcast" | "documentation" | "benchmarking" | "unique_local" | "teredo" | "six_to_four" | "nat64" | "reserved" | "public";
34
+ export type AddressClassification = "unspecified" | "loopback" | "private" | "link_local" | "carrier_nat" | "multicast" | "broadcast" | "documentation" | "benchmarking" | "unique_local" | "teredo" | "six_to_four" | "nat64" | "reserved" | "public";
34
35
  /** Why an outbound URL was refused. */
35
- type OutboundRejectionReason =
36
+ export type OutboundRejectionReason =
36
37
  /** Not parseable as a URL. */
37
38
  "malformed" |
38
39
  /** Scheme outside the permitted set. */
@@ -44,7 +45,7 @@ type OutboundRejectionReason =
44
45
  /** A routable address, or a name, that policy does not permit. */
45
46
  "host_not_allowed";
46
47
  /** Outcome of {@link assertOutboundUrl}. */
47
- type OutboundUrlVerdict = {
48
+ export type OutboundUrlVerdict = {
48
49
  readonly allowed: true;
49
50
  readonly url: URL;
50
51
  /** `null` when the host is a name rather than an IP literal. */
@@ -54,7 +55,7 @@ type OutboundUrlVerdict = {
54
55
  readonly reason: OutboundRejectionReason;
55
56
  };
56
57
  /** Policy for {@link assertOutboundUrl}. */
57
- interface OutboundUrlPolicy {
58
+ export interface OutboundUrlPolicy {
58
59
  /**
59
60
  * Hosts permitted regardless of classification, compared case-insensitively against
60
61
  * the parsed host. The allowlist the OWASP cheat sheet asks for.
@@ -98,7 +99,7 @@ interface OutboundUrlPolicy {
98
99
  * classifyAddress("metadata.example.com"); // null — a name, not an address
99
100
  * ```
100
101
  */
101
- declare function classifyAddress(host: string): AddressClassification | null;
102
+ export declare function classifyAddress(host: string): AddressClassification | null;
102
103
  /**
103
104
  * Whether a host is an IP literal in a publicly routable range.
104
105
  *
@@ -106,7 +107,7 @@ declare function classifyAddress(host: string): AddressClassification | null;
106
107
  * @returns `true` only for a routable IP literal. A domain name returns `false`, because
107
108
  * this function cannot know what it resolves to.
108
109
  */
109
- declare function isPubliclyRoutableAddress(host: string): boolean;
110
+ export declare function isPubliclyRoutableAddress(host: string): boolean;
110
111
  /**
111
112
  * Decide whether a server may fetch a caller-supplied URL.
112
113
  *
@@ -136,7 +137,6 @@ declare function isPubliclyRoutableAddress(host: string): boolean;
136
137
  * await fetch(verdict.url);
137
138
  * ```
138
139
  */
139
- declare function assertOutboundUrl(candidate: string | URL, policy?: OutboundUrlPolicy): OutboundUrlVerdict;
140
+ export declare function assertOutboundUrl(candidate: string | URL, policy?: OutboundUrlPolicy): OutboundUrlVerdict;
140
141
  //#endregion
141
- export { AddressClassification, OutboundRejectionReason, OutboundUrlPolicy, OutboundUrlVerdict, assertOutboundUrl, classifyAddress, isPubliclyRoutableAddress };
142
142
  //# sourceMappingURL=address.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"address.d.mts","names":[],"sources":["../../src/controls/address.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAmCY;;KAkBA;;;;;;;;;;;;KAaA;WAEA;WACA,KAAK;;WAEL,gBAAgB;;WAEd;WAAyB,QAAQ;;;UAG9B;;;;;WAKP;;WAEA;;WAEA;;;;;;;;;;WAUA;;;;;;;;;;;;;;;;;;;;;;;;;iBAiLM,gBAAgB,eAAe;;;;;;;;iBA4B/B,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAwC1B,kBACf,oBAAoB,KACpB,SAAQ,oBACN"}
1
+ {"version":3,"file":"address.d.mts","names":[],"sources":["../../src/controls/address.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;YAoCY;;YAkBA;;;;;;;;;;;;YAaA;WAEA;WACA,KAAK;;WAEL,gBAAgB;;WAEd;WAAyB,QAAQ;;;iBAG9B;;;;;WAKP;;WAEA;;WAEA;;;;;;;;;;WAUA;;;;;;;;;;;;;;;;;;;;;;;;;wBAiLM,gBAAgB,eAAe;;;;;;;;wBA4B/B,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBAwC1B,kBACf,oBAAoB,KACpB,SAAQ,oBACN"}
@@ -1 +1 @@
1
- {"version":3,"file":"address.mjs","names":[],"sources":["../../src/controls/address.ts"],"sourcesContent":["/**\n * Copyright 2026 ResQ Systems, Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * @fileoverview IP address classification and outbound-URL policy — the SSRF control\n * the detection rules name but cannot themselves provide.\n *\n * The signatures in `threats/rules/system.ts` match literal addresses. A hostname whose\n * DNS record resolves to `169.254.169.254` passes every one of them, which is why those\n * rules point here instead.\n *\n * @module @resq-systems/security/controls/address\n */\n\n//#region Types\n\n/**\n * What an address is reserved for, per the IANA special-purpose registries.\n *\n * `public` means \"in no special-purpose range\" — routable on the internet. Every other\n * value is a reason not to fetch it from a server.\n */\nexport type AddressClassification =\n\t| \"unspecified\"\n\t| \"loopback\"\n\t| \"private\"\n\t| \"link_local\"\n\t| \"carrier_nat\"\n\t| \"multicast\"\n\t| \"broadcast\"\n\t| \"documentation\"\n\t| \"benchmarking\"\n\t| \"unique_local\"\n\t| \"teredo\"\n\t| \"six_to_four\"\n\t| \"nat64\"\n\t| \"reserved\"\n\t| \"public\";\n\n/** Why an outbound URL was refused. */\nexport type OutboundRejectionReason =\n\t/** Not parseable as a URL. */\n\t| \"malformed\"\n\t/** Scheme outside the permitted set. */\n\t| \"protocol_not_allowed\"\n\t/** Port outside the permitted set. */\n\t| \"port_not_allowed\"\n\t/** An IP literal in a range that is not publicly routable. */\n\t| \"address_not_routable\"\n\t/** A routable address, or a name, that policy does not permit. */\n\t| \"host_not_allowed\";\n\n/** Outcome of {@link assertOutboundUrl}. */\nexport type OutboundUrlVerdict =\n\t| {\n\t\t\treadonly allowed: true;\n\t\t\treadonly url: URL;\n\t\t\t/** `null` when the host is a name rather than an IP literal. */\n\t\t\treadonly classification: AddressClassification | null;\n\t }\n\t| { readonly allowed: false; readonly reason: OutboundRejectionReason };\n\n/** Policy for {@link assertOutboundUrl}. */\nexport interface OutboundUrlPolicy {\n\t/**\n\t * Hosts permitted regardless of classification, compared case-insensitively against\n\t * the parsed host. The allowlist the OWASP cheat sheet asks for.\n\t */\n\treadonly allowedHosts?: readonly string[];\n\t/** Schemes permitted. Defaults to `[\"https:\"]`. */\n\treadonly allowedProtocols?: readonly string[];\n\t/** Ports permitted in addition to the scheme's default. Defaults to none. */\n\treadonly allowedPorts?: readonly number[];\n\t/**\n\t * Permit any host not in a reserved range — every public address, and every name.\n\t *\n\t * Defaults to `false`, and that default is the point. A name is not an address: this\n\t * function cannot know whether `metadata.example.com` resolves to a public address or\n\t * to `169.254.169.254`. With no allowlist and this flag off, a name is refused.\n\t * Turning it on converts the control from an allowlist into a denylist over literals\n\t * only, which does not stop DNS from pointing inward.\n\t */\n\treadonly allowPublicHosts?: boolean;\n}\n\n//#endregion\n\n//#region Parsing\n\n/** Four octets, or `null` when the text is not a dotted-quad IPv4 address. */\nfunction parseIpv4(host: string): readonly number[] | null {\n\tconst parts = host.split(\".\");\n\tif (parts.length !== 4) return null;\n\n\tconst octets: number[] = [];\n\tfor (const part of parts) {\n\t\t// Rejects empty, signed, hex, octal and over-long forms. `new URL` already\n\t\t// canonicalises those for a parsed hostname; this matters for a caller handing a\n\t\t// raw string straight to `classifyAddress`.\n\t\tif (part.length === 0 || part.length > 3 || !/^\\d+$/.test(part)) return null;\n\t\tconst value = Number(part);\n\t\tif (value > 255) return null;\n\t\toctets.push(value);\n\t}\n\treturn octets;\n}\n\n/**\n * Eight 16-bit groups, or `null` when the text is not an IPv6 address.\n *\n * Accepts the bracketed, zero-compressed form `URL.hostname` produces, and the\n * dotted-quad tail of an IPv4-mapped address.\n */\nfunction parseIpv6(host: string): readonly number[] | null {\n\tlet text = host;\n\tif (text.startsWith(\"[\") && text.endsWith(\"]\")) text = text.slice(1, -1);\n\n\t// A zone identifier is not part of the address.\n\tconst zone = text.indexOf(\"%\");\n\tif (zone !== -1) text = text.slice(0, zone);\n\n\tif (!text.includes(\":\")) return null;\n\n\t// A trailing dotted quad contributes the final two groups.\n\tlet tail: number[] = [];\n\tconst lastColon = text.lastIndexOf(\":\");\n\tconst suffix = text.slice(lastColon + 1);\n\tif (suffix.includes(\".\")) {\n\t\tconst octets = parseIpv4(suffix);\n\t\tif (octets === null) return null;\n\t\ttail = [\n\t\t\t((octets[0] as number) << 8) | (octets[1] as number),\n\t\t\t((octets[2] as number) << 8) | (octets[3] as number),\n\t\t];\n\t\ttext = text.slice(0, lastColon);\n\t\tif (!text.endsWith(\":\")) text += \":\";\n\t\ttext = text.slice(0, -1);\n\t\tif (text.length === 0) text = \"::\";\n\t}\n\n\tconst halves = text.split(\"::\");\n\tif (halves.length > 2) return null;\n\n\tconst toGroups = (part: string): number[] | null => {\n\t\tif (part.length === 0) return [];\n\t\tconst groups: number[] = [];\n\t\tfor (const piece of part.split(\":\")) {\n\t\t\tif (piece.length === 0 || piece.length > 4 || !/^[0-9a-f]+$/i.test(piece)) return null;\n\t\t\tgroups.push(Number.parseInt(piece, 16));\n\t\t}\n\t\treturn groups;\n\t};\n\n\tconst head = toGroups(halves[0] ?? \"\");\n\tif (head === null) return null;\n\n\tif (halves.length === 1) {\n\t\tconst all = [...head, ...tail];\n\t\treturn all.length === 8 ? all : null;\n\t}\n\n\tconst rest = toGroups(halves[1] ?? \"\");\n\tif (rest === null) return null;\n\n\tconst known = head.length + rest.length + tail.length;\n\tif (known > 8) return null;\n\treturn [...head, ...(new Array(8 - known).fill(0) as number[]), ...rest, ...tail];\n}\n\n//#endregion\n\n//#region Classification\n\n/** IPv4 special-purpose ranges, as `[network, prefixLength, classification]`. */\nconst IPV4_RANGES: readonly (readonly [readonly number[], number, AddressClassification])[] = [\n\t[[0, 0, 0, 0], 8, \"unspecified\"],\n\t[[10, 0, 0, 0], 8, \"private\"],\n\t[[100, 64, 0, 0], 10, \"carrier_nat\"],\n\t[[127, 0, 0, 0], 8, \"loopback\"],\n\t[[169, 254, 0, 0], 16, \"link_local\"],\n\t[[172, 16, 0, 0], 12, \"private\"],\n\t[[192, 0, 2, 0], 24, \"documentation\"],\n\t[[192, 88, 99, 0], 24, \"reserved\"],\n\t[[192, 0, 0, 0], 24, \"reserved\"],\n\t[[192, 168, 0, 0], 16, \"private\"],\n\t[[198, 18, 0, 0], 15, \"benchmarking\"],\n\t[[198, 51, 100, 0], 24, \"documentation\"],\n\t[[203, 0, 113, 0], 24, \"documentation\"],\n\t[[224, 0, 0, 0], 4, \"multicast\"],\n\t[[255, 255, 255, 255], 32, \"broadcast\"],\n\t[[240, 0, 0, 0], 4, \"reserved\"],\n];\n\n/** IPv6 special-purpose ranges, as `[groups, prefixLength, classification]`. */\nconst IPV6_RANGES: readonly (readonly [readonly number[], number, AddressClassification])[] = [\n\t[[0, 0, 0, 0, 0, 0, 0, 1], 128, \"loopback\"],\n\t[[0, 0, 0, 0, 0, 0, 0, 0], 128, \"unspecified\"],\n\t[[0x64, 0xff9b, 0, 0, 0, 0, 0, 0], 96, \"nat64\"],\n\t[[0x100, 0, 0, 0, 0, 0, 0, 0], 64, \"reserved\"],\n\t[[0x2001, 0x0db8, 0, 0, 0, 0, 0, 0], 32, \"documentation\"],\n\t// Teredo. Absent from the first draft of this table, which classified it public.\n\t[[0x2001, 0, 0, 0, 0, 0, 0, 0], 32, \"teredo\"],\n\t[[0x2002, 0, 0, 0, 0, 0, 0, 0], 16, \"six_to_four\"],\n\t[[0xfc00, 0, 0, 0, 0, 0, 0, 0], 7, \"unique_local\"],\n\t[[0xfe80, 0, 0, 0, 0, 0, 0, 0], 10, \"link_local\"],\n\t[[0xff00, 0, 0, 0, 0, 0, 0, 0], 8, \"multicast\"],\n];\n\n/** Whether `parts` sits inside `network/prefix`, given `bits` per part. */\nfunction withinPrefix(\n\tparts: readonly number[],\n\tnetwork: readonly number[],\n\tprefix: number,\n\tbits: number,\n): boolean {\n\tlet remaining = prefix;\n\tfor (let index = 0; index < parts.length && remaining > 0; index++) {\n\t\tconst width = Math.min(bits, remaining);\n\t\tconst shift = bits - width;\n\t\tif ((parts[index] as number) >>> shift !== (network[index] as number) >>> shift) return false;\n\t\tremaining -= width;\n\t}\n\treturn true;\n}\n\n/** IPv4-mapped IPv6 prefix, `::ffff:0:0/96`. */\nconst IPV4_MAPPED: readonly number[] = [0, 0, 0, 0, 0, 0xffff, 0, 0];\n\n/** Classify four octets against the IPv4 table. */\nfunction classifyIpv4(octets: readonly number[]): AddressClassification {\n\tfor (const [network, prefix, classification] of IPV4_RANGES) {\n\t\tif (withinPrefix(octets, network, prefix, 8)) return classification;\n\t}\n\treturn \"public\";\n}\n\n/**\n * Classify a host as an IP address range, or `null` when it is not an IP literal.\n *\n * `null` is the answer for every domain name, and a caller must treat it as *unknown*\n * rather than safe — conflating the two is the classic fail-open in this kind of check.\n * {@link assertOutboundUrl} handles it explicitly.\n *\n * IPv4-mapped IPv6 addresses are unwrapped and classified by the address they carry, so\n * `::ffff:169.254.169.254` is `link_local` rather than merely \"some IPv6 address\". That\n * form matters in practice: `new URL(\"http://[::ffff:169.254.169.254]/\").hostname`\n * returns the bracketed, hex-compressed `[::ffff:a9fe:a9fe]`, which a string check misses.\n *\n * @param host - Hostname or IP literal, with or without IPv6 brackets.\n * @returns The classification, or `null` when `host` is not an IP literal.\n *\n * @example\n * ```ts\n * classifyAddress(\"169.254.169.254\"); // \"link_local\"\n * classifyAddress(\"172.32.0.1\"); // \"public\" — just outside 172.16/12\n * classifyAddress(\"[::ffff:a9fe:a9fe]\"); // \"link_local\"\n * classifyAddress(\"metadata.example.com\"); // null — a name, not an address\n * ```\n */\nexport function classifyAddress(host: string): AddressClassification | null {\n\tif (typeof host !== \"string\" || host.length === 0) return null;\n\n\tconst octets = parseIpv4(host);\n\tif (octets !== null) return classifyIpv4(octets);\n\n\tconst groups = parseIpv6(host);\n\tif (groups === null) return null;\n\n\tif (withinPrefix(groups, IPV4_MAPPED, 96, 16)) {\n\t\tconst high = groups[6] as number;\n\t\tconst low = groups[7] as number;\n\t\treturn classifyIpv4([high >>> 8, high & 0xff, low >>> 8, low & 0xff]);\n\t}\n\n\tfor (const [network, prefix, classification] of IPV6_RANGES) {\n\t\tif (withinPrefix(groups, network, prefix, 16)) return classification;\n\t}\n\treturn \"public\";\n}\n\n/**\n * Whether a host is an IP literal in a publicly routable range.\n *\n * @param host - Hostname or IP literal.\n * @returns `true` only for a routable IP literal. A domain name returns `false`, because\n * this function cannot know what it resolves to.\n */\nexport function isPubliclyRoutableAddress(host: string): boolean {\n\treturn classifyAddress(host) === \"public\";\n}\n\n//#endregion\n\n//#region Outbound policy\n\n/** Default schemes. `https:` only — a server fetching over `http:` is its own problem. */\nconst DEFAULT_PROTOCOLS: readonly string[] = [\"https:\"];\n\n/**\n * Decide whether a server may fetch a caller-supplied URL.\n *\n * The control the SSRF rules name. Those rules match literal addresses inside a string;\n * this decides whether the request should be made at all.\n *\n * **Default deny, exhaustively.** Every path ends in an explicit allow or an explicit\n * refusal. That is deliberate: `classifyAddress` returns `null` for every domain name, so\n * a policy shaped \"reject non-public *literals*\" silently permits every name — the exact\n * fail-open this control exists to prevent. With no `allowedHosts` and `allowPublicHosts`\n * off, a name is refused.\n *\n * **A pre-connection check, and it cannot be more.** The name is resolved by the network\n * stack after this returns, so DNS may answer differently then (rebinding); redirects\n * need the same check applied per hop; neither is closable by a synchronous function.\n * Network-layer egress control remains the durable fix — this narrows the window rather\n * than shutting it.\n *\n * @param candidate - The URL to fetch, as text or a parsed `URL`.\n * @param policy - See {@link OutboundUrlPolicy}. Defaults refuse everything not named.\n * @returns A discriminated verdict. Never throws.\n *\n * @example\n * ```ts\n * const verdict = assertOutboundUrl(webhookUrl, { allowedHosts: [\"hooks.partner.example\"] });\n * if (!verdict.allowed) return reject(verdict.reason);\n * await fetch(verdict.url);\n * ```\n */\nexport function assertOutboundUrl(\n\tcandidate: string | URL,\n\tpolicy: OutboundUrlPolicy = {},\n): OutboundUrlVerdict {\n\tlet url: URL;\n\ttry {\n\t\turl = candidate instanceof URL ? candidate : new URL(String(candidate));\n\t} catch {\n\t\treturn { allowed: false, reason: \"malformed\" };\n\t}\n\n\tconst protocols = policy.allowedProtocols ?? DEFAULT_PROTOCOLS;\n\tif (!protocols.includes(url.protocol)) {\n\t\treturn { allowed: false, reason: \"protocol_not_allowed\" };\n\t}\n\n\t// An empty `port` means the scheme default, which is always acceptable.\n\tif (url.port !== \"\") {\n\t\tconst port = Number(url.port);\n\t\tif (!(policy.allowedPorts ?? []).includes(port)) {\n\t\t\treturn { allowed: false, reason: \"port_not_allowed\" };\n\t\t}\n\t}\n\n\tconst host = url.hostname.toLowerCase();\n\tconst named = (policy.allowedHosts ?? []).some(\n\t\t(allowed) => allowed.trim().toLowerCase() === host,\n\t);\n\tconst classification = classifyAddress(host);\n\n\t// An IP literal is judged on its range first: naming a loopback address in an\n\t// allowlist should not turn it into a route back into the host.\n\tif (classification !== null) {\n\t\tif (classification !== \"public\") return { allowed: false, reason: \"address_not_routable\" };\n\t\treturn named || policy.allowPublicHosts === true\n\t\t\t? { allowed: true, url, classification }\n\t\t\t: { allowed: false, reason: \"host_not_allowed\" };\n\t}\n\n\t// A name. Nothing available here can tell what it resolves to.\n\treturn named || policy.allowPublicHosts === true\n\t\t? { allowed: true, url, classification: null }\n\t\t: { allowed: false, reason: \"host_not_allowed\" };\n}\n\n//#endregion\n"],"mappings":"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"}
1
+ {"version":3,"file":"address.mjs","names":[],"sources":["../../src/controls/address.ts"],"sourcesContent":["/**\n * Copyright 2026 ResQ Systems, Inc.\n * SPDX-License-Identifier: Apache-2.0\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * @fileoverview IP address classification and outbound-URL policy — the SSRF control\n * the detection rules name but cannot themselves provide.\n *\n * The signatures in `threats/rules/system.ts` match literal addresses. A hostname whose\n * DNS record resolves to `169.254.169.254` passes every one of them, which is why those\n * rules point here instead.\n *\n * @module @resq-systems/security/controls/address\n */\n\n//#region Types\n\n/**\n * What an address is reserved for, per the IANA special-purpose registries.\n *\n * `public` means \"in no special-purpose range\" — routable on the internet. Every other\n * value is a reason not to fetch it from a server.\n */\nexport type AddressClassification =\n\t| \"unspecified\"\n\t| \"loopback\"\n\t| \"private\"\n\t| \"link_local\"\n\t| \"carrier_nat\"\n\t| \"multicast\"\n\t| \"broadcast\"\n\t| \"documentation\"\n\t| \"benchmarking\"\n\t| \"unique_local\"\n\t| \"teredo\"\n\t| \"six_to_four\"\n\t| \"nat64\"\n\t| \"reserved\"\n\t| \"public\";\n\n/** Why an outbound URL was refused. */\nexport type OutboundRejectionReason =\n\t/** Not parseable as a URL. */\n\t| \"malformed\"\n\t/** Scheme outside the permitted set. */\n\t| \"protocol_not_allowed\"\n\t/** Port outside the permitted set. */\n\t| \"port_not_allowed\"\n\t/** An IP literal in a range that is not publicly routable. */\n\t| \"address_not_routable\"\n\t/** A routable address, or a name, that policy does not permit. */\n\t| \"host_not_allowed\";\n\n/** Outcome of {@link assertOutboundUrl}. */\nexport type OutboundUrlVerdict =\n\t| {\n\t\t\treadonly allowed: true;\n\t\t\treadonly url: URL;\n\t\t\t/** `null` when the host is a name rather than an IP literal. */\n\t\t\treadonly classification: AddressClassification | null;\n\t }\n\t| { readonly allowed: false; readonly reason: OutboundRejectionReason };\n\n/** Policy for {@link assertOutboundUrl}. */\nexport interface OutboundUrlPolicy {\n\t/**\n\t * Hosts permitted regardless of classification, compared case-insensitively against\n\t * the parsed host. The allowlist the OWASP cheat sheet asks for.\n\t */\n\treadonly allowedHosts?: readonly string[];\n\t/** Schemes permitted. Defaults to `[\"https:\"]`. */\n\treadonly allowedProtocols?: readonly string[];\n\t/** Ports permitted in addition to the scheme's default. Defaults to none. */\n\treadonly allowedPorts?: readonly number[];\n\t/**\n\t * Permit any host not in a reserved range — every public address, and every name.\n\t *\n\t * Defaults to `false`, and that default is the point. A name is not an address: this\n\t * function cannot know whether `metadata.example.com` resolves to a public address or\n\t * to `169.254.169.254`. With no allowlist and this flag off, a name is refused.\n\t * Turning it on converts the control from an allowlist into a denylist over literals\n\t * only, which does not stop DNS from pointing inward.\n\t */\n\treadonly allowPublicHosts?: boolean;\n}\n\n//#endregion\n\n//#region Parsing\n\n/** Four octets, or `null` when the text is not a dotted-quad IPv4 address. */\nfunction parseIpv4(host: string): readonly number[] | null {\n\tconst parts = host.split(\".\");\n\tif (parts.length !== 4) return null;\n\n\tconst octets: number[] = [];\n\tfor (const part of parts) {\n\t\t// Rejects empty, signed, hex, octal and over-long forms. `new URL` already\n\t\t// canonicalises those for a parsed hostname; this matters for a caller handing a\n\t\t// raw string straight to `classifyAddress`.\n\t\tif (part.length === 0 || part.length > 3 || !/^\\d+$/.test(part)) return null;\n\t\tconst value = Number(part);\n\t\tif (value > 255) return null;\n\t\toctets.push(value);\n\t}\n\treturn octets;\n}\n\n/**\n * Eight 16-bit groups, or `null` when the text is not an IPv6 address.\n *\n * Accepts the bracketed, zero-compressed form `URL.hostname` produces, and the\n * dotted-quad tail of an IPv4-mapped address.\n */\nfunction parseIpv6(host: string): readonly number[] | null {\n\tlet text = host;\n\tif (text.startsWith(\"[\") && text.endsWith(\"]\")) text = text.slice(1, -1);\n\n\t// A zone identifier is not part of the address.\n\tconst zone = text.indexOf(\"%\");\n\tif (zone !== -1) text = text.slice(0, zone);\n\n\tif (!text.includes(\":\")) return null;\n\n\t// A trailing dotted quad contributes the final two groups.\n\tlet tail: number[] = [];\n\tconst lastColon = text.lastIndexOf(\":\");\n\tconst suffix = text.slice(lastColon + 1);\n\tif (suffix.includes(\".\")) {\n\t\tconst octets = parseIpv4(suffix);\n\t\tif (octets === null) return null;\n\t\ttail = [\n\t\t\t((octets[0] as number) << 8) | (octets[1] as number),\n\t\t\t((octets[2] as number) << 8) | (octets[3] as number),\n\t\t];\n\t\ttext = text.slice(0, lastColon);\n\t\tif (!text.endsWith(\":\")) text += \":\";\n\t\ttext = text.slice(0, -1);\n\t\tif (text.length === 0) text = \"::\";\n\t}\n\n\tconst halves = text.split(\"::\");\n\tif (halves.length > 2) return null;\n\n\tconst toGroups = (part: string): number[] | null => {\n\t\tif (part.length === 0) return [];\n\t\tconst groups: number[] = [];\n\t\tfor (const piece of part.split(\":\")) {\n\t\t\tif (piece.length === 0 || piece.length > 4 || !/^[0-9a-f]+$/i.test(piece)) return null;\n\t\t\tgroups.push(Number.parseInt(piece, 16));\n\t\t}\n\t\treturn groups;\n\t};\n\n\tconst head = toGroups(halves[0] ?? \"\");\n\tif (head === null) return null;\n\n\tif (halves.length === 1) {\n\t\tconst all = [...head, ...tail];\n\t\treturn all.length === 8 ? all : null;\n\t}\n\n\tconst rest = toGroups(halves[1] ?? \"\");\n\tif (rest === null) return null;\n\n\tconst known = head.length + rest.length + tail.length;\n\tif (known > 8) return null;\n\treturn [...head, ...(new Array(8 - known).fill(0) as number[]), ...rest, ...tail];\n}\n\n//#endregion\n\n//#region Classification\n\n/** IPv4 special-purpose ranges, as `[network, prefixLength, classification]`. */\nconst IPV4_RANGES: readonly (readonly [readonly number[], number, AddressClassification])[] = [\n\t[[0, 0, 0, 0], 8, \"unspecified\"],\n\t[[10, 0, 0, 0], 8, \"private\"],\n\t[[100, 64, 0, 0], 10, \"carrier_nat\"],\n\t[[127, 0, 0, 0], 8, \"loopback\"],\n\t[[169, 254, 0, 0], 16, \"link_local\"],\n\t[[172, 16, 0, 0], 12, \"private\"],\n\t[[192, 0, 2, 0], 24, \"documentation\"],\n\t[[192, 88, 99, 0], 24, \"reserved\"],\n\t[[192, 0, 0, 0], 24, \"reserved\"],\n\t[[192, 168, 0, 0], 16, \"private\"],\n\t[[198, 18, 0, 0], 15, \"benchmarking\"],\n\t[[198, 51, 100, 0], 24, \"documentation\"],\n\t[[203, 0, 113, 0], 24, \"documentation\"],\n\t[[224, 0, 0, 0], 4, \"multicast\"],\n\t[[255, 255, 255, 255], 32, \"broadcast\"],\n\t[[240, 0, 0, 0], 4, \"reserved\"],\n];\n\n/** IPv6 special-purpose ranges, as `[groups, prefixLength, classification]`. */\nconst IPV6_RANGES: readonly (readonly [readonly number[], number, AddressClassification])[] = [\n\t[[0, 0, 0, 0, 0, 0, 0, 1], 128, \"loopback\"],\n\t[[0, 0, 0, 0, 0, 0, 0, 0], 128, \"unspecified\"],\n\t[[0x64, 0xff9b, 0, 0, 0, 0, 0, 0], 96, \"nat64\"],\n\t[[0x100, 0, 0, 0, 0, 0, 0, 0], 64, \"reserved\"],\n\t[[0x2001, 0x0db8, 0, 0, 0, 0, 0, 0], 32, \"documentation\"],\n\t// Teredo. Absent from the first draft of this table, which classified it public.\n\t[[0x2001, 0, 0, 0, 0, 0, 0, 0], 32, \"teredo\"],\n\t[[0x2002, 0, 0, 0, 0, 0, 0, 0], 16, \"six_to_four\"],\n\t[[0xfc00, 0, 0, 0, 0, 0, 0, 0], 7, \"unique_local\"],\n\t[[0xfe80, 0, 0, 0, 0, 0, 0, 0], 10, \"link_local\"],\n\t[[0xff00, 0, 0, 0, 0, 0, 0, 0], 8, \"multicast\"],\n];\n\n/** Whether `parts` sits inside `network/prefix`, given `bits` per part. */\nfunction withinPrefix(\n\tparts: readonly number[],\n\tnetwork: readonly number[],\n\tprefix: number,\n\tbits: number,\n): boolean {\n\tlet remaining = prefix;\n\tfor (let index = 0; index < parts.length && remaining > 0; index++) {\n\t\tconst width = Math.min(bits, remaining);\n\t\tconst shift = bits - width;\n\t\tif ((parts[index] as number) >>> shift !== (network[index] as number) >>> shift) return false;\n\t\tremaining -= width;\n\t}\n\treturn true;\n}\n\n/** IPv4-mapped IPv6 prefix, `::ffff:0:0/96`. */\nconst IPV4_MAPPED: readonly number[] = [0, 0, 0, 0, 0, 0xffff, 0, 0];\n\n/** Classify four octets against the IPv4 table. */\nfunction classifyIpv4(octets: readonly number[]): AddressClassification {\n\tfor (const [network, prefix, classification] of IPV4_RANGES) {\n\t\tif (withinPrefix(octets, network, prefix, 8)) return classification;\n\t}\n\treturn \"public\";\n}\n\n/**\n * Classify a host as an IP address range, or `null` when it is not an IP literal.\n *\n * `null` is the answer for every domain name, and a caller must treat it as *unknown*\n * rather than safe — conflating the two is the classic fail-open in this kind of check.\n * {@link assertOutboundUrl} handles it explicitly.\n *\n * IPv4-mapped IPv6 addresses are unwrapped and classified by the address they carry, so\n * `::ffff:169.254.169.254` is `link_local` rather than merely \"some IPv6 address\". That\n * form matters in practice: `new URL(\"http://[::ffff:169.254.169.254]/\").hostname`\n * returns the bracketed, hex-compressed `[::ffff:a9fe:a9fe]`, which a string check misses.\n *\n * @param host - Hostname or IP literal, with or without IPv6 brackets.\n * @returns The classification, or `null` when `host` is not an IP literal.\n *\n * @example\n * ```ts\n * classifyAddress(\"169.254.169.254\"); // \"link_local\"\n * classifyAddress(\"172.32.0.1\"); // \"public\" — just outside 172.16/12\n * classifyAddress(\"[::ffff:a9fe:a9fe]\"); // \"link_local\"\n * classifyAddress(\"metadata.example.com\"); // null — a name, not an address\n * ```\n */\nexport function classifyAddress(host: string): AddressClassification | null {\n\tif (typeof host !== \"string\" || host.length === 0) return null;\n\n\tconst octets = parseIpv4(host);\n\tif (octets !== null) return classifyIpv4(octets);\n\n\tconst groups = parseIpv6(host);\n\tif (groups === null) return null;\n\n\tif (withinPrefix(groups, IPV4_MAPPED, 96, 16)) {\n\t\tconst high = groups[6] as number;\n\t\tconst low = groups[7] as number;\n\t\treturn classifyIpv4([high >>> 8, high & 0xff, low >>> 8, low & 0xff]);\n\t}\n\n\tfor (const [network, prefix, classification] of IPV6_RANGES) {\n\t\tif (withinPrefix(groups, network, prefix, 16)) return classification;\n\t}\n\treturn \"public\";\n}\n\n/**\n * Whether a host is an IP literal in a publicly routable range.\n *\n * @param host - Hostname or IP literal.\n * @returns `true` only for a routable IP literal. A domain name returns `false`, because\n * this function cannot know what it resolves to.\n */\nexport function isPubliclyRoutableAddress(host: string): boolean {\n\treturn classifyAddress(host) === \"public\";\n}\n\n//#endregion\n\n//#region Outbound policy\n\n/** Default schemes. `https:` only — a server fetching over `http:` is its own problem. */\nconst DEFAULT_PROTOCOLS: readonly string[] = [\"https:\"];\n\n/**\n * Decide whether a server may fetch a caller-supplied URL.\n *\n * The control the SSRF rules name. Those rules match literal addresses inside a string;\n * this decides whether the request should be made at all.\n *\n * **Default deny, exhaustively.** Every path ends in an explicit allow or an explicit\n * refusal. That is deliberate: `classifyAddress` returns `null` for every domain name, so\n * a policy shaped \"reject non-public *literals*\" silently permits every name — the exact\n * fail-open this control exists to prevent. With no `allowedHosts` and `allowPublicHosts`\n * off, a name is refused.\n *\n * **A pre-connection check, and it cannot be more.** The name is resolved by the network\n * stack after this returns, so DNS may answer differently then (rebinding); redirects\n * need the same check applied per hop; neither is closable by a synchronous function.\n * Network-layer egress control remains the durable fix — this narrows the window rather\n * than shutting it.\n *\n * @param candidate - The URL to fetch, as text or a parsed `URL`.\n * @param policy - See {@link OutboundUrlPolicy}. Defaults refuse everything not named.\n * @returns A discriminated verdict. Never throws.\n *\n * @example\n * ```ts\n * const verdict = assertOutboundUrl(webhookUrl, { allowedHosts: [\"hooks.partner.example\"] });\n * if (!verdict.allowed) return reject(verdict.reason);\n * await fetch(verdict.url);\n * ```\n */\nexport function assertOutboundUrl(\n\tcandidate: string | URL,\n\tpolicy: OutboundUrlPolicy = {},\n): OutboundUrlVerdict {\n\tlet url: URL;\n\ttry {\n\t\turl = candidate instanceof URL ? candidate : new URL(String(candidate));\n\t} catch {\n\t\treturn { allowed: false, reason: \"malformed\" };\n\t}\n\n\tconst protocols = policy.allowedProtocols ?? DEFAULT_PROTOCOLS;\n\tif (!protocols.includes(url.protocol)) {\n\t\treturn { allowed: false, reason: \"protocol_not_allowed\" };\n\t}\n\n\t// An empty `port` means the scheme default, which is always acceptable.\n\tif (url.port !== \"\") {\n\t\tconst port = Number(url.port);\n\t\tif (!(policy.allowedPorts ?? []).includes(port)) {\n\t\t\treturn { allowed: false, reason: \"port_not_allowed\" };\n\t\t}\n\t}\n\n\tconst host = url.hostname.toLowerCase();\n\tconst named = (policy.allowedHosts ?? []).some(\n\t\t(allowed) => allowed.trim().toLowerCase() === host,\n\t);\n\tconst classification = classifyAddress(host);\n\n\t// An IP literal is judged on its range first: naming a loopback address in an\n\t// allowlist should not turn it into a route back into the host.\n\tif (classification !== null) {\n\t\tif (classification !== \"public\") return { allowed: false, reason: \"address_not_routable\" };\n\t\treturn named || policy.allowPublicHosts === true\n\t\t\t? { allowed: true, url, classification }\n\t\t\t: { allowed: false, reason: \"host_not_allowed\" };\n\t}\n\n\t// A name. Nothing available here can tell what it resolves to.\n\treturn named || policy.allowPublicHosts === true\n\t\t? { allowed: true, url, classification: null }\n\t\t: { allowed: false, reason: \"host_not_allowed\" };\n}\n\n//#endregion\n"],"mappings":"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"}
@@ -1,6 +1,7 @@
1
1
  //#region src/controls/csrf.d.ts
2
2
  /**
3
3
  * Copyright 2026 ResQ Systems, Inc.
4
+ * SPDX-License-Identifier: Apache-2.0
4
5
  *
5
6
  * Licensed under the Apache License, Version 2.0 (the "License");
6
7
  * you may not use this file except in compliance with the License.
@@ -15,7 +16,7 @@
15
16
  * limitations under the License.
16
17
  */
17
18
  /** Options for {@link createCsrfToken}. */
18
- interface CsrfTokenOptions {
19
+ export interface CsrfTokenOptions {
19
20
  /**
20
21
  * Session identifier to bind the token to. Strongly recommended: without it, a
21
22
  * token minted by any user verifies for every other user.
@@ -47,18 +48,18 @@ interface CsrfTokenOptions {
47
48
  * res.setHeader("Set-Cookie", `csrf=${token}; Path=/; SameSite=Lax`);
48
49
  * ```
49
50
  */
50
- declare function createCsrfToken(secret: string, options?: CsrfTokenOptions): string;
51
+ export declare function createCsrfToken(secret: string, options?: CsrfTokenOptions): string;
51
52
  /** Why a CSRF token was rejected. */
52
- type CsrfFailureReason = "malformed" | "expired" | "signature_mismatch" | "missing_token" | "missing_secret";
53
+ export type CsrfFailureReason = "malformed" | "expired" | "signature_mismatch" | "missing_token" | "missing_secret";
53
54
  /** Outcome of {@link verifyCsrfToken}. */
54
- type CsrfVerification = {
55
+ export type CsrfVerification = {
55
56
  readonly valid: true;
56
57
  } | {
57
58
  readonly valid: false;
58
59
  readonly reason: CsrfFailureReason;
59
60
  };
60
61
  /** Options for {@link verifyCsrfToken}. */
61
- interface CsrfVerifyOptions {
62
+ export interface CsrfVerifyOptions {
62
63
  /** Session the token must be bound to. Must match the value used at mint time. */
63
64
  readonly sessionId?: string;
64
65
  }
@@ -85,7 +86,6 @@ interface CsrfVerifyOptions {
85
86
  * }
86
87
  * ```
87
88
  */
88
- declare function verifyCsrfToken(token: string | undefined | null, secret: string, options?: CsrfVerifyOptions): CsrfVerification;
89
+ export declare function verifyCsrfToken(token: string | undefined | null, secret: string, options?: CsrfVerifyOptions): CsrfVerification;
89
90
  //#endregion
90
- export { CsrfFailureReason, CsrfTokenOptions, CsrfVerification, CsrfVerifyOptions, createCsrfToken, verifyCsrfToken };
91
91
  //# sourceMappingURL=csrf.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"csrf.d.mts","names":[],"sources":["../../src/controls/csrf.ts"],"mappings":";;;;;;;;;;;;;;;;;UA4DiB;;;;;WAKP;;WAEA;;;;;;;;;;;;;;;;;;;;;;;;;iBA4EM,gBAAgB,gBAAgB,UAAS;;KAmC7C;;KAQA;WACE;;WACA;WAAuB,QAAQ;;;UAG5B;;WAEP;;;;;;;;;;;;;;;;;;;;;;;;;iBAuCM,gBACf,kCACA,gBACA,UAAS,oBACP"}
1
+ {"version":3,"file":"csrf.d.mts","names":[],"sources":["../../src/controls/csrf.ts"],"mappings":";;;;;;;;;;;;;;;;;;iBA6DiB;;;;;WAKP;;WAEA;;;;;;;;;;;;;;;;;;;;;;;;;wBA4EM,gBAAgB,gBAAgB,UAAS;;YAmC7C;;YAQA;WACE;;WACA;WAAuB,QAAQ;;;iBAG5B;;WAEP;;;;;;;;;;;;;;;;;;;;;;;;;wBAuCM,gBACf,kCACA,gBACA,UAAS,oBACP"}
@@ -2,6 +2,7 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  //#region src/controls/csrf.ts
3
3
  /**
4
4
  * Copyright 2026 ResQ Systems, Inc.
5
+ * SPDX-License-Identifier: Apache-2.0
5
6
  *
6
7
  * Licensed under the Apache License, Version 2.0 (the "License");
7
8
  * you may not use this file except in compliance with the License.
@@ -37,7 +38,7 @@ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
37
38
  /** Bytes of randomness in the token nonce — 128 bits. */
38
39
  const NONCE_BYTES = 16;
39
40
  /** Default token lifetime: two hours. */
40
- const DEFAULT_TTL_MS = 7200 * 1e3;
41
+ const DEFAULT_TTL_MS = 72e5;
41
42
  /** Field separator. Outside the base64url alphabet, so it cannot occur within a field. */
42
43
  const SEPARATOR = ".";
43
44
  /** Radix for the expiry field, chosen to keep the token short. */
@@ -130,7 +131,9 @@ function createCsrfToken(secret, options = {}) {
130
131
  * 32 bytes first makes the comparison both constant-time and length-blind.
131
132
  */
132
133
  function constantTimeEquals(left, right) {
133
- return timingSafeEqual(createHmac("sha256", BLINDING_KEY).update(left, "utf8").digest(), createHmac("sha256", BLINDING_KEY).update(right, "utf8").digest());
134
+ const digestLeft = createHmac("sha256", BLINDING_KEY).update(left, "utf8").digest();
135
+ const digestRight = createHmac("sha256", BLINDING_KEY).update(right, "utf8").digest();
136
+ return timingSafeEqual(digestLeft, digestRight);
134
137
  }
135
138
  /**
136
139
  * Verify a signed CSRF token.
@@ -1 +1 @@
1
- {"version":3,"file":"csrf.mjs","names":[],"sources":["../../src/controls/csrf.ts"],"sourcesContent":["/**\n * Copyright 2026 ResQ Systems, Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * @fileoverview Signed CSRF tokens — the control for WSTG-SESS-05.\n *\n * A forged cross-site request is byte-identical to a genuine one; that identity *is*\n * the weakness, which is why no signature in the threat catalog can detect it. The\n * defence is to require a value the attacker's page can neither read nor predict.\n *\n * This implements the **signed double-submit** pattern: the token carries its own\n * HMAC, so verification needs the server secret and no per-session storage. Bind it to\n * a session with `sessionId` whenever one exists — an unbound token verifies for any\n * user, which still stops a plain cross-site forgery but not a logged-in attacker\n * minting a token and planting it on a victim.\n *\n * **This is one layer.** Also ship `SameSite=Lax` (or `Strict`) on session cookies and\n * validate `Origin` with `isAllowedOrigin` on state-changing requests. Each of the\n * three can be bypassed alone.\n *\n * @module @resq-systems/security/controls/csrf\n */\n\nimport { createHmac, randomBytes, timingSafeEqual } from \"node:crypto\";\n\n//#region Constants\n\n/** Bytes of randomness in the token nonce — 128 bits. */\nconst NONCE_BYTES = 16;\n\n/** Default token lifetime: two hours. */\nconst DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;\n\n/** Field separator. Outside the base64url alphabet, so it cannot occur within a field. */\nconst SEPARATOR = \".\";\n\n/** Radix for the expiry field, chosen to keep the token short. */\nconst TIMESTAMP_RADIX = 36;\n\n/** Fixed key for the length-blinding digest in {@link constantTimeEquals}. */\nconst BLINDING_KEY = \"resq-csrf-length-blind\";\n\n//#endregion\n\n//#region Token minting\n\n/** Options for {@link createCsrfToken}. */\nexport interface CsrfTokenOptions {\n\t/**\n\t * Session identifier to bind the token to. Strongly recommended: without it, a\n\t * token minted by any user verifies for every other user.\n\t */\n\treadonly sessionId?: string;\n\t/** Lifetime in milliseconds. Defaults to two hours. */\n\treadonly ttlMs?: number;\n}\n\n/**\n * Whether a string is well-formed UTF-16 — no unpaired surrogate.\n *\n * This matters because `createHmac().update(string)` encodes as UTF-8, which maps\n * *every* lone surrogate to the same replacement bytes `EF BF BD`, while\n * `String.length` counts UTF-16 code units. The length prefix therefore stops being\n * injective for ill-formed input: `\"tenant-\\uD800\"` and `\"tenant-�\"` hash\n * identically, and a token bound to one verifies for the other.\n *\n * Implemented by scanning rather than calling `String.prototype.isWellFormed`, so the\n * check does not depend on the ES2024 lib being configured.\n */\nfunction isWellFormedUtf16(value: string): boolean {\n\tfor (let i = 0; i < value.length; i++) {\n\t\tconst code = value.charCodeAt(i);\n\t\t// Not a surrogate — always fine.\n\t\tif (code < 0xd800 || code > 0xdfff) continue;\n\t\t// A trailing surrogate here means it had no leading partner.\n\t\tif (code >= 0xdc00) return false;\n\t\t// A leading surrogate must be followed by a trailing one.\n\t\tconst next = value.charCodeAt(i + 1);\n\t\tif (Number.isNaN(next) || next < 0xdc00 || next > 0xdfff) return false;\n\t\ti++;\n\t}\n\treturn true;\n}\n\n/** A session identifier usable for binding: a well-formed string. */\nfunction isBindableSessionId(value: unknown): value is string {\n\treturn typeof value === \"string\" && isWellFormedUtf16(value);\n}\n\n/**\n * Compute the token signature over the nonce, expiry, and bound session.\n *\n * Each field is length-prefixed before hashing. Without that, a different\n * (nonce, expiry, sessionId) split could produce the same concatenated input — so a\n * token bound to session `\"ab\"` would verify against session `\"a\"` with a shifted\n * nonce.\n *\n * Callers must pass a well-formed string; see {@link isBindableSessionId}. Both\n * entry points enforce it, because template-stringifying a non-string collapses every\n * object to `\"[object Object]\"` and every such session to the *same* signature.\n */\nfunction sign(secret: string, nonce: string, expiry: string, sessionId: string): string {\n\treturn createHmac(\"sha256\", secret)\n\t\t.update(`${nonce.length}:${nonce}|${expiry.length}:${expiry}|${sessionId.length}:${sessionId}`)\n\t\t.digest(\"base64url\");\n}\n\n/**\n * Mint a signed CSRF token.\n *\n * Send it to the client in a readable cookie *and* require it back in a header or form\n * field. A cross-origin page can cause the cookie to be sent but cannot read it, so it\n * cannot populate the second copy.\n *\n * @param secret - Server-side signing secret. Must be non-empty; use at least 32 bytes\n * of entropy from a secret manager, and never a value shipped to the client.\n * @param options - See {@link CsrfTokenOptions}.\n * @returns An opaque token safe to place in a cookie, header, or hidden form field.\n * @throws {TypeError} If `secret` is empty, if `sessionId` is present but is not a\n * well-formed string, or if `ttlMs` is not a positive integer. Each of the three is\n * a programming error that would otherwise weaken the token silently — a non-string\n * `sessionId` binds every session to the same signature, and a fractional `ttlMs`\n * injects the field separator into the expiry.\n *\n * @example\n * ```ts\n * const token = createCsrfToken(process.env.CSRF_SECRET!, { sessionId: session.id });\n * res.setHeader(\"Set-Cookie\", `csrf=${token}; Path=/; SameSite=Lax`);\n * ```\n */\nexport function createCsrfToken(secret: string, options: CsrfTokenOptions = {}): string {\n\tif (typeof secret !== \"string\" || secret.length === 0) {\n\t\tthrow new TypeError(\"createCsrfToken: secret must be a non-empty string\");\n\t}\n\n\tconst { sessionId = \"\", ttlMs = DEFAULT_TTL_MS } = options;\n\n\t// Guarded after the destructuring default, so `undefined` still means \"unbound\"\n\t// while `null` and every non-string are refused with a clear message rather than a\n\t// cryptic `sessionId.length` failure. Without this, any object binds to the\n\t// constant \"[object Object]\" and one token verifies for every session.\n\tif (!isBindableSessionId(sessionId)) {\n\t\tthrow new TypeError(\n\t\t\t\"createCsrfToken: sessionId must be a well-formed string (no unpaired surrogates)\",\n\t\t);\n\t}\n\n\t// Integer, not merely finite: a fractional value renders through\n\t// `Number.prototype.toString(36)` with a fractional tail, injecting the field\n\t// separator into the expiry and producing a token this module cannot parse back.\n\tif (!Number.isInteger(ttlMs) || ttlMs <= 0) {\n\t\tthrow new TypeError(\"createCsrfToken: ttlMs must be a positive integer number of milliseconds\");\n\t}\n\n\tconst nonce = randomBytes(NONCE_BYTES).toString(\"base64url\");\n\tconst expiry = (Date.now() + ttlMs).toString(TIMESTAMP_RADIX);\n\n\treturn [nonce, expiry, sign(secret, nonce, expiry, sessionId)].join(SEPARATOR);\n}\n\n//#endregion\n\n//#region Verification\n\n/** Why a CSRF token was rejected. */\nexport type CsrfFailureReason =\n\t| \"malformed\"\n\t| \"expired\"\n\t| \"signature_mismatch\"\n\t| \"missing_token\"\n\t| \"missing_secret\";\n\n/** Outcome of {@link verifyCsrfToken}. */\nexport type CsrfVerification =\n\t| { readonly valid: true }\n\t| { readonly valid: false; readonly reason: CsrfFailureReason };\n\n/** Options for {@link verifyCsrfToken}. */\nexport interface CsrfVerifyOptions {\n\t/** Session the token must be bound to. Must match the value used at mint time. */\n\treadonly sessionId?: string;\n}\n\n/**\n * Constant-time comparison that does not leak length.\n *\n * `timingSafeEqual` throws when its arguments differ in length, and guarding that with\n * an early `length` check reintroduces a timing signal. Hashing both sides to a fixed\n * 32 bytes first makes the comparison both constant-time and length-blind.\n */\nfunction constantTimeEquals(left: string, right: string): boolean {\n\tconst digestLeft = createHmac(\"sha256\", BLINDING_KEY).update(left, \"utf8\").digest();\n\tconst digestRight = createHmac(\"sha256\", BLINDING_KEY).update(right, \"utf8\").digest();\n\treturn timingSafeEqual(digestLeft, digestRight);\n}\n\n/**\n * Verify a signed CSRF token.\n *\n * Checks the signature in constant time, then the expiry. The failure `reason` is for\n * server-side logging — do not return it to the client, since it distinguishes\n * \"expired\" from \"forged\" for anyone probing the endpoint.\n *\n * @param token - The token submitted with the request.\n * @param secret - The same signing secret used at mint time.\n * @param options - See {@link CsrfVerifyOptions}.\n * @returns `{ valid: true }`, or `{ valid: false, reason }`. Never throws.\n *\n * @example\n * ```ts\n * const result = verifyCsrfToken(req.headers[\"x-csrf-token\"], secret, {\n * sessionId: session.id,\n * });\n * if (!result.valid) {\n * logger.warn(\"csrf rejected\", { reason: result.reason });\n * return new Response(\"Forbidden\", { status: 403 });\n * }\n * ```\n */\nexport function verifyCsrfToken(\n\ttoken: string | undefined | null,\n\tsecret: string,\n\toptions: CsrfVerifyOptions = {},\n): CsrfVerification {\n\tif (typeof secret !== \"string\" || secret.length === 0) {\n\t\treturn { valid: false, reason: \"missing_secret\" };\n\t}\n\tif (typeof token !== \"string\" || token.length === 0) {\n\t\treturn { valid: false, reason: \"missing_token\" };\n\t}\n\n\tconst parts = token.split(SEPARATOR);\n\tif (parts.length !== 3) return { valid: false, reason: \"malformed\" };\n\n\tconst [nonce, expiry, signature] = parts as [string, string, string];\n\tif (nonce.length === 0 || expiry.length === 0 || signature.length === 0) {\n\t\treturn { valid: false, reason: \"malformed\" };\n\t}\n\n\t// `?? \"\"` first so null and undefined both keep meaning \"unbound\", then reject\n\t// anything that is not a well-formed string. Returning a verdict rather than\n\t// throwing, because this function's contract is that it never throws.\n\tconst boundSession = options.sessionId ?? \"\";\n\tif (!isBindableSessionId(boundSession)) {\n\t\treturn { valid: false, reason: \"malformed\" };\n\t}\n\n\t// Signature first, expiry second. Checking expiry first would let an attacker\n\t// distinguish a well-formed-but-stale token from a forged one by timing alone.\n\tconst expected = sign(secret, nonce, expiry, boundSession);\n\tif (!constantTimeEquals(signature, expected)) {\n\t\treturn { valid: false, reason: \"signature_mismatch\" };\n\t}\n\n\tconst expiresAt = Number.parseInt(expiry, TIMESTAMP_RADIX);\n\tif (!Number.isFinite(expiresAt)) return { valid: false, reason: \"malformed\" };\n\tif (Date.now() > expiresAt) return { valid: false, reason: \"expired\" };\n\n\treturn { valid: true };\n}\n\n//#endregion\n"],"mappings":"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"}
1
+ {"version":3,"file":"csrf.mjs","names":[],"sources":["../../src/controls/csrf.ts"],"sourcesContent":["/**\n * Copyright 2026 ResQ Systems, Inc.\n * SPDX-License-Identifier: Apache-2.0\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * @fileoverview Signed CSRF tokens — the control for WSTG-SESS-05.\n *\n * A forged cross-site request is byte-identical to a genuine one; that identity *is*\n * the weakness, which is why no signature in the threat catalog can detect it. The\n * defence is to require a value the attacker's page can neither read nor predict.\n *\n * This implements the **signed double-submit** pattern: the token carries its own\n * HMAC, so verification needs the server secret and no per-session storage. Bind it to\n * a session with `sessionId` whenever one exists — an unbound token verifies for any\n * user, which still stops a plain cross-site forgery but not a logged-in attacker\n * minting a token and planting it on a victim.\n *\n * **This is one layer.** Also ship `SameSite=Lax` (or `Strict`) on session cookies and\n * validate `Origin` with `isAllowedOrigin` on state-changing requests. Each of the\n * three can be bypassed alone.\n *\n * @module @resq-systems/security/controls/csrf\n */\n\nimport { createHmac, randomBytes, timingSafeEqual } from \"node:crypto\";\n\n//#region Constants\n\n/** Bytes of randomness in the token nonce — 128 bits. */\nconst NONCE_BYTES = 16;\n\n/** Default token lifetime: two hours. */\nconst DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;\n\n/** Field separator. Outside the base64url alphabet, so it cannot occur within a field. */\nconst SEPARATOR = \".\";\n\n/** Radix for the expiry field, chosen to keep the token short. */\nconst TIMESTAMP_RADIX = 36;\n\n/** Fixed key for the length-blinding digest in {@link constantTimeEquals}. */\nconst BLINDING_KEY = \"resq-csrf-length-blind\";\n\n//#endregion\n\n//#region Token minting\n\n/** Options for {@link createCsrfToken}. */\nexport interface CsrfTokenOptions {\n\t/**\n\t * Session identifier to bind the token to. Strongly recommended: without it, a\n\t * token minted by any user verifies for every other user.\n\t */\n\treadonly sessionId?: string;\n\t/** Lifetime in milliseconds. Defaults to two hours. */\n\treadonly ttlMs?: number;\n}\n\n/**\n * Whether a string is well-formed UTF-16 — no unpaired surrogate.\n *\n * This matters because `createHmac().update(string)` encodes as UTF-8, which maps\n * *every* lone surrogate to the same replacement bytes `EF BF BD`, while\n * `String.length` counts UTF-16 code units. The length prefix therefore stops being\n * injective for ill-formed input: `\"tenant-\\uD800\"` and `\"tenant-�\"` hash\n * identically, and a token bound to one verifies for the other.\n *\n * Implemented by scanning rather than calling `String.prototype.isWellFormed`, so the\n * check does not depend on the ES2024 lib being configured.\n */\nfunction isWellFormedUtf16(value: string): boolean {\n\tfor (let i = 0; i < value.length; i++) {\n\t\tconst code = value.charCodeAt(i);\n\t\t// Not a surrogate — always fine.\n\t\tif (code < 0xd800 || code > 0xdfff) continue;\n\t\t// A trailing surrogate here means it had no leading partner.\n\t\tif (code >= 0xdc00) return false;\n\t\t// A leading surrogate must be followed by a trailing one.\n\t\tconst next = value.charCodeAt(i + 1);\n\t\tif (Number.isNaN(next) || next < 0xdc00 || next > 0xdfff) return false;\n\t\ti++;\n\t}\n\treturn true;\n}\n\n/** A session identifier usable for binding: a well-formed string. */\nfunction isBindableSessionId(value: unknown): value is string {\n\treturn typeof value === \"string\" && isWellFormedUtf16(value);\n}\n\n/**\n * Compute the token signature over the nonce, expiry, and bound session.\n *\n * Each field is length-prefixed before hashing. Without that, a different\n * (nonce, expiry, sessionId) split could produce the same concatenated input — so a\n * token bound to session `\"ab\"` would verify against session `\"a\"` with a shifted\n * nonce.\n *\n * Callers must pass a well-formed string; see {@link isBindableSessionId}. Both\n * entry points enforce it, because template-stringifying a non-string collapses every\n * object to `\"[object Object]\"` and every such session to the *same* signature.\n */\nfunction sign(secret: string, nonce: string, expiry: string, sessionId: string): string {\n\treturn createHmac(\"sha256\", secret)\n\t\t.update(`${nonce.length}:${nonce}|${expiry.length}:${expiry}|${sessionId.length}:${sessionId}`)\n\t\t.digest(\"base64url\");\n}\n\n/**\n * Mint a signed CSRF token.\n *\n * Send it to the client in a readable cookie *and* require it back in a header or form\n * field. A cross-origin page can cause the cookie to be sent but cannot read it, so it\n * cannot populate the second copy.\n *\n * @param secret - Server-side signing secret. Must be non-empty; use at least 32 bytes\n * of entropy from a secret manager, and never a value shipped to the client.\n * @param options - See {@link CsrfTokenOptions}.\n * @returns An opaque token safe to place in a cookie, header, or hidden form field.\n * @throws {TypeError} If `secret` is empty, if `sessionId` is present but is not a\n * well-formed string, or if `ttlMs` is not a positive integer. Each of the three is\n * a programming error that would otherwise weaken the token silently — a non-string\n * `sessionId` binds every session to the same signature, and a fractional `ttlMs`\n * injects the field separator into the expiry.\n *\n * @example\n * ```ts\n * const token = createCsrfToken(process.env.CSRF_SECRET!, { sessionId: session.id });\n * res.setHeader(\"Set-Cookie\", `csrf=${token}; Path=/; SameSite=Lax`);\n * ```\n */\nexport function createCsrfToken(secret: string, options: CsrfTokenOptions = {}): string {\n\tif (typeof secret !== \"string\" || secret.length === 0) {\n\t\tthrow new TypeError(\"createCsrfToken: secret must be a non-empty string\");\n\t}\n\n\tconst { sessionId = \"\", ttlMs = DEFAULT_TTL_MS } = options;\n\n\t// Guarded after the destructuring default, so `undefined` still means \"unbound\"\n\t// while `null` and every non-string are refused with a clear message rather than a\n\t// cryptic `sessionId.length` failure. Without this, any object binds to the\n\t// constant \"[object Object]\" and one token verifies for every session.\n\tif (!isBindableSessionId(sessionId)) {\n\t\tthrow new TypeError(\n\t\t\t\"createCsrfToken: sessionId must be a well-formed string (no unpaired surrogates)\",\n\t\t);\n\t}\n\n\t// Integer, not merely finite: a fractional value renders through\n\t// `Number.prototype.toString(36)` with a fractional tail, injecting the field\n\t// separator into the expiry and producing a token this module cannot parse back.\n\tif (!Number.isInteger(ttlMs) || ttlMs <= 0) {\n\t\tthrow new TypeError(\"createCsrfToken: ttlMs must be a positive integer number of milliseconds\");\n\t}\n\n\tconst nonce = randomBytes(NONCE_BYTES).toString(\"base64url\");\n\tconst expiry = (Date.now() + ttlMs).toString(TIMESTAMP_RADIX);\n\n\treturn [nonce, expiry, sign(secret, nonce, expiry, sessionId)].join(SEPARATOR);\n}\n\n//#endregion\n\n//#region Verification\n\n/** Why a CSRF token was rejected. */\nexport type CsrfFailureReason =\n\t| \"malformed\"\n\t| \"expired\"\n\t| \"signature_mismatch\"\n\t| \"missing_token\"\n\t| \"missing_secret\";\n\n/** Outcome of {@link verifyCsrfToken}. */\nexport type CsrfVerification =\n\t| { readonly valid: true }\n\t| { readonly valid: false; readonly reason: CsrfFailureReason };\n\n/** Options for {@link verifyCsrfToken}. */\nexport interface CsrfVerifyOptions {\n\t/** Session the token must be bound to. Must match the value used at mint time. */\n\treadonly sessionId?: string;\n}\n\n/**\n * Constant-time comparison that does not leak length.\n *\n * `timingSafeEqual` throws when its arguments differ in length, and guarding that with\n * an early `length` check reintroduces a timing signal. Hashing both sides to a fixed\n * 32 bytes first makes the comparison both constant-time and length-blind.\n */\nfunction constantTimeEquals(left: string, right: string): boolean {\n\tconst digestLeft = createHmac(\"sha256\", BLINDING_KEY).update(left, \"utf8\").digest();\n\tconst digestRight = createHmac(\"sha256\", BLINDING_KEY).update(right, \"utf8\").digest();\n\treturn timingSafeEqual(digestLeft, digestRight);\n}\n\n/**\n * Verify a signed CSRF token.\n *\n * Checks the signature in constant time, then the expiry. The failure `reason` is for\n * server-side logging — do not return it to the client, since it distinguishes\n * \"expired\" from \"forged\" for anyone probing the endpoint.\n *\n * @param token - The token submitted with the request.\n * @param secret - The same signing secret used at mint time.\n * @param options - See {@link CsrfVerifyOptions}.\n * @returns `{ valid: true }`, or `{ valid: false, reason }`. Never throws.\n *\n * @example\n * ```ts\n * const result = verifyCsrfToken(req.headers[\"x-csrf-token\"], secret, {\n * sessionId: session.id,\n * });\n * if (!result.valid) {\n * logger.warn(\"csrf rejected\", { reason: result.reason });\n * return new Response(\"Forbidden\", { status: 403 });\n * }\n * ```\n */\nexport function verifyCsrfToken(\n\ttoken: string | undefined | null,\n\tsecret: string,\n\toptions: CsrfVerifyOptions = {},\n): CsrfVerification {\n\tif (typeof secret !== \"string\" || secret.length === 0) {\n\t\treturn { valid: false, reason: \"missing_secret\" };\n\t}\n\tif (typeof token !== \"string\" || token.length === 0) {\n\t\treturn { valid: false, reason: \"missing_token\" };\n\t}\n\n\tconst parts = token.split(SEPARATOR);\n\tif (parts.length !== 3) return { valid: false, reason: \"malformed\" };\n\n\tconst [nonce, expiry, signature] = parts as [string, string, string];\n\tif (nonce.length === 0 || expiry.length === 0 || signature.length === 0) {\n\t\treturn { valid: false, reason: \"malformed\" };\n\t}\n\n\t// `?? \"\"` first so null and undefined both keep meaning \"unbound\", then reject\n\t// anything that is not a well-formed string. Returning a verdict rather than\n\t// throwing, because this function's contract is that it never throws.\n\tconst boundSession = options.sessionId ?? \"\";\n\tif (!isBindableSessionId(boundSession)) {\n\t\treturn { valid: false, reason: \"malformed\" };\n\t}\n\n\t// Signature first, expiry second. Checking expiry first would let an attacker\n\t// distinguish a well-formed-but-stale token from a forged one by timing alone.\n\tconst expected = sign(secret, nonce, expiry, boundSession);\n\tif (!constantTimeEquals(signature, expected)) {\n\t\treturn { valid: false, reason: \"signature_mismatch\" };\n\t}\n\n\tconst expiresAt = Number.parseInt(expiry, TIMESTAMP_RADIX);\n\tif (!Number.isFinite(expiresAt)) return { valid: false, reason: \"malformed\" };\n\tif (Date.now() > expiresAt) return { valid: false, reason: \"expired\" };\n\n\treturn { valid: true };\n}\n\n//#endregion\n"],"mappings":"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"}
@@ -1,6 +1,7 @@
1
1
  //#region src/controls/origin.d.ts
2
2
  /**
3
3
  * Copyright 2026 ResQ Systems, Inc.
4
+ * SPDX-License-Identifier: Apache-2.0
4
5
  *
5
6
  * Licensed under the Apache License, Version 2.0 (the "License");
6
7
  * you may not use this file except in compliance with the License.
@@ -33,9 +34,9 @@
33
34
  * normalizeOrigin("null"); // null
34
35
  * ```
35
36
  */
36
- declare function normalizeOrigin(origin: string): string | null;
37
+ export declare function normalizeOrigin(origin: string): string | null;
37
38
  /** Options for {@link isAllowedOrigin}. */
38
- interface OriginPolicyOptions {
39
+ export interface OriginPolicyOptions {
39
40
  /**
40
41
  * Parent origins whose **subdomains** are also accepted. Matching is on label
41
42
  * boundaries, so `https://example.com` admits `https://app.example.com` but never
@@ -70,9 +71,9 @@ interface OriginPolicyOptions {
70
71
  * }
71
72
  * ```
72
73
  */
73
- declare function isAllowedOrigin(origin: string, allowlist: readonly string[], options?: OriginPolicyOptions): boolean;
74
+ export declare function isAllowedOrigin(origin: string, allowlist: readonly string[], options?: OriginPolicyOptions): boolean;
74
75
  /** A CORS response configuration, checked for the credentialed-wildcard mistake. */
75
- interface CorsResponsePolicy {
76
+ export interface CorsResponsePolicy {
76
77
  /** Value destined for `Access-Control-Allow-Origin`. */
77
78
  readonly allowOrigin: string;
78
79
  /** Whether `Access-Control-Allow-Credentials: true` will be sent. */
@@ -89,7 +90,6 @@ interface CorsResponsePolicy {
89
90
  * @param policy - The headers about to be sent.
90
91
  * @returns `null` when the combination is safe, otherwise a message naming the problem.
91
92
  */
92
- declare function checkCorsResponsePolicy(policy: CorsResponsePolicy): string | null;
93
+ export declare function checkCorsResponsePolicy(policy: CorsResponsePolicy): string | null;
93
94
  //#endregion
94
- export { CorsResponsePolicy, OriginPolicyOptions, checkCorsResponsePolicy, isAllowedOrigin, normalizeOrigin };
95
95
  //# sourceMappingURL=origin.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"origin.d.mts","names":[],"sources":["../../src/controls/origin.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAqEgB,gBAAgB;;UAiCf;;;;;;;;;WASP;;;;;;;;;;;;;;;;;;;;;;;;;;iBAgCM,gBACf,gBACA,8BACA,UAAS;;UAqCO;;WAEP;;WAEA;;;;;;;;;;;;;iBAcM,wBAAwB,QAAQ"}
1
+ {"version":3,"file":"origin.d.mts","names":[],"sources":["../../src/controls/origin.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBAsEgB,gBAAgB;;iBAiCf;;;;;;;;;WASP;;;;;;;;;;;;;;;;;;;;;;;;;;wBAgCM,gBACf,gBACA,8BACA,UAAS;;iBAqCO;;WAEP;;WAEA;;;;;;;;;;;;;wBAcM,wBAAwB,QAAQ"}
@@ -1,6 +1,7 @@
1
1
  //#region src/controls/origin.ts
2
2
  /**
3
3
  * Copyright 2026 ResQ Systems, Inc.
4
+ * SPDX-License-Identifier: Apache-2.0
4
5
  *
5
6
  * Licensed under the Apache License, Version 2.0 (the "License");
6
7
  * you may not use this file except in compliance with the License.
@@ -1 +1 @@
1
- {"version":3,"file":"origin.mjs","names":[],"sources":["../../src/controls/origin.ts"],"sourcesContent":["/**\n * Copyright 2026 ResQ Systems, Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * @fileoverview CORS origin validation — the control for WSTG-CLNT-07.\n *\n * No signature helps here: `Origin: https://evil.example` is byte-identical in shape to\n * a legitimate origin, and the vulnerability lives in how the *server* decides to\n * reflect it. So this is a decision function, not a detector.\n *\n * Every real-world CORS bypass comes from one of four shortcuts, and each is refused\n * explicitly below:\n *\n * 1. `startsWith` / `includes` matching — `https://example.com.evil.test` passes a\n * prefix check and `https://evil-example.com` passes a substring check.\n * 2. Reflecting the request's own `Origin` header into `Access-Control-Allow-Origin`.\n * 3. Accepting the literal `null` origin, which any sandboxed iframe or `data:`\n * document can send.\n * 4. Pairing `Access-Control-Allow-Origin: *` with\n * `Access-Control-Allow-Credentials: true`.\n *\n * @module @resq-systems/security/controls/origin\n */\n\n//#region Normalization\n\n/** Default ports omitted from an origin's serialized form. */\nconst DEFAULT_PORTS: Readonly<Record<string, string>> = {\n\t\"http:\": \"80\",\n\t\"https:\": \"443\",\n\t\"ws:\": \"80\",\n\t\"wss:\": \"443\",\n};\n\n/** Schemes an origin may use. Anything else is refused before comparison. */\nconst ALLOWED_SCHEMES = new Set([\"http:\", \"https:\", \"ws:\", \"wss:\"]);\n\n/**\n * Reduce an origin string to its canonical serialized form.\n *\n * Lowercases the scheme and host, drops a redundant default port, and refuses anything\n * carrying a path, query, fragment, or userinfo — none of which belongs in an origin,\n * and each of which is a way to smuggle a different host past a careless comparison.\n *\n * @param origin - Raw `Origin` header value.\n * @returns The canonical origin (`https://example.com`, `https://example.com:8443`), or\n * `null` when the value is not a well-formed, comparable origin. The literal `\"null\"`\n * origin always yields `null`.\n *\n * @example\n * ```ts\n * normalizeOrigin(\"HTTPS://Example.COM:443\"); // \"https://example.com\"\n * normalizeOrigin(\"https://example.com/path\"); // null — origins carry no path\n * normalizeOrigin(\"null\"); // null\n * ```\n */\nexport function normalizeOrigin(origin: string): string | null {\n\tif (typeof origin !== \"string\") return null;\n\n\tconst trimmed = origin.trim();\n\t// Any sandboxed iframe, `data:` document, or cross-origin redirect can present\n\t// `null`. It is never a grant of trust, so it never survives normalization.\n\tif (trimmed.length === 0 || trimmed === \"null\" || trimmed === \"*\") return null;\n\n\tlet parsed: URL;\n\ttry {\n\t\tparsed = new URL(trimmed);\n\t} catch {\n\t\treturn null;\n\t}\n\n\tif (!ALLOWED_SCHEMES.has(parsed.protocol)) return null;\n\tif (parsed.username !== \"\" || parsed.password !== \"\") return null;\n\tif (parsed.search !== \"\" || parsed.hash !== \"\") return null;\n\t// `new URL(\"https://example.com\")` yields pathname \"/\", the only acceptable value;\n\t// anything longer means a path was supplied and this is not a bare origin.\n\tif (parsed.pathname !== \"/\") return null;\n\tif (parsed.hostname.length === 0) return null;\n\n\tconst port = parsed.port === DEFAULT_PORTS[parsed.protocol] ? \"\" : parsed.port;\n\tconst host = parsed.hostname.toLowerCase();\n\treturn port === \"\" ? `${parsed.protocol}//${host}` : `${parsed.protocol}//${host}:${port}`;\n}\n\n//#endregion\n\n//#region Matching\n\n/** Options for {@link isAllowedOrigin}. */\nexport interface OriginPolicyOptions {\n\t/**\n\t * Parent origins whose **subdomains** are also accepted. Matching is on label\n\t * boundaries, so `https://example.com` admits `https://app.example.com` but never\n\t * `https://example.com.evil.test` and never `https://notexample.com`.\n\t *\n\t * Off by default. Enabling it makes every subdomain as trusted as the parent —\n\t * including any subdomain an attacker manages to take over, which is WSTG-CONF-10.\n\t */\n\treadonly allowSubdomainsOf?: readonly string[];\n}\n\n/** True when `host` is a strict subdomain of `parentHost`, on a label boundary. */\nfunction isSubdomainOf(host: string, parentHost: string): boolean {\n\treturn host.length > parentHost.length && host.endsWith(`.${parentHost}`);\n}\n\n/**\n * Decide whether an origin is allowed.\n *\n * Both sides are normalized, then matched **exactly**. There is no prefix, suffix, or\n * substring path through this function, and no wildcard: the allowlist is a list of\n * origins, not a list of patterns.\n *\n * @param origin - The request's `Origin` header value.\n * @param allowlist - Origins to accept. Entries that fail normalization are skipped\n * rather than silently widening the policy.\n * @param options - See {@link OriginPolicyOptions}.\n * @returns `true` only when the origin is well-formed and present in the allowlist.\n *\n * @example\n * ```ts\n * const ALLOWED = [\"https://app.example.com\", \"https://admin.example.com\"];\n *\n * if (isAllowedOrigin(req.headers.origin ?? \"\", ALLOWED)) {\n * // Echo the *normalized allowlisted* value, never the raw request header.\n * res.setHeader(\"Access-Control-Allow-Origin\", normalizeOrigin(req.headers.origin)!);\n * res.setHeader(\"Vary\", \"Origin\");\n * }\n * ```\n */\nexport function isAllowedOrigin(\n\torigin: string,\n\tallowlist: readonly string[],\n\toptions: OriginPolicyOptions = {},\n): boolean {\n\tconst candidate = normalizeOrigin(origin);\n\tif (candidate === null) return false;\n\n\tconst entries = Array.isArray(allowlist) ? allowlist : [];\n\tfor (const entry of entries) {\n\t\tif (normalizeOrigin(entry) === candidate) return true;\n\t}\n\n\t// Checked after the exact list, not instead of it: an empty `allowlist` combined\n\t// with a populated `allowSubdomainsOf` is a legitimate configuration, and an early\n\t// return on `allowlist.length === 0` would silently deny every request.\n\tconst parents = options.allowSubdomainsOf;\n\tif (!Array.isArray(parents) || parents.length === 0) return false;\n\n\t// Scheme, port, and host are compared separately so a subdomain grant cannot also\n\t// downgrade the scheme or move the port.\n\tconst candidateUrl = new URL(candidate);\n\tfor (const parent of parents) {\n\t\tconst normalizedParent = normalizeOrigin(parent);\n\t\tif (normalizedParent === null) continue;\n\n\t\tconst parentUrl = new URL(normalizedParent);\n\t\tif (parentUrl.protocol !== candidateUrl.protocol) continue;\n\t\tif (parentUrl.port !== candidateUrl.port) continue;\n\t\tif (isSubdomainOf(candidateUrl.hostname, parentUrl.hostname)) return true;\n\t}\n\n\treturn false;\n}\n\n//#endregion\n\n//#region Policy assertion\n\n/** A CORS response configuration, checked for the credentialed-wildcard mistake. */\nexport interface CorsResponsePolicy {\n\t/** Value destined for `Access-Control-Allow-Origin`. */\n\treadonly allowOrigin: string;\n\t/** Whether `Access-Control-Allow-Credentials: true` will be sent. */\n\treadonly allowCredentials: boolean;\n}\n\n/**\n * Reject the CORS combinations that browsers treat as an error and servers still ship:\n * `Access-Control-Allow-Origin: *` or `null` together with\n * `Access-Control-Allow-Credentials: true`.\n *\n * Call it where response headers are assembled, so the mistake fails a test rather than\n * reaching production.\n *\n * @param policy - The headers about to be sent.\n * @returns `null` when the combination is safe, otherwise a message naming the problem.\n */\nexport function checkCorsResponsePolicy(policy: CorsResponsePolicy): string | null {\n\tif (!policy.allowCredentials) return null;\n\n\tconst value = policy.allowOrigin.trim();\n\tif (value === \"*\") {\n\t\treturn \"Access-Control-Allow-Origin: * cannot be combined with Access-Control-Allow-Credentials: true — name the exact origin instead\";\n\t}\n\tif (value === \"null\") {\n\t\treturn \"Access-Control-Allow-Origin: null grants access to sandboxed and data: documents — name the exact origin instead\";\n\t}\n\treturn null;\n}\n\n//#endregion\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwCA,MAAM,gBAAkD;CACvD,SAAS;CACT,UAAU;CACV,OAAO;CACP,QAAQ;AACT;;AAGA,MAAM,kCAAkB,IAAI,IAAI;CAAC;CAAS;CAAU;CAAO;AAAM,CAAC;;;;;;;;;;;;;;;;;;;;AAqBlE,SAAgB,gBAAgB,QAA+B;CAC9D,IAAI,OAAO,WAAW,UAAU,OAAO;CAEvC,MAAM,UAAU,OAAO,KAAK;CAG5B,IAAI,QAAQ,WAAW,KAAK,YAAY,UAAU,YAAY,KAAK,OAAO;CAE1E,IAAI;CACJ,IAAI;EACH,SAAS,IAAI,IAAI,OAAO;CACzB,QAAQ;EACP,OAAO;CACR;CAEA,IAAI,CAAC,gBAAgB,IAAI,OAAO,QAAQ,GAAG,OAAO;CAClD,IAAI,OAAO,aAAa,MAAM,OAAO,aAAa,IAAI,OAAO;CAC7D,IAAI,OAAO,WAAW,MAAM,OAAO,SAAS,IAAI,OAAO;CAGvD,IAAI,OAAO,aAAa,KAAK,OAAO;CACpC,IAAI,OAAO,SAAS,WAAW,GAAG,OAAO;CAEzC,MAAM,OAAO,OAAO,SAAS,cAAc,OAAO,YAAY,KAAK,OAAO;CAC1E,MAAM,OAAO,OAAO,SAAS,YAAY;CACzC,OAAO,SAAS,KAAK,GAAG,OAAO,SAAS,IAAI,SAAS,GAAG,OAAO,SAAS,IAAI,KAAK,GAAG;AACrF;;AAoBA,SAAS,cAAc,MAAc,YAA6B;CACjE,OAAO,KAAK,SAAS,WAAW,UAAU,KAAK,SAAS,IAAI,YAAY;AACzE;;;;;;;;;;;;;;;;;;;;;;;;;AA0BA,SAAgB,gBACf,QACA,WACA,UAA+B,CAAC,GACtB;CACV,MAAM,YAAY,gBAAgB,MAAM;CACxC,IAAI,cAAc,MAAM,OAAO;CAE/B,MAAM,UAAU,MAAM,QAAQ,SAAS,IAAI,YAAY,CAAC;CACxD,KAAK,MAAM,SAAS,SACnB,IAAI,gBAAgB,KAAK,MAAM,WAAW,OAAO;CAMlD,MAAM,UAAU,QAAQ;CACxB,IAAI,CAAC,MAAM,QAAQ,OAAO,KAAK,QAAQ,WAAW,GAAG,OAAO;CAI5D,MAAM,eAAe,IAAI,IAAI,SAAS;CACtC,KAAK,MAAM,UAAU,SAAS;EAC7B,MAAM,mBAAmB,gBAAgB,MAAM;EAC/C,IAAI,qBAAqB,MAAM;EAE/B,MAAM,YAAY,IAAI,IAAI,gBAAgB;EAC1C,IAAI,UAAU,aAAa,aAAa,UAAU;EAClD,IAAI,UAAU,SAAS,aAAa,MAAM;EAC1C,IAAI,cAAc,aAAa,UAAU,UAAU,QAAQ,GAAG,OAAO;CACtE;CAEA,OAAO;AACR;;;;;;;;;;;;AAyBA,SAAgB,wBAAwB,QAA2C;CAClF,IAAI,CAAC,OAAO,kBAAkB,OAAO;CAErC,MAAM,QAAQ,OAAO,YAAY,KAAK;CACtC,IAAI,UAAU,KACb,OAAO;CAER,IAAI,UAAU,QACb,OAAO;CAER,OAAO;AACR"}
1
+ {"version":3,"file":"origin.mjs","names":[],"sources":["../../src/controls/origin.ts"],"sourcesContent":["/**\n * Copyright 2026 ResQ Systems, Inc.\n * SPDX-License-Identifier: Apache-2.0\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * @fileoverview CORS origin validation — the control for WSTG-CLNT-07.\n *\n * No signature helps here: `Origin: https://evil.example` is byte-identical in shape to\n * a legitimate origin, and the vulnerability lives in how the *server* decides to\n * reflect it. So this is a decision function, not a detector.\n *\n * Every real-world CORS bypass comes from one of four shortcuts, and each is refused\n * explicitly below:\n *\n * 1. `startsWith` / `includes` matching — `https://example.com.evil.test` passes a\n * prefix check and `https://evil-example.com` passes a substring check.\n * 2. Reflecting the request's own `Origin` header into `Access-Control-Allow-Origin`.\n * 3. Accepting the literal `null` origin, which any sandboxed iframe or `data:`\n * document can send.\n * 4. Pairing `Access-Control-Allow-Origin: *` with\n * `Access-Control-Allow-Credentials: true`.\n *\n * @module @resq-systems/security/controls/origin\n */\n\n//#region Normalization\n\n/** Default ports omitted from an origin's serialized form. */\nconst DEFAULT_PORTS: Readonly<Record<string, string>> = {\n\t\"http:\": \"80\",\n\t\"https:\": \"443\",\n\t\"ws:\": \"80\",\n\t\"wss:\": \"443\",\n};\n\n/** Schemes an origin may use. Anything else is refused before comparison. */\nconst ALLOWED_SCHEMES = new Set([\"http:\", \"https:\", \"ws:\", \"wss:\"]);\n\n/**\n * Reduce an origin string to its canonical serialized form.\n *\n * Lowercases the scheme and host, drops a redundant default port, and refuses anything\n * carrying a path, query, fragment, or userinfo — none of which belongs in an origin,\n * and each of which is a way to smuggle a different host past a careless comparison.\n *\n * @param origin - Raw `Origin` header value.\n * @returns The canonical origin (`https://example.com`, `https://example.com:8443`), or\n * `null` when the value is not a well-formed, comparable origin. The literal `\"null\"`\n * origin always yields `null`.\n *\n * @example\n * ```ts\n * normalizeOrigin(\"HTTPS://Example.COM:443\"); // \"https://example.com\"\n * normalizeOrigin(\"https://example.com/path\"); // null — origins carry no path\n * normalizeOrigin(\"null\"); // null\n * ```\n */\nexport function normalizeOrigin(origin: string): string | null {\n\tif (typeof origin !== \"string\") return null;\n\n\tconst trimmed = origin.trim();\n\t// Any sandboxed iframe, `data:` document, or cross-origin redirect can present\n\t// `null`. It is never a grant of trust, so it never survives normalization.\n\tif (trimmed.length === 0 || trimmed === \"null\" || trimmed === \"*\") return null;\n\n\tlet parsed: URL;\n\ttry {\n\t\tparsed = new URL(trimmed);\n\t} catch {\n\t\treturn null;\n\t}\n\n\tif (!ALLOWED_SCHEMES.has(parsed.protocol)) return null;\n\tif (parsed.username !== \"\" || parsed.password !== \"\") return null;\n\tif (parsed.search !== \"\" || parsed.hash !== \"\") return null;\n\t// `new URL(\"https://example.com\")` yields pathname \"/\", the only acceptable value;\n\t// anything longer means a path was supplied and this is not a bare origin.\n\tif (parsed.pathname !== \"/\") return null;\n\tif (parsed.hostname.length === 0) return null;\n\n\tconst port = parsed.port === DEFAULT_PORTS[parsed.protocol] ? \"\" : parsed.port;\n\tconst host = parsed.hostname.toLowerCase();\n\treturn port === \"\" ? `${parsed.protocol}//${host}` : `${parsed.protocol}//${host}:${port}`;\n}\n\n//#endregion\n\n//#region Matching\n\n/** Options for {@link isAllowedOrigin}. */\nexport interface OriginPolicyOptions {\n\t/**\n\t * Parent origins whose **subdomains** are also accepted. Matching is on label\n\t * boundaries, so `https://example.com` admits `https://app.example.com` but never\n\t * `https://example.com.evil.test` and never `https://notexample.com`.\n\t *\n\t * Off by default. Enabling it makes every subdomain as trusted as the parent —\n\t * including any subdomain an attacker manages to take over, which is WSTG-CONF-10.\n\t */\n\treadonly allowSubdomainsOf?: readonly string[];\n}\n\n/** True when `host` is a strict subdomain of `parentHost`, on a label boundary. */\nfunction isSubdomainOf(host: string, parentHost: string): boolean {\n\treturn host.length > parentHost.length && host.endsWith(`.${parentHost}`);\n}\n\n/**\n * Decide whether an origin is allowed.\n *\n * Both sides are normalized, then matched **exactly**. There is no prefix, suffix, or\n * substring path through this function, and no wildcard: the allowlist is a list of\n * origins, not a list of patterns.\n *\n * @param origin - The request's `Origin` header value.\n * @param allowlist - Origins to accept. Entries that fail normalization are skipped\n * rather than silently widening the policy.\n * @param options - See {@link OriginPolicyOptions}.\n * @returns `true` only when the origin is well-formed and present in the allowlist.\n *\n * @example\n * ```ts\n * const ALLOWED = [\"https://app.example.com\", \"https://admin.example.com\"];\n *\n * if (isAllowedOrigin(req.headers.origin ?? \"\", ALLOWED)) {\n * // Echo the *normalized allowlisted* value, never the raw request header.\n * res.setHeader(\"Access-Control-Allow-Origin\", normalizeOrigin(req.headers.origin)!);\n * res.setHeader(\"Vary\", \"Origin\");\n * }\n * ```\n */\nexport function isAllowedOrigin(\n\torigin: string,\n\tallowlist: readonly string[],\n\toptions: OriginPolicyOptions = {},\n): boolean {\n\tconst candidate = normalizeOrigin(origin);\n\tif (candidate === null) return false;\n\n\tconst entries = Array.isArray(allowlist) ? allowlist : [];\n\tfor (const entry of entries) {\n\t\tif (normalizeOrigin(entry) === candidate) return true;\n\t}\n\n\t// Checked after the exact list, not instead of it: an empty `allowlist` combined\n\t// with a populated `allowSubdomainsOf` is a legitimate configuration, and an early\n\t// return on `allowlist.length === 0` would silently deny every request.\n\tconst parents = options.allowSubdomainsOf;\n\tif (!Array.isArray(parents) || parents.length === 0) return false;\n\n\t// Scheme, port, and host are compared separately so a subdomain grant cannot also\n\t// downgrade the scheme or move the port.\n\tconst candidateUrl = new URL(candidate);\n\tfor (const parent of parents) {\n\t\tconst normalizedParent = normalizeOrigin(parent);\n\t\tif (normalizedParent === null) continue;\n\n\t\tconst parentUrl = new URL(normalizedParent);\n\t\tif (parentUrl.protocol !== candidateUrl.protocol) continue;\n\t\tif (parentUrl.port !== candidateUrl.port) continue;\n\t\tif (isSubdomainOf(candidateUrl.hostname, parentUrl.hostname)) return true;\n\t}\n\n\treturn false;\n}\n\n//#endregion\n\n//#region Policy assertion\n\n/** A CORS response configuration, checked for the credentialed-wildcard mistake. */\nexport interface CorsResponsePolicy {\n\t/** Value destined for `Access-Control-Allow-Origin`. */\n\treadonly allowOrigin: string;\n\t/** Whether `Access-Control-Allow-Credentials: true` will be sent. */\n\treadonly allowCredentials: boolean;\n}\n\n/**\n * Reject the CORS combinations that browsers treat as an error and servers still ship:\n * `Access-Control-Allow-Origin: *` or `null` together with\n * `Access-Control-Allow-Credentials: true`.\n *\n * Call it where response headers are assembled, so the mistake fails a test rather than\n * reaching production.\n *\n * @param policy - The headers about to be sent.\n * @returns `null` when the combination is safe, otherwise a message naming the problem.\n */\nexport function checkCorsResponsePolicy(policy: CorsResponsePolicy): string | null {\n\tif (!policy.allowCredentials) return null;\n\n\tconst value = policy.allowOrigin.trim();\n\tif (value === \"*\") {\n\t\treturn \"Access-Control-Allow-Origin: * cannot be combined with Access-Control-Allow-Credentials: true — name the exact origin instead\";\n\t}\n\tif (value === \"null\") {\n\t\treturn \"Access-Control-Allow-Origin: null grants access to sandboxed and data: documents — name the exact origin instead\";\n\t}\n\treturn null;\n}\n\n//#endregion\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyCA,MAAM,gBAAkD;CACvD,SAAS;CACT,UAAU;CACV,OAAO;CACP,QAAQ;AACT;;AAGA,MAAM,kCAAkB,IAAI,IAAI;CAAC;CAAS;CAAU;CAAO;AAAM,CAAC;;;;;;;;;;;;;;;;;;;;AAqBlE,SAAgB,gBAAgB,QAA+B;CAC9D,IAAI,OAAO,WAAW,UAAU,OAAO;CAEvC,MAAM,UAAU,OAAO,KAAK;CAG5B,IAAI,QAAQ,WAAW,KAAK,YAAY,UAAU,YAAY,KAAK,OAAO;CAE1E,IAAI;CACJ,IAAI;EACH,SAAS,IAAI,IAAI,OAAO;CACzB,QAAQ;EACP,OAAO;CACR;CAEA,IAAI,CAAC,gBAAgB,IAAI,OAAO,QAAQ,GAAG,OAAO;CAClD,IAAI,OAAO,aAAa,MAAM,OAAO,aAAa,IAAI,OAAO;CAC7D,IAAI,OAAO,WAAW,MAAM,OAAO,SAAS,IAAI,OAAO;CAGvD,IAAI,OAAO,aAAa,KAAK,OAAO;CACpC,IAAI,OAAO,SAAS,WAAW,GAAG,OAAO;CAEzC,MAAM,OAAO,OAAO,SAAS,cAAc,OAAO,YAAY,KAAK,OAAO;CAC1E,MAAM,OAAO,OAAO,SAAS,YAAY;CACzC,OAAO,SAAS,KAAK,GAAG,OAAO,SAAS,IAAI,SAAS,GAAG,OAAO,SAAS,IAAI,KAAK,GAAG;AACrF;;AAoBA,SAAS,cAAc,MAAc,YAA6B;CACjE,OAAO,KAAK,SAAS,WAAW,UAAU,KAAK,SAAS,IAAI,YAAY;AACzE;;;;;;;;;;;;;;;;;;;;;;;;;AA0BA,SAAgB,gBACf,QACA,WACA,UAA+B,CAAC,GACtB;CACV,MAAM,YAAY,gBAAgB,MAAM;CACxC,IAAI,cAAc,MAAM,OAAO;CAE/B,MAAM,UAAU,MAAM,QAAQ,SAAS,IAAI,YAAY,CAAC;CACxD,KAAK,MAAM,SAAS,SACnB,IAAI,gBAAgB,KAAK,MAAM,WAAW,OAAO;CAMlD,MAAM,UAAU,QAAQ;CACxB,IAAI,CAAC,MAAM,QAAQ,OAAO,KAAK,QAAQ,WAAW,GAAG,OAAO;CAI5D,MAAM,eAAe,IAAI,IAAI,SAAS;CACtC,KAAK,MAAM,UAAU,SAAS;EAC7B,MAAM,mBAAmB,gBAAgB,MAAM;EAC/C,IAAI,qBAAqB,MAAM;EAE/B,MAAM,YAAY,IAAI,IAAI,gBAAgB;EAC1C,IAAI,UAAU,aAAa,aAAa,UAAU;EAClD,IAAI,UAAU,SAAS,aAAa,MAAM;EAC1C,IAAI,cAAc,aAAa,UAAU,UAAU,QAAQ,GAAG,OAAO;CACtE;CAEA,OAAO;AACR;;;;;;;;;;;;AAyBA,SAAgB,wBAAwB,QAA2C;CAClF,IAAI,CAAC,OAAO,kBAAkB,OAAO;CAErC,MAAM,QAAQ,OAAO,YAAY,KAAK;CACtC,IAAI,UAAU,KACb,OAAO;CAER,IAAI,UAAU,QACb,OAAO;CAER,OAAO;AACR"}
@@ -1,6 +1,7 @@
1
1
  //#region src/controls/payload.d.ts
2
2
  /**
3
3
  * Copyright 2026 ResQ Systems, Inc.
4
+ * SPDX-License-Identifier: Apache-2.0
4
5
  *
5
6
  * Licensed under the Apache License, Version 2.0 (the "License");
6
7
  * you may not use this file except in compliance with the License.
@@ -21,7 +22,7 @@
21
22
  * @module @resq-systems/security/controls/payload
22
23
  */
23
24
  /** Bounds for {@link checkJsonPayloadLimits}. */
24
- interface JsonPayloadLimits {
25
+ export interface JsonPayloadLimits {
25
26
  /** Deepest container nesting. Defaults to 100. */
26
27
  readonly maxDepth?: number;
27
28
  /** Most entries in any single array. Defaults to 10 000. */
@@ -34,7 +35,7 @@ interface JsonPayloadLimits {
34
35
  readonly maxLength?: number;
35
36
  }
36
37
  /** Result of {@link checkJsonPayloadLimits}. */
37
- interface JsonPayloadReport {
38
+ export interface JsonPayloadReport {
38
39
  /** Deepest container nesting reached. */
39
40
  readonly depth: number;
40
41
  /** Largest array seen, by entry count. */
@@ -78,7 +79,6 @@ interface JsonPayloadReport {
78
79
  * }
79
80
  * ```
80
81
  */
81
- declare function checkJsonPayloadLimits(text: string, limits?: JsonPayloadLimits): JsonPayloadReport;
82
+ export declare function checkJsonPayloadLimits(text: string, limits?: JsonPayloadLimits): JsonPayloadReport;
82
83
  //#endregion
83
- export { JsonPayloadLimits, JsonPayloadReport, checkJsonPayloadLimits };
84
84
  //# sourceMappingURL=payload.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"payload.d.mts","names":[],"sources":["../../src/controls/payload.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;UA0BiB;;WAEP;;WAEA;;WAEA;;WAEA;;WAEA;;;UAIO;;WAEP;;WAEA;;WAEA;;WAEA;;WAEA;;WAEA;;WAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA6DM,uBACf,cACA,SAAQ,oBACN"}
1
+ {"version":3,"file":"payload.d.mts","names":[],"sources":["../../src/controls/payload.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;iBA2BiB;;WAEP;;WAEA;;WAEA;;WAEA;;WAEA;;;iBAIO;;WAEP;;WAEA;;WAEA;;WAEA;;WAEA;;WAEA;;WAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBA6DM,uBACf,cACA,SAAQ,oBACN"}
@@ -1 +1 @@
1
- {"version":3,"file":"payload.mjs","names":[],"sources":["../../src/controls/payload.ts"],"sourcesContent":["/**\n * Copyright 2026 ResQ Systems, Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * @fileoverview Structural bounds on a JSON payload, measured from the text before\n * anything parses it (OWASP API Security API4 — unrestricted resource consumption).\n *\n * @module @resq-systems/security/controls/payload\n */\n\n//#region Types\n\n/** Bounds for {@link checkJsonPayloadLimits}. */\nexport interface JsonPayloadLimits {\n\t/** Deepest container nesting. Defaults to 100. */\n\treadonly maxDepth?: number;\n\t/** Most entries in any single array. Defaults to 10 000. */\n\treadonly maxArrayLength?: number;\n\t/** Most keys in any single object. Defaults to 2 000. */\n\treadonly maxObjectKeys?: number;\n\t/** Longest single string value, in characters. Defaults to 1 000 000. */\n\treadonly maxStringLength?: number;\n\t/** Longest payload overall, in characters. Defaults to 5 000 000. */\n\treadonly maxLength?: number;\n}\n\n/** Result of {@link checkJsonPayloadLimits}. */\nexport interface JsonPayloadReport {\n\t/** Deepest container nesting reached. */\n\treadonly depth: number;\n\t/** Largest array seen, by entry count. */\n\treadonly arrayLength: number;\n\t/** Largest object seen, by key count. */\n\treadonly objectKeys: number;\n\t/** Longest string value seen. */\n\treadonly stringLength: number;\n\t/** Character length of the payload. */\n\treadonly length: number;\n\t/** `true` when every bound is satisfied. */\n\treadonly withinLimits: boolean;\n\t/** Names of the bounds exceeded; empty when `withinLimits`. */\n\treadonly exceeded: readonly string[];\n}\n\n//#endregion\n\n//#region Implementation\n\n/**\n * Defaults sized from real payloads rather than from what looks tidy.\n *\n * An earlier draft used 20/1000/200/10k/1M and rejected four of five ordinary bodies: a\n * 250-key dependency manifest, a 25-deep config tree, a 40 KB data-URI avatar and a\n * 2000-row page. These are backstops against a payload built to exhaust memory, not a\n * schema — a body that trips one of them is pathological rather than merely large.\n */\nconst DEFAULT_LIMITS = {\n\tmaxDepth: 100,\n\tmaxArrayLength: 10_000,\n\tmaxObjectKeys: 2_000,\n\tmaxStringLength: 1_000_000,\n\tmaxLength: 5_000_000,\n} as const satisfies Required<JsonPayloadLimits>;\n\n/**\n * Cap on the per-container counter stack.\n *\n * Without it, a payload nested 200 000 deep grows 200 000 entries of scanner state — the\n * same unbounded allocation this function exists to prevent, moved one layer down. Depth\n * is still counted past the cap; only per-container entry counts stop being tracked, and\n * a payload that deep has already exceeded `maxDepth` by a wide margin.\n */\nconst MAX_COUNTER_STACK = 512;\n\n/**\n * Measure a JSON payload's structure without parsing it.\n *\n * `JSON.parse` allocates the whole object graph before a caller can inspect anything, so\n * a body designed to exhaust memory has already succeeded by the time validation runs.\n * This is one linear pass over the *text*: it counts nesting, container sizes and string\n * lengths, and never builds a value.\n *\n * Reporting rather than enforcing, deliberately. It returns what it measured and which\n * bounds were exceeded; the caller decides. Schema validation remains the real control\n * for shape — this only bounds the cost of getting there.\n *\n * Malformed JSON is not diagnosed. The scanner is a bracket counter, so an invalid or\n * truncated payload yields whatever it measured before running out; use `JSON.parse` for\n * validity, once this has bounded the cost.\n *\n * @param text - The raw JSON text, before parsing.\n * @param limits - See {@link JsonPayloadLimits}.\n * @returns The measured {@link JsonPayloadReport}. Never throws.\n *\n * @example\n * ```ts\n * const report = checkJsonPayloadLimits(await request.text());\n * if (!report.withinLimits) {\n * return new Response(`Payload rejected: ${report.exceeded.join(\", \")}`, { status: 413 });\n * }\n * ```\n */\nexport function checkJsonPayloadLimits(\n\ttext: string,\n\tlimits: JsonPayloadLimits = {},\n): JsonPayloadReport {\n\tconst { maxDepth, maxArrayLength, maxObjectKeys, maxStringLength, maxLength } = {\n\t\t...DEFAULT_LIMITS,\n\t\t...limits,\n\t};\n\n\tif (typeof text !== \"string\") {\n\t\treturn {\n\t\t\tdepth: 0,\n\t\t\tarrayLength: 0,\n\t\t\tobjectKeys: 0,\n\t\t\tstringLength: 0,\n\t\t\tlength: 0,\n\t\t\twithinLimits: true,\n\t\t\texceeded: [],\n\t\t};\n\t}\n\n\tlet depth = 0;\n\tlet deepest = 0;\n\tlet arrayLength = 0;\n\tlet objectKeys = 0;\n\tlet stringLength = 0;\n\n\t/** Entry counts for the containers currently open, innermost last. */\n\tconst counters: { isArray: boolean; count: number }[] = [];\n\tlet inString = false;\n\tlet escaped = false;\n\tlet stringStart = 0;\n\t/** Whether the innermost container has seen content since the last comma. */\n\tlet sawContent = false;\n\n\tfor (let index = 0; index < text.length; index++) {\n\t\tconst char = text[index];\n\n\t\tif (inString) {\n\t\t\tif (escaped) {\n\t\t\t\tescaped = false;\n\t\t\t} else if (char === \"\\\\\") {\n\t\t\t\tescaped = true;\n\t\t\t} else if (char === '\"') {\n\t\t\t\tinString = false;\n\t\t\t\tconst measured = index - stringStart;\n\t\t\t\tif (measured > stringLength) stringLength = measured;\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === '\"') {\n\t\t\tinString = true;\n\t\t\tstringStart = index + 1;\n\t\t\tsawContent = true;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === \"{\" || char === \"[\") {\n\t\t\tdepth++;\n\t\t\tif (depth > deepest) deepest = depth;\n\t\t\tif (counters.length < MAX_COUNTER_STACK) {\n\t\t\t\tcounters.push({ isArray: char === \"[\", count: 0 });\n\t\t\t}\n\t\t\tsawContent = false;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === \"}\" || char === \"]\") {\n\t\t\tconst container = counters.pop();\n\t\t\tif (container !== undefined) {\n\t\t\t\t// A container holding any content has one more entry than it has commas.\n\t\t\t\tconst entries = sawContent || container.count > 0 ? container.count + 1 : 0;\n\t\t\t\tif (container.isArray) {\n\t\t\t\t\tif (entries > arrayLength) arrayLength = entries;\n\t\t\t\t} else if (entries > objectKeys) {\n\t\t\t\t\tobjectKeys = entries;\n\t\t\t\t}\n\t\t\t}\n\t\t\tdepth = Math.max(0, depth - 1);\n\t\t\tsawContent = true;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === \",\") {\n\t\t\tconst container = counters[counters.length - 1];\n\t\t\tif (container !== undefined) container.count++;\n\t\t\tsawContent = false;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char !== undefined && char !== \":\" && char.trim().length > 0) sawContent = true;\n\t}\n\n\tconst exceeded: string[] = [];\n\tif (deepest > maxDepth) exceeded.push(\"depth\");\n\tif (arrayLength > maxArrayLength) exceeded.push(\"arrayLength\");\n\tif (objectKeys > maxObjectKeys) exceeded.push(\"objectKeys\");\n\tif (stringLength > maxStringLength) exceeded.push(\"stringLength\");\n\tif (text.length > maxLength) exceeded.push(\"length\");\n\n\treturn {\n\t\tdepth: deepest,\n\t\tarrayLength,\n\t\tobjectKeys,\n\t\tstringLength,\n\t\tlength: text.length,\n\t\twithinLimits: exceeded.length === 0,\n\t\texceeded,\n\t};\n}\n\n//#endregion\n"],"mappings":";;;;;;;;;AAqEA,MAAM,iBAAiB;CACtB,UAAU;CACV,gBAAgB;CAChB,eAAe;CACf,iBAAiB;CACjB,WAAW;AACZ;;;;;;;;;AAUA,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8B1B,SAAgB,uBACf,MACA,SAA4B,CAAC,GACT;CACpB,MAAM,EAAE,UAAU,gBAAgB,eAAe,iBAAiB,cAAc;EAC/E,GAAG;EACH,GAAG;CACJ;CAEA,IAAI,OAAO,SAAS,UACnB,OAAO;EACN,OAAO;EACP,aAAa;EACb,YAAY;EACZ,cAAc;EACd,QAAQ;EACR,cAAc;EACd,UAAU,CAAC;CACZ;CAGD,IAAI,QAAQ;CACZ,IAAI,UAAU;CACd,IAAI,cAAc;CAClB,IAAI,aAAa;CACjB,IAAI,eAAe;;CAGnB,MAAM,WAAkD,CAAC;CACzD,IAAI,WAAW;CACf,IAAI,UAAU;CACd,IAAI,cAAc;;CAElB,IAAI,aAAa;CAEjB,KAAK,IAAI,QAAQ,GAAG,QAAQ,KAAK,QAAQ,SAAS;EACjD,MAAM,OAAO,KAAK;EAElB,IAAI,UAAU;GACb,IAAI,SACH,UAAU;QACJ,IAAI,SAAS,MACnB,UAAU;QACJ,IAAI,SAAS,MAAK;IACxB,WAAW;IACX,MAAM,WAAW,QAAQ;IACzB,IAAI,WAAW,cAAc,eAAe;GAC7C;GACA;EACD;EAEA,IAAI,SAAS,MAAK;GACjB,WAAW;GACX,cAAc,QAAQ;GACtB,aAAa;GACb;EACD;EAEA,IAAI,SAAS,OAAO,SAAS,KAAK;GACjC;GACA,IAAI,QAAQ,SAAS,UAAU;GAC/B,IAAI,SAAS,SAAS,mBACrB,SAAS,KAAK;IAAE,SAAS,SAAS;IAAK,OAAO;GAAE,CAAC;GAElD,aAAa;GACb;EACD;EAEA,IAAI,SAAS,OAAO,SAAS,KAAK;GACjC,MAAM,YAAY,SAAS,IAAI;GAC/B,IAAI,cAAc,KAAA,GAAW;IAE5B,MAAM,UAAU,cAAc,UAAU,QAAQ,IAAI,UAAU,QAAQ,IAAI;IAC1E,IAAI,UAAU,SACT;SAAA,UAAU,aAAa,cAAc;IAAA,OACnC,IAAI,UAAU,YACpB,aAAa;GAEf;GACA,QAAQ,KAAK,IAAI,GAAG,QAAQ,CAAC;GAC7B,aAAa;GACb;EACD;EAEA,IAAI,SAAS,KAAK;GACjB,MAAM,YAAY,SAAS,SAAS,SAAS;GAC7C,IAAI,cAAc,KAAA,GAAW,UAAU;GACvC,aAAa;GACb;EACD;EAEA,IAAI,SAAS,KAAA,KAAa,SAAS,OAAO,KAAK,KAAK,CAAC,CAAC,SAAS,GAAG,aAAa;CAChF;CAEA,MAAM,WAAqB,CAAC;CAC5B,IAAI,UAAU,UAAU,SAAS,KAAK,OAAO;CAC7C,IAAI,cAAc,gBAAgB,SAAS,KAAK,aAAa;CAC7D,IAAI,aAAa,eAAe,SAAS,KAAK,YAAY;CAC1D,IAAI,eAAe,iBAAiB,SAAS,KAAK,cAAc;CAChE,IAAI,KAAK,SAAS,WAAW,SAAS,KAAK,QAAQ;CAEnD,OAAO;EACN,OAAO;EACP;EACA;EACA;EACA,QAAQ,KAAK;EACb,cAAc,SAAS,WAAW;EAClC;CACD;AACD"}
1
+ {"version":3,"file":"payload.mjs","names":[],"sources":["../../src/controls/payload.ts"],"sourcesContent":["/**\n * Copyright 2026 ResQ Systems, Inc.\n * SPDX-License-Identifier: Apache-2.0\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * @fileoverview Structural bounds on a JSON payload, measured from the text before\n * anything parses it (OWASP API Security API4 — unrestricted resource consumption).\n *\n * @module @resq-systems/security/controls/payload\n */\n\n//#region Types\n\n/** Bounds for {@link checkJsonPayloadLimits}. */\nexport interface JsonPayloadLimits {\n\t/** Deepest container nesting. Defaults to 100. */\n\treadonly maxDepth?: number;\n\t/** Most entries in any single array. Defaults to 10 000. */\n\treadonly maxArrayLength?: number;\n\t/** Most keys in any single object. Defaults to 2 000. */\n\treadonly maxObjectKeys?: number;\n\t/** Longest single string value, in characters. Defaults to 1 000 000. */\n\treadonly maxStringLength?: number;\n\t/** Longest payload overall, in characters. Defaults to 5 000 000. */\n\treadonly maxLength?: number;\n}\n\n/** Result of {@link checkJsonPayloadLimits}. */\nexport interface JsonPayloadReport {\n\t/** Deepest container nesting reached. */\n\treadonly depth: number;\n\t/** Largest array seen, by entry count. */\n\treadonly arrayLength: number;\n\t/** Largest object seen, by key count. */\n\treadonly objectKeys: number;\n\t/** Longest string value seen. */\n\treadonly stringLength: number;\n\t/** Character length of the payload. */\n\treadonly length: number;\n\t/** `true` when every bound is satisfied. */\n\treadonly withinLimits: boolean;\n\t/** Names of the bounds exceeded; empty when `withinLimits`. */\n\treadonly exceeded: readonly string[];\n}\n\n//#endregion\n\n//#region Implementation\n\n/**\n * Defaults sized from real payloads rather than from what looks tidy.\n *\n * An earlier draft used 20/1000/200/10k/1M and rejected four of five ordinary bodies: a\n * 250-key dependency manifest, a 25-deep config tree, a 40 KB data-URI avatar and a\n * 2000-row page. These are backstops against a payload built to exhaust memory, not a\n * schema — a body that trips one of them is pathological rather than merely large.\n */\nconst DEFAULT_LIMITS = {\n\tmaxDepth: 100,\n\tmaxArrayLength: 10_000,\n\tmaxObjectKeys: 2_000,\n\tmaxStringLength: 1_000_000,\n\tmaxLength: 5_000_000,\n} as const satisfies Required<JsonPayloadLimits>;\n\n/**\n * Cap on the per-container counter stack.\n *\n * Without it, a payload nested 200 000 deep grows 200 000 entries of scanner state — the\n * same unbounded allocation this function exists to prevent, moved one layer down. Depth\n * is still counted past the cap; only per-container entry counts stop being tracked, and\n * a payload that deep has already exceeded `maxDepth` by a wide margin.\n */\nconst MAX_COUNTER_STACK = 512;\n\n/**\n * Measure a JSON payload's structure without parsing it.\n *\n * `JSON.parse` allocates the whole object graph before a caller can inspect anything, so\n * a body designed to exhaust memory has already succeeded by the time validation runs.\n * This is one linear pass over the *text*: it counts nesting, container sizes and string\n * lengths, and never builds a value.\n *\n * Reporting rather than enforcing, deliberately. It returns what it measured and which\n * bounds were exceeded; the caller decides. Schema validation remains the real control\n * for shape — this only bounds the cost of getting there.\n *\n * Malformed JSON is not diagnosed. The scanner is a bracket counter, so an invalid or\n * truncated payload yields whatever it measured before running out; use `JSON.parse` for\n * validity, once this has bounded the cost.\n *\n * @param text - The raw JSON text, before parsing.\n * @param limits - See {@link JsonPayloadLimits}.\n * @returns The measured {@link JsonPayloadReport}. Never throws.\n *\n * @example\n * ```ts\n * const report = checkJsonPayloadLimits(await request.text());\n * if (!report.withinLimits) {\n * return new Response(`Payload rejected: ${report.exceeded.join(\", \")}`, { status: 413 });\n * }\n * ```\n */\nexport function checkJsonPayloadLimits(\n\ttext: string,\n\tlimits: JsonPayloadLimits = {},\n): JsonPayloadReport {\n\tconst { maxDepth, maxArrayLength, maxObjectKeys, maxStringLength, maxLength } = {\n\t\t...DEFAULT_LIMITS,\n\t\t...limits,\n\t};\n\n\tif (typeof text !== \"string\") {\n\t\treturn {\n\t\t\tdepth: 0,\n\t\t\tarrayLength: 0,\n\t\t\tobjectKeys: 0,\n\t\t\tstringLength: 0,\n\t\t\tlength: 0,\n\t\t\twithinLimits: true,\n\t\t\texceeded: [],\n\t\t};\n\t}\n\n\tlet depth = 0;\n\tlet deepest = 0;\n\tlet arrayLength = 0;\n\tlet objectKeys = 0;\n\tlet stringLength = 0;\n\n\t/** Entry counts for the containers currently open, innermost last. */\n\tconst counters: { isArray: boolean; count: number }[] = [];\n\tlet inString = false;\n\tlet escaped = false;\n\tlet stringStart = 0;\n\t/** Whether the innermost container has seen content since the last comma. */\n\tlet sawContent = false;\n\n\tfor (let index = 0; index < text.length; index++) {\n\t\tconst char = text[index];\n\n\t\tif (inString) {\n\t\t\tif (escaped) {\n\t\t\t\tescaped = false;\n\t\t\t} else if (char === \"\\\\\") {\n\t\t\t\tescaped = true;\n\t\t\t} else if (char === '\"') {\n\t\t\t\tinString = false;\n\t\t\t\tconst measured = index - stringStart;\n\t\t\t\tif (measured > stringLength) stringLength = measured;\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === '\"') {\n\t\t\tinString = true;\n\t\t\tstringStart = index + 1;\n\t\t\tsawContent = true;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === \"{\" || char === \"[\") {\n\t\t\tdepth++;\n\t\t\tif (depth > deepest) deepest = depth;\n\t\t\tif (counters.length < MAX_COUNTER_STACK) {\n\t\t\t\tcounters.push({ isArray: char === \"[\", count: 0 });\n\t\t\t}\n\t\t\tsawContent = false;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === \"}\" || char === \"]\") {\n\t\t\tconst container = counters.pop();\n\t\t\tif (container !== undefined) {\n\t\t\t\t// A container holding any content has one more entry than it has commas.\n\t\t\t\tconst entries = sawContent || container.count > 0 ? container.count + 1 : 0;\n\t\t\t\tif (container.isArray) {\n\t\t\t\t\tif (entries > arrayLength) arrayLength = entries;\n\t\t\t\t} else if (entries > objectKeys) {\n\t\t\t\t\tobjectKeys = entries;\n\t\t\t\t}\n\t\t\t}\n\t\t\tdepth = Math.max(0, depth - 1);\n\t\t\tsawContent = true;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === \",\") {\n\t\t\tconst container = counters[counters.length - 1];\n\t\t\tif (container !== undefined) container.count++;\n\t\t\tsawContent = false;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char !== undefined && char !== \":\" && char.trim().length > 0) sawContent = true;\n\t}\n\n\tconst exceeded: string[] = [];\n\tif (deepest > maxDepth) exceeded.push(\"depth\");\n\tif (arrayLength > maxArrayLength) exceeded.push(\"arrayLength\");\n\tif (objectKeys > maxObjectKeys) exceeded.push(\"objectKeys\");\n\tif (stringLength > maxStringLength) exceeded.push(\"stringLength\");\n\tif (text.length > maxLength) exceeded.push(\"length\");\n\n\treturn {\n\t\tdepth: deepest,\n\t\tarrayLength,\n\t\tobjectKeys,\n\t\tstringLength,\n\t\tlength: text.length,\n\t\twithinLimits: exceeded.length === 0,\n\t\texceeded,\n\t};\n}\n\n//#endregion\n"],"mappings":";;;;;;;;;AAsEA,MAAM,iBAAiB;CACtB,UAAU;CACV,gBAAgB;CAChB,eAAe;CACf,iBAAiB;CACjB,WAAW;AACZ;;;;;;;;;AAUA,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8B1B,SAAgB,uBACf,MACA,SAA4B,CAAC,GACT;CACpB,MAAM,EAAE,UAAU,gBAAgB,eAAe,iBAAiB,cAAc;EAC/E,GAAG;EACH,GAAG;CACJ;CAEA,IAAI,OAAO,SAAS,UACnB,OAAO;EACN,OAAO;EACP,aAAa;EACb,YAAY;EACZ,cAAc;EACd,QAAQ;EACR,cAAc;EACd,UAAU,CAAC;CACZ;CAGD,IAAI,QAAQ;CACZ,IAAI,UAAU;CACd,IAAI,cAAc;CAClB,IAAI,aAAa;CACjB,IAAI,eAAe;;CAGnB,MAAM,WAAkD,CAAC;CACzD,IAAI,WAAW;CACf,IAAI,UAAU;CACd,IAAI,cAAc;;CAElB,IAAI,aAAa;CAEjB,KAAK,IAAI,QAAQ,GAAG,QAAQ,KAAK,QAAQ,SAAS;EACjD,MAAM,OAAO,KAAK;EAElB,IAAI,UAAU;GACb,IAAI,SACH,UAAU;QACJ,IAAI,SAAS,MACnB,UAAU;QACJ,IAAI,SAAS,MAAK;IACxB,WAAW;IACX,MAAM,WAAW,QAAQ;IACzB,IAAI,WAAW,cAAc,eAAe;GAC7C;GACA;EACD;EAEA,IAAI,SAAS,MAAK;GACjB,WAAW;GACX,cAAc,QAAQ;GACtB,aAAa;GACb;EACD;EAEA,IAAI,SAAS,OAAO,SAAS,KAAK;GACjC;GACA,IAAI,QAAQ,SAAS,UAAU;GAC/B,IAAI,SAAS,SAAS,mBACrB,SAAS,KAAK;IAAE,SAAS,SAAS;IAAK,OAAO;GAAE,CAAC;GAElD,aAAa;GACb;EACD;EAEA,IAAI,SAAS,OAAO,SAAS,KAAK;GACjC,MAAM,YAAY,SAAS,IAAI;GAC/B,IAAI,cAAc,KAAA,GAAW;IAE5B,MAAM,UAAU,cAAc,UAAU,QAAQ,IAAI,UAAU,QAAQ,IAAI;IAC1E,IAAI,UAAU,SACT;SAAA,UAAU,aAAa,cAAc;IAAA,OACnC,IAAI,UAAU,YACpB,aAAa;GAEf;GACA,QAAQ,KAAK,IAAI,GAAG,QAAQ,CAAC;GAC7B,aAAa;GACb;EACD;EAEA,IAAI,SAAS,KAAK;GACjB,MAAM,YAAY,SAAS,SAAS,SAAS;GAC7C,IAAI,cAAc,KAAA,GAAW,UAAU;GACvC,aAAa;GACb;EACD;EAEA,IAAI,SAAS,KAAA,KAAa,SAAS,OAAO,KAAK,KAAK,CAAC,CAAC,SAAS,GAAG,aAAa;CAChF;CAEA,MAAM,WAAqB,CAAC;CAC5B,IAAI,UAAU,UAAU,SAAS,KAAK,OAAO;CAC7C,IAAI,cAAc,gBAAgB,SAAS,KAAK,aAAa;CAC7D,IAAI,aAAa,eAAe,SAAS,KAAK,YAAY;CAC1D,IAAI,eAAe,iBAAiB,SAAS,KAAK,cAAc;CAChE,IAAI,KAAK,SAAS,WAAW,SAAS,KAAK,QAAQ;CAEnD,OAAO;EACN,OAAO;EACP;EACA;EACA;EACA,QAAQ,KAAK;EACb,cAAc,SAAS,WAAW;EAClC;CACD;AACD"}
@@ -1,6 +1,7 @@
1
1
  //#region src/controls/query.d.ts
2
2
  /**
3
3
  * Copyright 2026 ResQ Systems, Inc.
4
+ * SPDX-License-Identifier: Apache-2.0
4
5
  *
5
6
  * Licensed under the Apache License, Version 2.0 (the "License");
6
7
  * you may not use this file except in compliance with the License.
@@ -37,9 +38,9 @@
37
38
  * validateJsonpCallback("window.eval"); // false — every segment is checked
38
39
  * ```
39
40
  */
40
- declare function validateJsonpCallback(callback: string): boolean;
41
+ export declare function validateJsonpCallback(callback: string): boolean;
41
42
  /** Limits applied by {@link analyzeQueryComplexity}. */
42
- interface QueryComplexityLimits {
43
+ export interface QueryComplexityLimits {
43
44
  /** Maximum nesting depth. Defaults to 10. */
44
45
  readonly maxDepth?: number;
45
46
  /** Maximum aliased fields. Defaults to 50. */
@@ -50,7 +51,7 @@ interface QueryComplexityLimits {
50
51
  readonly maxLength?: number;
51
52
  }
52
53
  /** Measured shape of a query. */
53
- interface QueryComplexity {
54
+ export interface QueryComplexity {
54
55
  /** Deepest brace nesting reached. */
55
56
  readonly depth: number;
56
57
  /** Count of `alias: field` constructs. Approximate — see the function note. */
@@ -91,9 +92,9 @@ interface QueryComplexity {
91
92
  * }
92
93
  * ```
93
94
  */
94
- declare function analyzeQueryComplexity(query: string, limits?: QueryComplexityLimits): QueryComplexity;
95
+ export declare function analyzeQueryComplexity(query: string, limits?: QueryComplexityLimits): QueryComplexity;
95
96
  /** Limits for {@link analyzeGraphQLRequest}. */
96
- interface GraphQLRequestLimits extends QueryComplexityLimits {
97
+ export interface GraphQLRequestLimits extends QueryComplexityLimits {
97
98
  /**
98
99
  * Top-level operations permitted across the whole request. Defaults to 10.
99
100
  *
@@ -105,7 +106,7 @@ interface GraphQLRequestLimits extends QueryComplexityLimits {
105
106
  readonly maxDocuments?: number;
106
107
  }
107
108
  /** Result of {@link analyzeGraphQLRequest}. */
108
- interface GraphQLRequestAnalysis {
109
+ export interface GraphQLRequestAnalysis {
109
110
  /** Documents found in the request. `1` for an ordinary single query. */
110
111
  readonly documents: number;
111
112
  /** Top-level operations summed across every document. */
@@ -163,7 +164,6 @@ interface GraphQLRequestAnalysis {
163
164
  * }
164
165
  * ```
165
166
  */
166
- declare function analyzeGraphQLRequest(body: unknown, limits?: GraphQLRequestLimits): GraphQLRequestAnalysis;
167
+ export declare function analyzeGraphQLRequest(body: unknown, limits?: GraphQLRequestLimits): GraphQLRequestAnalysis;
167
168
  //#endregion
168
- export { GraphQLRequestAnalysis, GraphQLRequestLimits, QueryComplexity, QueryComplexityLimits, analyzeGraphQLRequest, analyzeQueryComplexity, validateJsonpCallback };
169
169
  //# sourceMappingURL=query.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"query.d.mts","names":[],"sources":["../../src/controls/query.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA6EgB,sBAAsB;;UAgBrB;;WAEP;;WAEA;;WAEA;;WAEA;;;UAIO;;WAEP;;WAEA;;WAEA;;WAEA;;WAEA;;WAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAyCM,uBACf,eACA,SAAQ,wBACN;;UAoFc,6BAA6B;;;;;;;WAOpC;;WAEA;;;UAIO;;WAEP;;WAEA;;WAEA,OAAO;;WAEP;;;;;;;;;;;;;;WAcA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAiNM,sBACf,eACA,SAAQ,uBACN"}
1
+ {"version":3,"file":"query.d.mts","names":[],"sources":["../../src/controls/query.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBA8EgB,sBAAsB;;iBAgBrB;;WAEP;;WAEA;;WAEA;;WAEA;;;iBAIO;;WAEP;;WAEA;;WAEA;;WAEA;;WAEA;;WAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBAyCM,uBACf,eACA,SAAQ,wBACN;;iBAoFc,6BAA6B;;;;;;;WAOpC;;WAEA;;;iBAIO;;WAEP;;WAEA;;WAEA,OAAO;;WAEP;;;;;;;;;;;;;;WAcA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBAiNM,sBACf,eACA,SAAQ,uBACN"}
@@ -1,6 +1,7 @@
1
1
  //#region src/controls/query.ts
2
2
  /**
3
3
  * Copyright 2026 ResQ Systems, Inc.
4
+ * SPDX-License-Identifier: Apache-2.0
4
5
  *
5
6
  * Licensed under the Apache License, Version 2.0 (the "License");
6
7
  * you may not use this file except in compliance with the License.