@resq-systems/security 1.0.5 → 2.0.0

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 (108) hide show
  1. package/README.md +236 -33
  2. package/lib/controls/address.d.mts +142 -0
  3. package/lib/controls/address.d.mts.map +1 -0
  4. package/lib/controls/address.mjs +533 -0
  5. package/lib/controls/address.mjs.map +1 -0
  6. package/lib/controls/csrf.d.mts +91 -0
  7. package/lib/controls/csrf.d.mts.map +1 -0
  8. package/lib/controls/csrf.mjs +200 -0
  9. package/lib/controls/csrf.mjs.map +1 -0
  10. package/lib/controls/index.d.mts +8 -0
  11. package/lib/controls/index.mjs +8 -0
  12. package/lib/controls/origin.d.mts +95 -0
  13. package/lib/controls/origin.d.mts.map +1 -0
  14. package/lib/controls/origin.mjs +156 -0
  15. package/lib/controls/origin.mjs.map +1 -0
  16. package/lib/controls/payload.d.mts +84 -0
  17. package/lib/controls/payload.d.mts.map +1 -0
  18. package/lib/controls/payload.mjs +147 -0
  19. package/lib/controls/payload.mjs.map +1 -0
  20. package/lib/controls/query.d.mts +157 -0
  21. package/lib/controls/query.d.mts.map +1 -0
  22. package/lib/controls/query.mjs +368 -0
  23. package/lib/controls/query.mjs.map +1 -0
  24. package/lib/controls/redirect.d.mts +92 -0
  25. package/lib/controls/redirect.d.mts.map +1 -0
  26. package/lib/controls/redirect.mjs +110 -0
  27. package/lib/controls/redirect.mjs.map +1 -0
  28. package/lib/controls/upload.d.mts +108 -0
  29. package/lib/controls/upload.d.mts.map +1 -0
  30. package/lib/controls/upload.mjs +374 -0
  31. package/lib/controls/upload.mjs.map +1 -0
  32. package/lib/crypto.d.mts +18 -5
  33. package/lib/crypto.d.mts.map +1 -1
  34. package/lib/crypto.mjs +35 -24
  35. package/lib/crypto.mjs.map +1 -1
  36. package/lib/hash.d.mts +51 -6
  37. package/lib/hash.d.mts.map +1 -1
  38. package/lib/hash.mjs +51 -6
  39. package/lib/hash.mjs.map +1 -1
  40. package/lib/index.d.mts +17 -2
  41. package/lib/index.mjs +19 -2
  42. package/lib/paths.d.mts +92 -0
  43. package/lib/paths.d.mts.map +1 -0
  44. package/lib/paths.mjs +140 -0
  45. package/lib/paths.mjs.map +1 -0
  46. package/lib/sanitize.d.mts +137 -35
  47. package/lib/sanitize.d.mts.map +1 -1
  48. package/lib/sanitize.mjs +170 -46
  49. package/lib/sanitize.mjs.map +1 -1
  50. package/lib/threats/capec.generated.d.mts +59 -0
  51. package/lib/threats/capec.generated.d.mts.map +1 -0
  52. package/lib/threats/capec.generated.mjs +644 -0
  53. package/lib/threats/capec.generated.mjs.map +1 -0
  54. package/lib/threats/engine.d.mts +94 -0
  55. package/lib/threats/engine.d.mts.map +1 -0
  56. package/lib/threats/engine.mjs +167 -0
  57. package/lib/threats/engine.mjs.map +1 -0
  58. package/lib/threats/index.d.mts +11 -0
  59. package/lib/threats/index.mjs +11 -0
  60. package/lib/threats/rules/datastore.d.mts +13 -0
  61. package/lib/threats/rules/datastore.d.mts.map +1 -0
  62. package/lib/threats/rules/datastore.mjs +366 -0
  63. package/lib/threats/rules/datastore.mjs.map +1 -0
  64. package/lib/threats/rules/index.d.mts +54 -0
  65. package/lib/threats/rules/index.d.mts.map +1 -0
  66. package/lib/threats/rules/index.mjs +121 -0
  67. package/lib/threats/rules/index.mjs.map +1 -0
  68. package/lib/threats/rules/markup.d.mts +28 -0
  69. package/lib/threats/rules/markup.d.mts.map +1 -0
  70. package/lib/threats/rules/markup.mjs +373 -0
  71. package/lib/threats/rules/markup.mjs.map +1 -0
  72. package/lib/threats/rules/protocol.d.mts +49 -0
  73. package/lib/threats/rules/protocol.d.mts.map +1 -0
  74. package/lib/threats/rules/protocol.mjs +175 -0
  75. package/lib/threats/rules/protocol.mjs.map +1 -0
  76. package/lib/threats/rules/system.d.mts +19 -0
  77. package/lib/threats/rules/system.d.mts.map +1 -0
  78. package/lib/threats/rules/system.mjs +455 -0
  79. package/lib/threats/rules/system.mjs.map +1 -0
  80. package/lib/threats/rules/web.d.mts +26 -0
  81. package/lib/threats/rules/web.d.mts.map +1 -0
  82. package/lib/threats/rules/web.mjs +412 -0
  83. package/lib/threats/rules/web.mjs.map +1 -0
  84. package/lib/threats/scoring.d.mts +59 -0
  85. package/lib/threats/scoring.d.mts.map +1 -0
  86. package/lib/threats/scoring.mjs +111 -0
  87. package/lib/threats/scoring.mjs.map +1 -0
  88. package/lib/threats/types.d.mts +245 -0
  89. package/lib/threats/types.d.mts.map +1 -0
  90. package/lib/threats/types.mjs +52 -0
  91. package/lib/threats/types.mjs.map +1 -0
  92. package/lib/threats/variants.d.mts +57 -0
  93. package/lib/threats/variants.d.mts.map +1 -0
  94. package/lib/threats/variants.mjs +144 -0
  95. package/lib/threats/variants.mjs.map +1 -0
  96. package/lib/unicode/confusables.d.mts +82 -0
  97. package/lib/unicode/confusables.d.mts.map +1 -0
  98. package/lib/unicode/confusables.mjs +954 -0
  99. package/lib/unicode/confusables.mjs.map +1 -0
  100. package/lib/unicode/index.d.mts +126 -0
  101. package/lib/unicode/index.d.mts.map +1 -0
  102. package/lib/unicode/index.mjs +288 -0
  103. package/lib/unicode/index.mjs.map +1 -0
  104. package/lib/validators.d.mts +341 -164
  105. package/lib/validators.d.mts.map +1 -1
  106. package/lib/validators.mjs +519 -338
  107. package/lib/validators.mjs.map +1 -1
  108. package/package.json +35 -8
