@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.
- package/README.md +236 -33
- package/lib/controls/address.d.mts +142 -0
- package/lib/controls/address.d.mts.map +1 -0
- package/lib/controls/address.mjs +533 -0
- package/lib/controls/address.mjs.map +1 -0
- package/lib/controls/csrf.d.mts +91 -0
- package/lib/controls/csrf.d.mts.map +1 -0
- package/lib/controls/csrf.mjs +200 -0
- package/lib/controls/csrf.mjs.map +1 -0
- package/lib/controls/index.d.mts +8 -0
- package/lib/controls/index.mjs +8 -0
- package/lib/controls/origin.d.mts +95 -0
- package/lib/controls/origin.d.mts.map +1 -0
- package/lib/controls/origin.mjs +156 -0
- package/lib/controls/origin.mjs.map +1 -0
- package/lib/controls/payload.d.mts +84 -0
- package/lib/controls/payload.d.mts.map +1 -0
- package/lib/controls/payload.mjs +147 -0
- package/lib/controls/payload.mjs.map +1 -0
- package/lib/controls/query.d.mts +157 -0
- package/lib/controls/query.d.mts.map +1 -0
- package/lib/controls/query.mjs +368 -0
- package/lib/controls/query.mjs.map +1 -0
- package/lib/controls/redirect.d.mts +92 -0
- package/lib/controls/redirect.d.mts.map +1 -0
- package/lib/controls/redirect.mjs +110 -0
- package/lib/controls/redirect.mjs.map +1 -0
- package/lib/controls/upload.d.mts +108 -0
- package/lib/controls/upload.d.mts.map +1 -0
- package/lib/controls/upload.mjs +374 -0
- package/lib/controls/upload.mjs.map +1 -0
- package/lib/crypto.d.mts +18 -5
- package/lib/crypto.d.mts.map +1 -1
- package/lib/crypto.mjs +35 -24
- package/lib/crypto.mjs.map +1 -1
- package/lib/hash.d.mts +51 -6
- package/lib/hash.d.mts.map +1 -1
- package/lib/hash.mjs +51 -6
- package/lib/hash.mjs.map +1 -1
- package/lib/index.d.mts +17 -2
- package/lib/index.mjs +19 -2
- package/lib/paths.d.mts +92 -0
- package/lib/paths.d.mts.map +1 -0
- package/lib/paths.mjs +140 -0
- package/lib/paths.mjs.map +1 -0
- package/lib/sanitize.d.mts +137 -35
- package/lib/sanitize.d.mts.map +1 -1
- package/lib/sanitize.mjs +170 -46
- package/lib/sanitize.mjs.map +1 -1
- package/lib/threats/capec.generated.d.mts +59 -0
- package/lib/threats/capec.generated.d.mts.map +1 -0
- package/lib/threats/capec.generated.mjs +644 -0
- package/lib/threats/capec.generated.mjs.map +1 -0
- package/lib/threats/engine.d.mts +94 -0
- package/lib/threats/engine.d.mts.map +1 -0
- package/lib/threats/engine.mjs +167 -0
- package/lib/threats/engine.mjs.map +1 -0
- package/lib/threats/index.d.mts +11 -0
- package/lib/threats/index.mjs +11 -0
- package/lib/threats/rules/datastore.d.mts +13 -0
- package/lib/threats/rules/datastore.d.mts.map +1 -0
- package/lib/threats/rules/datastore.mjs +366 -0
- package/lib/threats/rules/datastore.mjs.map +1 -0
- package/lib/threats/rules/index.d.mts +54 -0
- package/lib/threats/rules/index.d.mts.map +1 -0
- package/lib/threats/rules/index.mjs +121 -0
- package/lib/threats/rules/index.mjs.map +1 -0
- package/lib/threats/rules/markup.d.mts +28 -0
- package/lib/threats/rules/markup.d.mts.map +1 -0
- package/lib/threats/rules/markup.mjs +373 -0
- package/lib/threats/rules/markup.mjs.map +1 -0
- package/lib/threats/rules/protocol.d.mts +49 -0
- package/lib/threats/rules/protocol.d.mts.map +1 -0
- package/lib/threats/rules/protocol.mjs +175 -0
- package/lib/threats/rules/protocol.mjs.map +1 -0
- package/lib/threats/rules/system.d.mts +19 -0
- package/lib/threats/rules/system.d.mts.map +1 -0
- package/lib/threats/rules/system.mjs +455 -0
- package/lib/threats/rules/system.mjs.map +1 -0
- package/lib/threats/rules/web.d.mts +26 -0
- package/lib/threats/rules/web.d.mts.map +1 -0
- package/lib/threats/rules/web.mjs +412 -0
- package/lib/threats/rules/web.mjs.map +1 -0
- package/lib/threats/scoring.d.mts +59 -0
- package/lib/threats/scoring.d.mts.map +1 -0
- package/lib/threats/scoring.mjs +111 -0
- package/lib/threats/scoring.mjs.map +1 -0
- package/lib/threats/types.d.mts +245 -0
- package/lib/threats/types.d.mts.map +1 -0
- package/lib/threats/types.mjs +52 -0
- package/lib/threats/types.mjs.map +1 -0
- package/lib/threats/variants.d.mts +57 -0
- package/lib/threats/variants.d.mts.map +1 -0
- package/lib/threats/variants.mjs +144 -0
- package/lib/threats/variants.mjs.map +1 -0
- package/lib/unicode/confusables.d.mts +82 -0
- package/lib/unicode/confusables.d.mts.map +1 -0
- package/lib/unicode/confusables.mjs +954 -0
- package/lib/unicode/confusables.mjs.map +1 -0
- package/lib/unicode/index.d.mts +126 -0
- package/lib/unicode/index.d.mts.map +1 -0
- package/lib/unicode/index.mjs +288 -0
- package/lib/unicode/index.mjs.map +1 -0
- package/lib/validators.d.mts +341 -164
- package/lib/validators.d.mts.map +1 -1
- package/lib/validators.mjs +519 -338
- package/lib/validators.mjs.map +1 -1
- 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"}
|