@@ -0,0 +1,200 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+ //#region src/controls/csrf.ts
3
+ /**
4
+ * Copyright 2026 ResQ Systems, Inc.
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */
18
+ /**
19
+ * @fileoverview Signed CSRF tokens — the control for WSTG-SESS-05.
20
+ *
21
+ * A forged cross-site request is byte-identical to a genuine one; that identity *is*
22
+ * the weakness, which is why no signature in the threat catalog can detect it. The
23
+ * defence is to require a value the attacker's page can neither read nor predict.
24
+ *
25
+ * This implements the **signed double-submit** pattern: the token carries its own
26
+ * HMAC, so verification needs the server secret and no per-session storage. Bind it to
27
+ * a session with `sessionId` whenever one exists — an unbound token verifies for any
28
+ * user, which still stops a plain cross-site forgery but not a logged-in attacker
29
+ * minting a token and planting it on a victim.
30
+ *
31
+ * **This is one layer.** Also ship `SameSite=Lax` (or `Strict`) on session cookies and
32
+ * validate `Origin` with `isAllowedOrigin` on state-changing requests. Each of the
33
+ * three can be bypassed alone.
34
+ *
35
+ * @module @resq-systems/security/controls/csrf
36
+ */
37
+ /** Bytes of randomness in the token nonce — 128 bits. */
38
+ const NONCE_BYTES = 16;
39
+ /** Default token lifetime: two hours. */
40
+ const DEFAULT_TTL_MS = 7200 * 1e3;
41
+ /** Field separator. Outside the base64url alphabet, so it cannot occur within a field. */
42
+ const SEPARATOR = ".";
43
+ /** Radix for the expiry field, chosen to keep the token short. */
44
+ const TIMESTAMP_RADIX = 36;
45
+ /** Fixed key for the length-blinding digest in {@link constantTimeEquals}. */
46
+ const BLINDING_KEY = "resq-csrf-length-blind";
47
+ /**
48
+ * Whether a string is well-formed UTF-16 — no unpaired surrogate.
49
+ *
50
+ * This matters because `createHmac().update(string)` encodes as UTF-8, which maps
51
+ * *every* lone surrogate to the same replacement bytes `EF BF BD`, while
52
+ * `String.length` counts UTF-16 code units. The length prefix therefore stops being
53
+ * injective for ill-formed input: `"tenant-\uD800"` and `"tenant-�"` hash
54
+ * identically, and a token bound to one verifies for the other.
55
+ *
56
+ * Implemented by scanning rather than calling `String.prototype.isWellFormed`, so the
57
+ * check does not depend on the ES2024 lib being configured.
58
+ */
59
+ function isWellFormedUtf16(value) {
60
+ for (let i = 0; i < value.length; i++) {
61
+ const code = value.charCodeAt(i);
62
+ if (code < 55296 || code > 57343) continue;
63
+ if (code >= 56320) return false;
64
+ const next = value.charCodeAt(i + 1);
65
+ if (Number.isNaN(next) || next < 56320 || next > 57343) return false;
66
+ i++;
67
+ }
68
+ return true;
69
+ }
70
+ /** A session identifier usable for binding: a well-formed string. */
71
+ function isBindableSessionId(value) {
72
+ return typeof value === "string" && isWellFormedUtf16(value);
73
+ }
74
+ /**
75
+ * Compute the token signature over the nonce, expiry, and bound session.
76
+ *
77
+ * Each field is length-prefixed before hashing. Without that, a different
78
+ * (nonce, expiry, sessionId) split could produce the same concatenated input — so a
79
+ * token bound to session `"ab"` would verify against session `"a"` with a shifted
80
+ * nonce.
81
+ *
82
+ * Callers must pass a well-formed string; see {@link isBindableSessionId}. Both
83
+ * entry points enforce it, because template-stringifying a non-string collapses every
84
+ * object to `"[object Object]"` and every such session to the *same* signature.
85
+ */
86
+ function sign(secret, nonce, expiry, sessionId) {
87
+ return createHmac("sha256", secret).update(`${nonce.length}:${nonce}|${expiry.length}:${expiry}|${sessionId.length}:${sessionId}`).digest("base64url");
88
+ }
89
+ /**
90
+ * Mint a signed CSRF token.
91
+ *
92
+ * Send it to the client in a readable cookie *and* require it back in a header or form
93
+ * field. A cross-origin page can cause the cookie to be sent but cannot read it, so it
94
+ * cannot populate the second copy.
95
+ *
96
+ * @param secret - Server-side signing secret. Must be non-empty; use at least 32 bytes
97
+ * of entropy from a secret manager, and never a value shipped to the client.
98
+ * @param options - See {@link CsrfTokenOptions}.
99
+ * @returns An opaque token safe to place in a cookie, header, or hidden form field.
100
+ * @throws {TypeError} If `secret` is empty, if `sessionId` is present but is not a
101
+ * well-formed string, or if `ttlMs` is not a positive integer. Each of the three is
102
+ * a programming error that would otherwise weaken the token silently — a non-string
103
+ * `sessionId` binds every session to the same signature, and a fractional `ttlMs`
104
+ * injects the field separator into the expiry.
105
+ *
106
+ * @example
107
+ * ```ts
108
+ * const token = createCsrfToken(process.env.CSRF_SECRET!, { sessionId: session.id });
109
+ * res.setHeader("Set-Cookie", `csrf=${token}; Path=/; SameSite=Lax`);
110
+ * ```
111
+ */
112
+ function createCsrfToken(secret, options = {}) {
113
+ if (typeof secret !== "string" || secret.length === 0) throw new TypeError("createCsrfToken: secret must be a non-empty string");
114
+ const { sessionId = "", ttlMs = DEFAULT_TTL_MS } = options;
115
+ if (!isBindableSessionId(sessionId)) throw new TypeError("createCsrfToken: sessionId must be a well-formed string (no unpaired surrogates)");
116
+ if (!Number.isInteger(ttlMs) || ttlMs <= 0) throw new TypeError("createCsrfToken: ttlMs must be a positive integer number of milliseconds");
117
+ const nonce = randomBytes(NONCE_BYTES).toString("base64url");
118
+ const expiry = (Date.now() + ttlMs).toString(TIMESTAMP_RADIX);
119
+ return [
120
+ nonce,
121
+ expiry,
122
+ sign(secret, nonce, expiry, sessionId)
123
+ ].join(SEPARATOR);
124
+ }
125
+ /**
126
+ * Constant-time comparison that does not leak length.
127
+ *
128
+ * `timingSafeEqual` throws when its arguments differ in length, and guarding that with
129
+ * an early `length` check reintroduces a timing signal. Hashing both sides to a fixed
130
+ * 32 bytes first makes the comparison both constant-time and length-blind.
131
+ */
132
+ function constantTimeEquals(left, right) {
133
+ return timingSafeEqual(createHmac("sha256", BLINDING_KEY).update(left, "utf8").digest(), createHmac("sha256", BLINDING_KEY).update(right, "utf8").digest());
134
+ }
135
+ /**
136
+ * Verify a signed CSRF token.
137
+ *
138
+ * Checks the signature in constant time, then the expiry. The failure `reason` is for
139
+ * server-side logging — do not return it to the client, since it distinguishes
140
+ * "expired" from "forged" for anyone probing the endpoint.
141
+ *
142
+ * @param token - The token submitted with the request.
143
+ * @param secret - The same signing secret used at mint time.
144
+ * @param options - See {@link CsrfVerifyOptions}.
145
+ * @returns `{ valid: true }`, or `{ valid: false, reason }`. Never throws.
146
+ *
147
+ * @example
148
+ * ```ts
149
+ * const result = verifyCsrfToken(req.headers["x-csrf-token"], secret, {
150
+ * sessionId: session.id,
151
+ * });
152
+ * if (!result.valid) {
153
+ * logger.warn("csrf rejected", { reason: result.reason });
154
+ * return new Response("Forbidden", { status: 403 });
155
+ * }
156
+ * ```
157
+ */
158
+ function verifyCsrfToken(token, secret, options = {}) {
159
+ if (typeof secret !== "string" || secret.length === 0) return {
160
+ valid: false,
161
+ reason: "missing_secret"
162
+ };
163
+ if (typeof token !== "string" || token.length === 0) return {
164
+ valid: false,
165
+ reason: "missing_token"
166
+ };
167
+ const parts = token.split(SEPARATOR);
168
+ if (parts.length !== 3) return {
169
+ valid: false,
170
+ reason: "malformed"
171
+ };
172
+ const [nonce, expiry, signature] = parts;
173
+ if (nonce.length === 0 || expiry.length === 0 || signature.length === 0) return {
174
+ valid: false,
175
+ reason: "malformed"
176
+ };
177
+ const boundSession = options.sessionId ?? "";
178
+ if (!isBindableSessionId(boundSession)) return {
179
+ valid: false,
180
+ reason: "malformed"
181
+ };
182
+ if (!constantTimeEquals(signature, sign(secret, nonce, expiry, boundSession))) return {
183
+ valid: false,
184
+ reason: "signature_mismatch"
185
+ };
186
+ const expiresAt = Number.parseInt(expiry, TIMESTAMP_RADIX);
187
+ if (!Number.isFinite(expiresAt)) return {
188
+ valid: false,
189
+ reason: "malformed"
190
+ };
191
+ if (Date.now() > expiresAt) return {
192
+ valid: false,
193
+ reason: "expired"
194
+ };
195
+ return { valid: true };
196
+ }
197
+ //#endregion
198
+ export { createCsrfToken, verifyCsrfToken };
199
+
200
+ //# sourceMappingURL=csrf.mjs.map
@@ -0,0 +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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyCA,MAAM,cAAc;;AAGpB,MAAM,iBAAiB,OAAc;;AAGrC,MAAM,YAAY;;AAGlB,MAAM,kBAAkB;;AAGxB,MAAM,eAAe;;;;;;;;;;;;;AA6BrB,SAAS,kBAAkB,OAAwB;CAClD,KAAK,IAAI,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;EACtC,MAAM,OAAO,MAAM,WAAW,CAAC;EAE/B,IAAI,OAAO,SAAU,OAAO,OAAQ;EAEpC,IAAI,QAAQ,OAAQ,OAAO;EAE3B,MAAM,OAAO,MAAM,WAAW,IAAI,CAAC;EACnC,IAAI,OAAO,MAAM,IAAI,KAAK,OAAO,SAAU,OAAO,OAAQ,OAAO;EACjE;CACD;CACA,OAAO;AACR;;AAGA,SAAS,oBAAoB,OAAiC;CAC7D,OAAO,OAAO,UAAU,YAAY,kBAAkB,KAAK;AAC5D;;;;;;;;;;;;;AAcA,SAAS,KAAK,QAAgB,OAAe,QAAgB,WAA2B;CACvF,OAAO,WAAW,UAAU,MAAM,CAAC,CACjC,OAAO,GAAG,MAAM,OAAO,GAAG,MAAM,GAAG,OAAO,OAAO,GAAG,OAAO,GAAG,UAAU,OAAO,GAAG,WAAW,CAAC,CAC9F,OAAO,WAAW;AACrB;;;;;;;;;;;;;;;;;;;;;;;;AAyBA,SAAgB,gBAAgB,QAAgB,UAA4B,CAAC,GAAW;CACvF,IAAI,OAAO,WAAW,YAAY,OAAO,WAAW,GACnD,MAAM,IAAI,UAAU,oDAAoD;CAGzE,MAAM,EAAE,YAAY,IAAI,QAAQ,mBAAmB;CAMnD,IAAI,CAAC,oBAAoB,SAAS,GACjC,MAAM,IAAI,UACT,kFACD;CAMD,IAAI,CAAC,OAAO,UAAU,KAAK,KAAK,SAAS,GACxC,MAAM,IAAI,UAAU,0EAA0E;CAG/F,MAAM,QAAQ,YAAY,WAAW,CAAC,CAAC,SAAS,WAAW;CAC3D,MAAM,UAAU,KAAK,IAAI,IAAI,MAAA,CAAO,SAAS,eAAe;CAE5D,OAAO;EAAC;EAAO;EAAQ,KAAK,QAAQ,OAAO,QAAQ,SAAS;CAAC,CAAC,CAAC,KAAK,SAAS;AAC9E;;;;;;;;AAgCA,SAAS,mBAAmB,MAAc,OAAwB;CAGjE,OAAO,gBAFY,WAAW,UAAU,YAAY,CAAC,CAAC,OAAO,MAAM,MAAM,CAAC,CAAC,OAE3C,GADZ,WAAW,UAAU,YAAY,CAAC,CAAC,OAAO,OAAO,MAAM,CAAC,CAAC,OAChC,CAAC;AAC/C;;;;;;;;;;;;;;;;;;;;;;;;AAyBA,SAAgB,gBACf,OACA,QACA,UAA6B,CAAC,GACX;CACnB,IAAI,OAAO,WAAW,YAAY,OAAO,WAAW,GACnD,OAAO;EAAE,OAAO;EAAO,QAAQ;CAAiB;CAEjD,IAAI,OAAO,UAAU,YAAY,MAAM,WAAW,GACjD,OAAO;EAAE,OAAO;EAAO,QAAQ;CAAgB;CAGhD,MAAM,QAAQ,MAAM,MAAM,SAAS;CACnC,IAAI,MAAM,WAAW,GAAG,OAAO;EAAE,OAAO;EAAO,QAAQ;CAAY;CAEnE,MAAM,CAAC,OAAO,QAAQ,aAAa;CACnC,IAAI,MAAM,WAAW,KAAK,OAAO,WAAW,KAAK,UAAU,WAAW,GACrE,OAAO;EAAE,OAAO;EAAO,QAAQ;CAAY;CAM5C,MAAM,eAAe,QAAQ,aAAa;CAC1C,IAAI,CAAC,oBAAoB,YAAY,GACpC,OAAO;EAAE,OAAO;EAAO,QAAQ;CAAY;CAM5C,IAAI,CAAC,mBAAmB,WADP,KAAK,QAAQ,OAAO,QAAQ,YACH,CAAC,GAC1C,OAAO;EAAE,OAAO;EAAO,QAAQ;CAAqB;CAGrD,MAAM,YAAY,OAAO,SAAS,QAAQ,eAAe;CACzD,IAAI,CAAC,OAAO,SAAS,SAAS,GAAG,OAAO;EAAE,OAAO;EAAO,QAAQ;CAAY;CAC5E,IAAI,KAAK,IAAI,IAAI,WAAW,OAAO;EAAE,OAAO;EAAO,QAAQ;CAAU;CAErE,OAAO,EAAE,OAAO,KAAK;AACtB"}
@@ -0,0 +1,8 @@
1
+ import { AddressClassification, OutboundRejectionReason, OutboundUrlPolicy, OutboundUrlVerdict, assertOutboundUrl, classifyAddress, isPubliclyRoutableAddress } from "./address.mjs";
2
+ import { CsrfFailureReason, CsrfTokenOptions, CsrfVerification, CsrfVerifyOptions, createCsrfToken, verifyCsrfToken } from "./csrf.mjs";
3
+ import { CorsResponsePolicy, OriginPolicyOptions, checkCorsResponsePolicy, isAllowedOrigin, normalizeOrigin } from "./origin.mjs";
4
+ import { RedirectPolicyOptions, RedirectRejectionReason, RedirectVerdict, resolveRedirectTarget } from "./redirect.mjs";
5
+ import { GraphQLRequestAnalysis, GraphQLRequestLimits, QueryComplexity, QueryComplexityLimits, analyzeGraphQLRequest, analyzeQueryComplexity, validateJsonpCallback } from "./query.mjs";
6
+ import { JsonPayloadLimits, JsonPayloadReport, checkJsonPayloadLimits } from "./payload.mjs";
7
+ import { FileTypeName, UploadCandidate, UploadRejectionReason, UploadVerdict, assertUploadType, detectFileSignature } from "./upload.mjs";
8
+ export { type AddressClassification, type CorsResponsePolicy, type CsrfFailureReason, type CsrfTokenOptions, type CsrfVerification, type CsrfVerifyOptions, type FileTypeName, type GraphQLRequestAnalysis, type GraphQLRequestLimits, type JsonPayloadLimits, type JsonPayloadReport, type OriginPolicyOptions, type OutboundRejectionReason, type OutboundUrlPolicy, type OutboundUrlVerdict, type QueryComplexity, type QueryComplexityLimits, type RedirectPolicyOptions, type RedirectRejectionReason, type RedirectVerdict, type UploadCandidate, type UploadRejectionReason, type UploadVerdict, analyzeGraphQLRequest, analyzeQueryComplexity, assertOutboundUrl, assertUploadType, checkCorsResponsePolicy, checkJsonPayloadLimits, classifyAddress, createCsrfToken, detectFileSignature, isAllowedOrigin, isPubliclyRoutableAddress, normalizeOrigin, resolveRedirectTarget, validateJsonpCallback, verifyCsrfToken };
@@ -0,0 +1,8 @@
1
+ import { assertOutboundUrl, classifyAddress, isPubliclyRoutableAddress } from "./address.mjs";
2
+ import { createCsrfToken, verifyCsrfToken } from "./csrf.mjs";
3
+ import { checkCorsResponsePolicy, isAllowedOrigin, normalizeOrigin } from "./origin.mjs";
4
+ import { resolveRedirectTarget } from "./redirect.mjs";
5
+ import { analyzeGraphQLRequest, analyzeQueryComplexity, validateJsonpCallback } from "./query.mjs";
6
+ import { checkJsonPayloadLimits } from "./payload.mjs";
7
+ import { assertUploadType, detectFileSignature } from "./upload.mjs";
8
+ export { analyzeGraphQLRequest, analyzeQueryComplexity, assertOutboundUrl, assertUploadType, checkCorsResponsePolicy, checkJsonPayloadLimits, classifyAddress, createCsrfToken, detectFileSignature, isAllowedOrigin, isPubliclyRoutableAddress, normalizeOrigin, resolveRedirectTarget, validateJsonpCallback, verifyCsrfToken };
@@ -0,0 +1,95 @@
1
+ //#region src/controls/origin.d.ts
2
+ /**
3
+ * Copyright 2026 ResQ Systems, Inc.
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * Reduce an origin string to its canonical serialized form.
19
+ *
20
+ * Lowercases the scheme and host, drops a redundant default port, and refuses anything
21
+ * carrying a path, query, fragment, or userinfo — none of which belongs in an origin,
22
+ * and each of which is a way to smuggle a different host past a careless comparison.
23
+ *
24
+ * @param origin - Raw `Origin` header value.
25
+ * @returns The canonical origin (`https://example.com`, `https://example.com:8443`), or
26
+ * `null` when the value is not a well-formed, comparable origin. The literal `"null"`
27
+ * origin always yields `null`.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * normalizeOrigin("HTTPS://Example.COM:443"); // "https://example.com"
32
+ * normalizeOrigin("https://example.com/path"); // null — origins carry no path
33
+ * normalizeOrigin("null"); // null
34
+ * ```
35
+ */
36
+ declare function normalizeOrigin(origin: string): string | null;
37
+ /** Options for {@link isAllowedOrigin}. */
38
+ interface OriginPolicyOptions {
39
+ /**
40
+ * Parent origins whose **subdomains** are also accepted. Matching is on label
41
+ * boundaries, so `https://example.com` admits `https://app.example.com` but never
42
+ * `https://example.com.evil.test` and never `https://notexample.com`.
43
+ *
44
+ * Off by default. Enabling it makes every subdomain as trusted as the parent —
45
+ * including any subdomain an attacker manages to take over, which is WSTG-CONF-10.
46
+ */
47
+ readonly allowSubdomainsOf?: readonly string[];
48
+ }
49
+ /**
50
+ * Decide whether an origin is allowed.
51
+ *
52
+ * Both sides are normalized, then matched **exactly**. There is no prefix, suffix, or
53
+ * substring path through this function, and no wildcard: the allowlist is a list of
54
+ * origins, not a list of patterns.
55
+ *
56
+ * @param origin - The request's `Origin` header value.
57
+ * @param allowlist - Origins to accept. Entries that fail normalization are skipped
58
+ * rather than silently widening the policy.
59
+ * @param options - See {@link OriginPolicyOptions}.
60
+ * @returns `true` only when the origin is well-formed and present in the allowlist.
61
+ *
62
+ * @example
63
+ * ```ts
64
+ * const ALLOWED = ["https://app.example.com", "https://admin.example.com"];
65
+ *
66
+ * if (isAllowedOrigin(req.headers.origin ?? "", ALLOWED)) {
67
+ * // Echo the *normalized allowlisted* value, never the raw request header.
68
+ * res.setHeader("Access-Control-Allow-Origin", normalizeOrigin(req.headers.origin)!);
69
+ * res.setHeader("Vary", "Origin");
70
+ * }
71
+ * ```
72
+ */
73
+ declare function isAllowedOrigin(origin: string, allowlist: readonly string[], options?: OriginPolicyOptions): boolean;
74
+ /** A CORS response configuration, checked for the credentialed-wildcard mistake. */
75
+ interface CorsResponsePolicy {
76
+ /** Value destined for `Access-Control-Allow-Origin`. */
77
+ readonly allowOrigin: string;
78
+ /** Whether `Access-Control-Allow-Credentials: true` will be sent. */
79
+ readonly allowCredentials: boolean;
80
+ }
81
+ /**
82
+ * Reject the CORS combinations that browsers treat as an error and servers still ship:
83
+ * `Access-Control-Allow-Origin: *` or `null` together with
84
+ * `Access-Control-Allow-Credentials: true`.
85
+ *
86
+ * Call it where response headers are assembled, so the mistake fails a test rather than
87
+ * reaching production.
88
+ *
89
+ * @param policy - The headers about to be sent.
90
+ * @returns `null` when the combination is safe, otherwise a message naming the problem.
91
+ */
92
+ declare function checkCorsResponsePolicy(policy: CorsResponsePolicy): string | null;
93
+ //#endregion
94
+ export { CorsResponsePolicy, OriginPolicyOptions, checkCorsResponsePolicy, isAllowedOrigin, normalizeOrigin };
95
+ //# sourceMappingURL=origin.d.mts.map
@@ -0,0 +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"}
@@ -0,0 +1,156 @@
1
+ //#region src/controls/origin.ts
2
+ /**
3
+ * Copyright 2026 ResQ Systems, Inc.
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * @fileoverview CORS origin validation — the control for WSTG-CLNT-07.
19
+ *
20
+ * No signature helps here: `Origin: https://evil.example` is byte-identical in shape to
21
+ * a legitimate origin, and the vulnerability lives in how the *server* decides to
22
+ * reflect it. So this is a decision function, not a detector.
23
+ *
24
+ * Every real-world CORS bypass comes from one of four shortcuts, and each is refused
25
+ * explicitly below:
26
+ *
27
+ * 1. `startsWith` / `includes` matching — `https://example.com.evil.test` passes a
28
+ * prefix check and `https://evil-example.com` passes a substring check.
29
+ * 2. Reflecting the request's own `Origin` header into `Access-Control-Allow-Origin`.
30
+ * 3. Accepting the literal `null` origin, which any sandboxed iframe or `data:`
31
+ * document can send.
32
+ * 4. Pairing `Access-Control-Allow-Origin: *` with
33
+ * `Access-Control-Allow-Credentials: true`.
34
+ *
35
+ * @module @resq-systems/security/controls/origin
36
+ */
37
+ /** Default ports omitted from an origin's serialized form. */
38
+ const DEFAULT_PORTS = {
39
+ "http:": "80",
40
+ "https:": "443",
41
+ "ws:": "80",
42
+ "wss:": "443"
43
+ };
44
+ /** Schemes an origin may use. Anything else is refused before comparison. */
45
+ const ALLOWED_SCHEMES = /* @__PURE__ */ new Set([
46
+ "http:",
47
+ "https:",
48
+ "ws:",
49
+ "wss:"
50
+ ]);
51
+ /**
52
+ * Reduce an origin string to its canonical serialized form.
53
+ *
54
+ * Lowercases the scheme and host, drops a redundant default port, and refuses anything
55
+ * carrying a path, query, fragment, or userinfo — none of which belongs in an origin,
56
+ * and each of which is a way to smuggle a different host past a careless comparison.
57
+ *
58
+ * @param origin - Raw `Origin` header value.
59
+ * @returns The canonical origin (`https://example.com`, `https://example.com:8443`), or
60
+ * `null` when the value is not a well-formed, comparable origin. The literal `"null"`
61
+ * origin always yields `null`.
62
+ *
63
+ * @example
64
+ * ```ts
65
+ * normalizeOrigin("HTTPS://Example.COM:443"); // "https://example.com"
66
+ * normalizeOrigin("https://example.com/path"); // null — origins carry no path
67
+ * normalizeOrigin("null"); // null
68
+ * ```
69
+ */
70
+ function normalizeOrigin(origin) {
71
+ if (typeof origin !== "string") return null;
72
+ const trimmed = origin.trim();
73
+ if (trimmed.length === 0 || trimmed === "null" || trimmed === "*") return null;
74
+ let parsed;
75
+ try {
76
+ parsed = new URL(trimmed);
77
+ } catch {
78
+ return null;
79
+ }
80
+ if (!ALLOWED_SCHEMES.has(parsed.protocol)) return null;
81
+ if (parsed.username !== "" || parsed.password !== "") return null;
82
+ if (parsed.search !== "" || parsed.hash !== "") return null;
83
+ if (parsed.pathname !== "/") return null;
84
+ if (parsed.hostname.length === 0) return null;
85
+ const port = parsed.port === DEFAULT_PORTS[parsed.protocol] ? "" : parsed.port;
86
+ const host = parsed.hostname.toLowerCase();
87
+ return port === "" ? `${parsed.protocol}//${host}` : `${parsed.protocol}//${host}:${port}`;
88
+ }
89
+ /** True when `host` is a strict subdomain of `parentHost`, on a label boundary. */
90
+ function isSubdomainOf(host, parentHost) {
91
+ return host.length > parentHost.length && host.endsWith(`.${parentHost}`);
92
+ }
93
+ /**
94
+ * Decide whether an origin is allowed.
95
+ *
96
+ * Both sides are normalized, then matched **exactly**. There is no prefix, suffix, or
97
+ * substring path through this function, and no wildcard: the allowlist is a list of
98
+ * origins, not a list of patterns.
99
+ *
100
+ * @param origin - The request's `Origin` header value.
101
+ * @param allowlist - Origins to accept. Entries that fail normalization are skipped
102
+ * rather than silently widening the policy.
103
+ * @param options - See {@link OriginPolicyOptions}.
104
+ * @returns `true` only when the origin is well-formed and present in the allowlist.
105
+ *
106
+ * @example
107
+ * ```ts
108
+ * const ALLOWED = ["https://app.example.com", "https://admin.example.com"];
109
+ *
110
+ * if (isAllowedOrigin(req.headers.origin ?? "", ALLOWED)) {
111
+ * // Echo the *normalized allowlisted* value, never the raw request header.
112
+ * res.setHeader("Access-Control-Allow-Origin", normalizeOrigin(req.headers.origin)!);
113
+ * res.setHeader("Vary", "Origin");
114
+ * }
115
+ * ```
116
+ */
117
+ function isAllowedOrigin(origin, allowlist, options = {}) {
118
+ const candidate = normalizeOrigin(origin);
119
+ if (candidate === null) return false;
120
+ const entries = Array.isArray(allowlist) ? allowlist : [];
121
+ for (const entry of entries) if (normalizeOrigin(entry) === candidate) return true;
122
+ const parents = options.allowSubdomainsOf;
123
+ if (!Array.isArray(parents) || parents.length === 0) return false;
124
+ const candidateUrl = new URL(candidate);
125
+ for (const parent of parents) {
126
+ const normalizedParent = normalizeOrigin(parent);
127
+ if (normalizedParent === null) continue;
128
+ const parentUrl = new URL(normalizedParent);
129
+ if (parentUrl.protocol !== candidateUrl.protocol) continue;
130
+ if (parentUrl.port !== candidateUrl.port) continue;
131
+ if (isSubdomainOf(candidateUrl.hostname, parentUrl.hostname)) return true;
132
+ }
133
+ return false;
134
+ }
135
+ /**
136
+ * Reject the CORS combinations that browsers treat as an error and servers still ship:
137
+ * `Access-Control-Allow-Origin: *` or `null` together with
138
+ * `Access-Control-Allow-Credentials: true`.
139
+ *
140
+ * Call it where response headers are assembled, so the mistake fails a test rather than
141
+ * reaching production.
142
+ *
143
+ * @param policy - The headers about to be sent.
144
+ * @returns `null` when the combination is safe, otherwise a message naming the problem.
145
+ */
146
+ function checkCorsResponsePolicy(policy) {
147
+ if (!policy.allowCredentials) return null;
148
+ const value = policy.allowOrigin.trim();
149
+ if (value === "*") return "Access-Control-Allow-Origin: * cannot be combined with Access-Control-Allow-Credentials: true — name the exact origin instead";
150
+ if (value === "null") return "Access-Control-Allow-Origin: null grants access to sandboxed and data: documents — name the exact origin instead";
151
+ return null;
152
+ }
153
+ //#endregion
154
+ export { checkCorsResponsePolicy, isAllowedOrigin, normalizeOrigin };
155
+
156
+ //# sourceMappingURL=origin.mjs.map
@@ -0,0 +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"}
@@ -0,0 +1,84 @@
1
+ //#region src/controls/payload.d.ts
2
+ /**
3
+ * Copyright 2026 ResQ Systems, Inc.
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * @fileoverview Structural bounds on a JSON payload, measured from the text before
19
+ * anything parses it (OWASP API Security API4 — unrestricted resource consumption).
20
+ *
21
+ * @module @resq-systems/security/controls/payload
22
+ */
23
+ /** Bounds for {@link checkJsonPayloadLimits}. */
24
+ interface JsonPayloadLimits {
25
+ /** Deepest container nesting. Defaults to 100. */
26
+ readonly maxDepth?: number;
27
+ /** Most entries in any single array. Defaults to 10 000. */
28
+ readonly maxArrayLength?: number;
29
+ /** Most keys in any single object. Defaults to 2 000. */
30
+ readonly maxObjectKeys?: number;
31
+ /** Longest single string value, in characters. Defaults to 1 000 000. */
32
+ readonly maxStringLength?: number;
33
+ /** Longest payload overall, in characters. Defaults to 5 000 000. */
34
+ readonly maxLength?: number;
35
+ }
36
+ /** Result of {@link checkJsonPayloadLimits}. */
37
+ interface JsonPayloadReport {
38
+ /** Deepest container nesting reached. */
39
+ readonly depth: number;
40
+ /** Largest array seen, by entry count. */
41
+ readonly arrayLength: number;
42
+ /** Largest object seen, by key count. */
43
+ readonly objectKeys: number;
44
+ /** Longest string value seen. */
45
+ readonly stringLength: number;
46
+ /** Character length of the payload. */
47
+ readonly length: number;
48
+ /** `true` when every bound is satisfied. */
49
+ readonly withinLimits: boolean;
50
+ /** Names of the bounds exceeded; empty when `withinLimits`. */
51
+ readonly exceeded: readonly string[];
52
+ }
53
+ /**
54
+ * Measure a JSON payload's structure without parsing it.
55
+ *
56
+ * `JSON.parse` allocates the whole object graph before a caller can inspect anything, so
57
+ * a body designed to exhaust memory has already succeeded by the time validation runs.
58
+ * This is one linear pass over the *text*: it counts nesting, container sizes and string
59
+ * lengths, and never builds a value.
60
+ *
61
+ * Reporting rather than enforcing, deliberately. It returns what it measured and which
62
+ * bounds were exceeded; the caller decides. Schema validation remains the real control
63
+ * for shape — this only bounds the cost of getting there.
64
+ *
65
+ * Malformed JSON is not diagnosed. The scanner is a bracket counter, so an invalid or
66
+ * truncated payload yields whatever it measured before running out; use `JSON.parse` for
67
+ * validity, once this has bounded the cost.
68
+ *
69
+ * @param text - The raw JSON text, before parsing.
70
+ * @param limits - See {@link JsonPayloadLimits}.
71
+ * @returns The measured {@link JsonPayloadReport}. Never throws.
72
+ *
73
+ * @example
74
+ * ```ts
75
+ * const report = checkJsonPayloadLimits(await request.text());
76
+ * if (!report.withinLimits) {
77
+ * return new Response(`Payload rejected: ${report.exceeded.join(", ")}`, { status: 413 });
78
+ * }
79
+ * ```
80
+ */
81
+ declare function checkJsonPayloadLimits(text: string, limits?: JsonPayloadLimits): JsonPayloadReport;
82
+ //#endregion
83
+ export { JsonPayloadLimits, JsonPayloadReport, checkJsonPayloadLimits };
84
+ //# sourceMappingURL=payload.d.mts.map
@@ -0,0 +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"}