@resq-systems/security 1.0.5 → 2.1.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 +169 -0
  21. package/lib/controls/query.d.mts.map +1 -0
  22. package/lib/controls/query.mjs +386 -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,386 @@
1
+ //#region src/controls/query.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 Two query-surface controls that are decisions rather than signatures:
19
+ * JSONP callback validation (WSTG-CLNT-13) and a computed query-complexity bound
20
+ * (WSTG-APIT-01).
21
+ *
22
+ * Both are *computed*, not matched. Depth and alias count are structural properties and
23
+ * a regex cannot count nesting — the same reason the engine's repeated-run check is a
24
+ * linear scan rather than a backreference.
25
+ *
26
+ * @module @resq-systems/security/controls/query
27
+ */
28
+ /**
29
+ * A JavaScript identifier, optionally dotted into a namespace: `cb`, `app.render`,
30
+ * `window.jsonp.handlers.onLoad`.
31
+ *
32
+ * Bounded at five segments of 64 characters. Nothing outside this shape can carry a
33
+ * payload, because the response body is `<callback>(<json>)` and every metacharacter
34
+ * needed to break out — quotes, parens, semicolons, angle brackets — is excluded.
35
+ */
36
+ const JSONP_CALLBACK = /^[A-Za-z_$][\w$]{0,63}(?:\.[A-Za-z_$][\w$]{0,63}){0,4}$/;
37
+ /** Identifiers refused regardless of shape, because reflecting them assists an attacker. */
38
+ const JSONP_RESERVED = /* @__PURE__ */ new Set([
39
+ "eval",
40
+ "Function",
41
+ "constructor",
42
+ "__proto__",
43
+ "prototype",
44
+ "alert",
45
+ "setTimeout",
46
+ "setInterval",
47
+ "import",
48
+ "require"
49
+ ]);
50
+ /**
51
+ * Validate a JSONP callback name.
52
+ *
53
+ * A JSONP response body is `<callback>(<json>)`, so the callback name is *concatenated
54
+ * into executable JavaScript* — an allowlist is the only safe validation. Escaping is
55
+ * not an option: there is no encoding of `alert(1);//` that is both harmless and still
56
+ * callable.
57
+ *
58
+ * Prefer not shipping JSONP at all. It predates CORS, requires an endpoint that answers
59
+ * `<script src>` with credentials attached, and is the mechanism behind cross-site
60
+ * script inclusion.
61
+ *
62
+ * @param callback - The requested callback name.
63
+ * @returns `true` when the name is a plain identifier or dotted namespace path and is
64
+ * not reserved.
65
+ *
66
+ * @example
67
+ * ```ts
68
+ * validateJsonpCallback("app.render"); // true
69
+ * validateJsonpCallback("alert(1);//"); // false
70
+ * validateJsonpCallback("window.eval"); // false — every segment is checked
71
+ * ```
72
+ */
73
+ function validateJsonpCallback(callback) {
74
+ if (typeof callback !== "string" || callback.length === 0) return false;
75
+ if (!JSONP_CALLBACK.test(callback)) return false;
76
+ for (const segment of callback.split(".")) if (JSONP_RESERVED.has(segment)) return false;
77
+ return true;
78
+ }
79
+ /** Defaults chosen to sit well above ordinary application queries. */
80
+ const DEFAULT_LIMITS = {
81
+ maxDepth: 10,
82
+ maxAliases: 50,
83
+ maxFields: 500,
84
+ maxLength: 2e4
85
+ };
86
+ /** Word characters, for the field-token counter. */
87
+ const WORD_CHARACTER = /[A-Za-z0-9_]/;
88
+ /**
89
+ * Measure a GraphQL-shaped query against structural limits.
90
+ *
91
+ * One linear pass counting brace depth, aliases, and field tokens. This is a *bound*,
92
+ * not a parser: it does not validate the document, resolve fragments, or account for
93
+ * list multipliers, so a production GraphQL server should still run a cost-analysis
94
+ * plugin. What it catches is the cheap denial-of-service shape — deeply nested
95
+ * recursive selections and mass aliasing — before the query reaches a resolver.
96
+ *
97
+ * String literals and `#` comments are skipped, so a brace inside `"{ }"` cannot
98
+ * inflate the depth. The alias count is approximate: argument colons inside parens are
99
+ * also preceded by a word and are counted too.
100
+ *
101
+ * @param query - The query document.
102
+ * @param limits - See {@link QueryComplexityLimits}.
103
+ * @returns The measured {@link QueryComplexity}. Never throws.
104
+ *
105
+ * @example
106
+ * ```ts
107
+ * const complexity = analyzeQueryComplexity(req.body.query, { maxDepth: 8 });
108
+ * if (!complexity.withinLimits) {
109
+ * return new Response(`Query too complex: ${complexity.exceeded.join(", ")}`, {
110
+ * status: 400,
111
+ * });
112
+ * }
113
+ * ```
114
+ */
115
+ function analyzeQueryComplexity(query, limits = {}) {
116
+ const { maxDepth, maxAliases, maxFields, maxLength } = {
117
+ ...DEFAULT_LIMITS,
118
+ ...limits
119
+ };
120
+ if (typeof query !== "string") return {
121
+ depth: 0,
122
+ aliases: 0,
123
+ fields: 0,
124
+ length: 0,
125
+ withinLimits: true,
126
+ exceeded: []
127
+ };
128
+ let depth = 0;
129
+ let deepest = 0;
130
+ let aliases = 0;
131
+ let fields = 0;
132
+ let inString = false;
133
+ let inComment = false;
134
+ let previousWasWord = false;
135
+ for (let i = 0; i < query.length; i++) {
136
+ const char = query[i];
137
+ if (inComment) {
138
+ if (char === "\n") inComment = false;
139
+ continue;
140
+ }
141
+ if (inString) {
142
+ if (char === "\\") i++;
143
+ else if (char === "\"") inString = false;
144
+ continue;
145
+ }
146
+ if (char === "\"") {
147
+ inString = true;
148
+ previousWasWord = false;
149
+ continue;
150
+ }
151
+ if (char === "#") {
152
+ inComment = true;
153
+ previousWasWord = false;
154
+ continue;
155
+ }
156
+ if (char === "{") {
157
+ depth++;
158
+ if (depth > deepest) deepest = depth;
159
+ previousWasWord = false;
160
+ continue;
161
+ }
162
+ if (char === "}") {
163
+ if (depth > 0) depth--;
164
+ previousWasWord = false;
165
+ continue;
166
+ }
167
+ if (char === ":") {
168
+ if (previousWasWord) aliases++;
169
+ previousWasWord = false;
170
+ continue;
171
+ }
172
+ if (WORD_CHARACTER.test(char)) {
173
+ if (!previousWasWord) fields++;
174
+ previousWasWord = true;
175
+ continue;
176
+ }
177
+ previousWasWord = false;
178
+ }
179
+ const exceeded = [];
180
+ if (deepest > maxDepth) exceeded.push("depth");
181
+ if (aliases > maxAliases) exceeded.push("aliases");
182
+ if (fields > maxFields) exceeded.push("fields");
183
+ if (query.length > maxLength) exceeded.push("length");
184
+ return {
185
+ depth: deepest,
186
+ aliases,
187
+ fields,
188
+ length: query.length,
189
+ withinLimits: exceeded.length === 0,
190
+ exceeded
191
+ };
192
+ }
193
+ /** Default batch bounds, generous compared with real client behaviour. */
194
+ const DEFAULT_REQUEST_LIMITS = {
195
+ maxOperations: 10,
196
+ maxDocuments: 10
197
+ };
198
+ /** An operation keyword at the start of a definition. */
199
+ const OPERATION_KEYWORD = /\b(?:query|mutation|subscription)\b/g;
200
+ /**
201
+ * Reduce a document to the text outside braces, strings and comments.
202
+ *
203
+ * Operation keywords only mean anything at depth 0 — `query` inside a selection set is a
204
+ * field name, not a second operation. Strings and `#` comments are dropped so neither can
205
+ * contribute a brace or a keyword.
206
+ *
207
+ * Block strings are handled explicitly: treating `"""` as three single quotes flips the
208
+ * in-string state an odd number of times and desynchronises the rest of the scan.
209
+ *
210
+ * @param document - A GraphQL document.
211
+ * @returns The depth-0 text, and whether a selection set was opened at depth 0.
212
+ */
213
+ function topLevelText(document) {
214
+ let text = "";
215
+ let depth = 0;
216
+ let hasSelection = false;
217
+ let index = 0;
218
+ while (index < document.length) {
219
+ const char = document[index];
220
+ if (char === "#") {
221
+ while (index < document.length && document[index] !== "\n") index++;
222
+ continue;
223
+ }
224
+ if (document.startsWith("\"\"\"", index)) {
225
+ index += 3;
226
+ while (index < document.length && !document.startsWith("\"\"\"", index)) index++;
227
+ index += 3;
228
+ continue;
229
+ }
230
+ if (char === "\"") {
231
+ index++;
232
+ while (index < document.length && document[index] !== "\"") index += document[index] === "\\" ? 2 : 1;
233
+ index++;
234
+ continue;
235
+ }
236
+ if (char === "{") {
237
+ if (depth === 0) hasSelection = true;
238
+ depth++;
239
+ index++;
240
+ continue;
241
+ }
242
+ if (char === "}") {
243
+ depth = Math.max(0, depth - 1);
244
+ index++;
245
+ continue;
246
+ }
247
+ if (depth === 0 && char !== void 0) text += char;
248
+ index++;
249
+ }
250
+ return {
251
+ text,
252
+ hasSelection
253
+ };
254
+ }
255
+ /**
256
+ * Count the top-level operations in one document.
257
+ *
258
+ * @param document - A GraphQL document.
259
+ * @returns The operation count. A document with no keyword but a depth-0 selection set
260
+ * counts as one, because anonymous shorthand is an operation.
261
+ */
262
+ function countOperations(document) {
263
+ const { text, hasSelection } = topLevelText(document);
264
+ const matches = text.match(OPERATION_KEYWORD)?.length ?? 0;
265
+ if (matches > 0) return matches;
266
+ return hasSelection ? 1 : 0;
267
+ }
268
+ /**
269
+ * Deepest body nesting traversed. A real batch is one array of operation objects, so
270
+ * anything past this is a client sending nesting for its own sake.
271
+ *
272
+ * The bound is what keeps {@link analyzeGraphQLRequest}'s "never throws" contract honest:
273
+ * a caller handing over `req.body` straight from a JSON body parser can pass `[[[[…]]]]`
274
+ * nested tens of thousands deep, and an unbounded descent raises `RangeError: Maximum
275
+ * call stack size exceeded` inside a function documented not to throw. The raw-text path
276
+ * never had this exposure — its `JSON.parse` failure is caught below — so only the
277
+ * already-parsed path was affected. Same unbounded-cost shape `payload.ts` caps with
278
+ * `MAX_COUNTER_STACK`.
279
+ */
280
+ const MAX_BODY_DEPTH = 8;
281
+ function documentsFrom(body) {
282
+ const documents = [];
283
+ let tooDeep = false;
284
+ let malformed = false;
285
+ const walk = (value, depth) => {
286
+ if (depth > MAX_BODY_DEPTH) {
287
+ tooDeep = true;
288
+ return;
289
+ }
290
+ if (typeof value === "string") {
291
+ const trimmed = value.trim();
292
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) try {
293
+ walk(JSON.parse(trimmed), depth + 1);
294
+ return;
295
+ } catch {
296
+ if (trimmed.startsWith("[")) {
297
+ malformed = true;
298
+ return;
299
+ }
300
+ }
301
+ documents.push(value);
302
+ return;
303
+ }
304
+ if (Array.isArray(value)) {
305
+ for (const entry of value) walk(entry, depth + 1);
306
+ return;
307
+ }
308
+ if (typeof value === "object" && value !== null) {
309
+ const query = value.query;
310
+ if (typeof query === "string") documents.push(query);
311
+ }
312
+ };
313
+ walk(body, 0);
314
+ return {
315
+ documents,
316
+ tooDeep,
317
+ malformed
318
+ };
319
+ }
320
+ /**
321
+ * Measure a whole GraphQL *request*, including batches.
322
+ *
323
+ * {@link analyzeQueryComplexity} measures one document, which leaves two ways past it,
324
+ * both measured against this package:
325
+ *
326
+ * - The documented call, `analyzeQueryComplexity(req.body.query, …)`, reads `undefined`
327
+ * when a client posts an **array** batch — so a 250-operation request measured
328
+ * `{ depth: 0, fields: 0, withinLimits: true }`. A total pass that does not even trip
329
+ * the length bound.
330
+ * - Passing the raw body instead does not help: the scanner skips everything between
331
+ * JSON quotes, so the same batch measured `fields: 0`.
332
+ *
333
+ * Alias batching *inside* one document is already bounded and needs nothing here — 300
334
+ * aliased selections report `exceeded: ["aliases", "fields"]`.
335
+ *
336
+ * Accepts a parsed body (`{ query }`, or an array of them) or the raw JSON text, and
337
+ * delegates per-document measurement to {@link analyzeQueryComplexity}, so existing
338
+ * callers and limits keep their meaning.
339
+ *
340
+ * Still a bound rather than a parser: run a cost-analysis plugin in the server too.
341
+ *
342
+ * @param body - The request body, parsed or raw.
343
+ * @param limits - Per-document limits, plus `maxOperations` and `maxDocuments`.
344
+ * @returns The measured {@link GraphQLRequestAnalysis}. Never throws.
345
+ *
346
+ * @example
347
+ * ```ts
348
+ * const analysis = analyzeGraphQLRequest(req.body);
349
+ * if (!analysis.withinLimits) {
350
+ * return new Response("Request rejected: " + analysis.exceeded.join(", "), { status: 400 });
351
+ * }
352
+ * ```
353
+ */
354
+ function analyzeGraphQLRequest(body, limits = {}) {
355
+ const { maxOperations, maxDocuments } = {
356
+ ...DEFAULT_REQUEST_LIMITS,
357
+ ...limits
358
+ };
359
+ const { documents, tooDeep, malformed } = documentsFrom(body);
360
+ const measured = documents.map((document) => analyzeQueryComplexity(document, limits));
361
+ const operations = documents.reduce((total, document) => total + countOperations(document), 0);
362
+ const worst = measured.reduce((currentWorst, complexity) => complexity.exceeded.length > currentWorst.exceeded.length || complexity.fields > currentWorst.fields ? complexity : currentWorst, {
363
+ depth: 0,
364
+ aliases: 0,
365
+ fields: 0,
366
+ length: 0,
367
+ withinLimits: true,
368
+ exceeded: []
369
+ });
370
+ const exceeded = [...worst.exceeded];
371
+ if (documents.length > maxDocuments) exceeded.push("documents");
372
+ if (operations > maxOperations) exceeded.push("operations");
373
+ if (tooDeep) exceeded.push("bodyDepth");
374
+ if (malformed) exceeded.push("malformedBody");
375
+ return {
376
+ documents: documents.length,
377
+ operations,
378
+ worst,
379
+ withinLimits: exceeded.length === 0,
380
+ exceeded
381
+ };
382
+ }
383
+ //#endregion
384
+ export { analyzeGraphQLRequest, analyzeQueryComplexity, validateJsonpCallback };
385
+
386
+ //# sourceMappingURL=query.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"query.mjs","names":[],"sources":["../../src/controls/query.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 Two query-surface controls that are decisions rather than signatures:\n * JSONP callback validation (WSTG-CLNT-13) and a computed query-complexity bound\n * (WSTG-APIT-01).\n *\n * Both are *computed*, not matched. Depth and alias count are structural properties and\n * a regex cannot count nesting — the same reason the engine's repeated-run check is a\n * linear scan rather than a backreference.\n *\n * @module @resq-systems/security/controls/query\n */\n\n//#region JSONP\n\n/**\n * A JavaScript identifier, optionally dotted into a namespace: `cb`, `app.render`,\n * `window.jsonp.handlers.onLoad`.\n *\n * Bounded at five segments of 64 characters. Nothing outside this shape can carry a\n * payload, because the response body is `<callback>(<json>)` and every metacharacter\n * needed to break out — quotes, parens, semicolons, angle brackets — is excluded.\n */\nconst JSONP_CALLBACK = /^[A-Za-z_$][\\w$]{0,63}(?:\\.[A-Za-z_$][\\w$]{0,63}){0,4}$/;\n\n/** Identifiers refused regardless of shape, because reflecting them assists an attacker. */\nconst JSONP_RESERVED = new Set([\n\t\"eval\",\n\t\"Function\",\n\t\"constructor\",\n\t\"__proto__\",\n\t\"prototype\",\n\t\"alert\",\n\t\"setTimeout\",\n\t\"setInterval\",\n\t\"import\",\n\t\"require\",\n]);\n\n/**\n * Validate a JSONP callback name.\n *\n * A JSONP response body is `<callback>(<json>)`, so the callback name is *concatenated\n * into executable JavaScript* — an allowlist is the only safe validation. Escaping is\n * not an option: there is no encoding of `alert(1);//` that is both harmless and still\n * callable.\n *\n * Prefer not shipping JSONP at all. It predates CORS, requires an endpoint that answers\n * `<script src>` with credentials attached, and is the mechanism behind cross-site\n * script inclusion.\n *\n * @param callback - The requested callback name.\n * @returns `true` when the name is a plain identifier or dotted namespace path and is\n * not reserved.\n *\n * @example\n * ```ts\n * validateJsonpCallback(\"app.render\"); // true\n * validateJsonpCallback(\"alert(1);//\"); // false\n * validateJsonpCallback(\"window.eval\"); // false — every segment is checked\n * ```\n */\nexport function validateJsonpCallback(callback: string): boolean {\n\tif (typeof callback !== \"string\" || callback.length === 0) return false;\n\tif (!JSONP_CALLBACK.test(callback)) return false;\n\n\t// Every segment is checked, so `window.eval` is refused as readily as `eval`.\n\tfor (const segment of callback.split(\".\")) {\n\t\tif (JSONP_RESERVED.has(segment)) return false;\n\t}\n\treturn true;\n}\n\n//#endregion\n\n//#region Query complexity\n\n/** Limits applied by {@link analyzeQueryComplexity}. */\nexport interface QueryComplexityLimits {\n\t/** Maximum nesting depth. Defaults to 10. */\n\treadonly maxDepth?: number;\n\t/** Maximum aliased fields. Defaults to 50. */\n\treadonly maxAliases?: number;\n\t/** Maximum selected fields overall. Defaults to 500. */\n\treadonly maxFields?: number;\n\t/** Maximum characters. Defaults to 20 000. */\n\treadonly maxLength?: number;\n}\n\n/** Measured shape of a query. */\nexport interface QueryComplexity {\n\t/** Deepest brace nesting reached. */\n\treadonly depth: number;\n\t/** Count of `alias: field` constructs. Approximate — see the function note. */\n\treadonly aliases: number;\n\t/** Approximate count of selected fields. */\n\treadonly fields: number;\n\t/** Character length. */\n\treadonly length: number;\n\t/** `true` when every limit is satisfied. */\n\treadonly withinLimits: boolean;\n\t/** Names of the limits exceeded; empty when `withinLimits`. */\n\treadonly exceeded: readonly string[];\n}\n\n/** Defaults chosen to sit well above ordinary application queries. */\nconst DEFAULT_LIMITS = {\n\tmaxDepth: 10,\n\tmaxAliases: 50,\n\tmaxFields: 500,\n\tmaxLength: 20_000,\n} as const satisfies Required<QueryComplexityLimits>;\n\n/** Word characters, for the field-token counter. */\nconst WORD_CHARACTER = /[A-Za-z0-9_]/;\n\n/**\n * Measure a GraphQL-shaped query against structural limits.\n *\n * One linear pass counting brace depth, aliases, and field tokens. This is a *bound*,\n * not a parser: it does not validate the document, resolve fragments, or account for\n * list multipliers, so a production GraphQL server should still run a cost-analysis\n * plugin. What it catches is the cheap denial-of-service shape — deeply nested\n * recursive selections and mass aliasing — before the query reaches a resolver.\n *\n * String literals and `#` comments are skipped, so a brace inside `\"{ }\"` cannot\n * inflate the depth. The alias count is approximate: argument colons inside parens are\n * also preceded by a word and are counted too.\n *\n * @param query - The query document.\n * @param limits - See {@link QueryComplexityLimits}.\n * @returns The measured {@link QueryComplexity}. Never throws.\n *\n * @example\n * ```ts\n * const complexity = analyzeQueryComplexity(req.body.query, { maxDepth: 8 });\n * if (!complexity.withinLimits) {\n * return new Response(`Query too complex: ${complexity.exceeded.join(\", \")}`, {\n * status: 400,\n * });\n * }\n * ```\n */\nexport function analyzeQueryComplexity(\n\tquery: string,\n\tlimits: QueryComplexityLimits = {},\n): QueryComplexity {\n\tconst { maxDepth, maxAliases, maxFields, maxLength } = { ...DEFAULT_LIMITS, ...limits };\n\n\tif (typeof query !== \"string\") {\n\t\treturn { depth: 0, aliases: 0, fields: 0, length: 0, withinLimits: true, exceeded: [] };\n\t}\n\n\tlet depth = 0;\n\tlet deepest = 0;\n\tlet aliases = 0;\n\tlet fields = 0;\n\tlet inString = false;\n\tlet inComment = false;\n\tlet previousWasWord = false;\n\n\tfor (let i = 0; i < query.length; i++) {\n\t\tconst char = query[i] as string;\n\n\t\tif (inComment) {\n\t\t\tif (char === \"\\n\") inComment = false;\n\t\t\tcontinue;\n\t\t}\n\t\tif (inString) {\n\t\t\tif (char === \"\\\\\") i++;\n\t\t\telse if (char === '\"') inString = false;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === '\"') {\n\t\t\tinString = true;\n\t\t\tpreviousWasWord = false;\n\t\t\tcontinue;\n\t\t}\n\t\tif (char === \"#\") {\n\t\t\tinComment = true;\n\t\t\tpreviousWasWord = false;\n\t\t\tcontinue;\n\t\t}\n\t\tif (char === \"{\") {\n\t\t\tdepth++;\n\t\t\tif (depth > deepest) deepest = depth;\n\t\t\tpreviousWasWord = false;\n\t\t\tcontinue;\n\t\t}\n\t\tif (char === \"}\") {\n\t\t\tif (depth > 0) depth--;\n\t\t\tpreviousWasWord = false;\n\t\t\tcontinue;\n\t\t}\n\t\tif (char === \":\") {\n\t\t\tif (previousWasWord) aliases++;\n\t\t\tpreviousWasWord = false;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (WORD_CHARACTER.test(char)) {\n\t\t\tif (!previousWasWord) fields++;\n\t\t\tpreviousWasWord = true;\n\t\t\tcontinue;\n\t\t}\n\t\tpreviousWasWord = false;\n\t}\n\n\tconst exceeded: string[] = [];\n\tif (deepest > maxDepth) exceeded.push(\"depth\");\n\tif (aliases > maxAliases) exceeded.push(\"aliases\");\n\tif (fields > maxFields) exceeded.push(\"fields\");\n\tif (query.length > maxLength) exceeded.push(\"length\");\n\n\treturn {\n\t\tdepth: deepest,\n\t\taliases,\n\t\tfields,\n\t\tlength: query.length,\n\t\twithinLimits: exceeded.length === 0,\n\t\texceeded,\n\t};\n}\n\n//#endregion\n\n//#region GraphQL requests\n\n/** Limits for {@link analyzeGraphQLRequest}. */\nexport interface GraphQLRequestLimits extends QueryComplexityLimits {\n\t/**\n\t * Top-level operations permitted across the whole request. Defaults to 10.\n\t *\n\t * The bound {@link analyzeQueryComplexity} cannot express, because it measures one\n\t * document and a batch is many.\n\t */\n\treadonly maxOperations?: number;\n\t/** Documents permitted in one batch. Defaults to 10. */\n\treadonly maxDocuments?: number;\n}\n\n/** Result of {@link analyzeGraphQLRequest}. */\nexport interface GraphQLRequestAnalysis {\n\t/** Documents found in the request. `1` for an ordinary single query. */\n\treadonly documents: number;\n\t/** Top-level operations summed across every document. */\n\treadonly operations: number;\n\t/** The highest per-document complexity in the batch. */\n\treadonly worst: QueryComplexity;\n\t/** `true` when every limit is satisfied *and* the body was fully readable. */\n\treadonly withinLimits: boolean;\n\t/**\n\t * Names of the limits exceeded; empty when `withinLimits`.\n\t *\n\t * Per-document names come from {@link QueryComplexity}: `depth`, `aliases`, `fields`,\n\t * `length`. Batch-level names are `documents` and `operations`. Two more report that\n\t * the body could not be read rather than that a bound was passed, and both mean the\n\t * other counts are lower bounds rather than measurements:\n\t *\n\t * - `bodyDepth` — nesting exceeded the internal walk limit, so documents past it were\n\t * never seen.\n\t * - `malformedBody` — a `[`-prefixed string was not valid JSON, so a batch could not\n\t * be read at all.\n\t */\n\treadonly exceeded: readonly string[];\n}\n\n/** Default batch bounds, generous compared with real client behaviour. */\nconst DEFAULT_REQUEST_LIMITS = {\n\tmaxOperations: 10,\n\tmaxDocuments: 10,\n} as const satisfies Required<Pick<GraphQLRequestLimits, \"maxOperations\" | \"maxDocuments\">>;\n\n/** An operation keyword at the start of a definition. */\nconst OPERATION_KEYWORD = /\\b(?:query|mutation|subscription)\\b/g;\n\n/**\n * Reduce a document to the text outside braces, strings and comments.\n *\n * Operation keywords only mean anything at depth 0 — `query` inside a selection set is a\n * field name, not a second operation. Strings and `#` comments are dropped so neither can\n * contribute a brace or a keyword.\n *\n * Block strings are handled explicitly: treating `\"\"\"` as three single quotes flips the\n * in-string state an odd number of times and desynchronises the rest of the scan.\n *\n * @param document - A GraphQL document.\n * @returns The depth-0 text, and whether a selection set was opened at depth 0.\n */\nfunction topLevelText(document: string): { text: string; hasSelection: boolean } {\n\tlet text = \"\";\n\tlet depth = 0;\n\tlet hasSelection = false;\n\tlet index = 0;\n\n\twhile (index < document.length) {\n\t\tconst char = document[index];\n\n\t\tif (char === \"#\") {\n\t\t\twhile (index < document.length && document[index] !== \"\\n\") index++;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (document.startsWith('\"\"\"', index)) {\n\t\t\tindex += 3;\n\t\t\twhile (index < document.length && !document.startsWith('\"\"\"', index)) index++;\n\t\t\tindex += 3;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === '\"') {\n\t\t\tindex++;\n\t\t\twhile (index < document.length && document[index] !== '\"') {\n\t\t\t\tindex += document[index] === \"\\\\\" ? 2 : 1;\n\t\t\t}\n\t\t\tindex++;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === \"{\") {\n\t\t\tif (depth === 0) hasSelection = true;\n\t\t\tdepth++;\n\t\t\tindex++;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (char === \"}\") {\n\t\t\tdepth = Math.max(0, depth - 1);\n\t\t\tindex++;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (depth === 0 && char !== undefined) text += char;\n\t\tindex++;\n\t}\n\n\treturn { text, hasSelection };\n}\n\n/**\n * Count the top-level operations in one document.\n *\n * @param document - A GraphQL document.\n * @returns The operation count. A document with no keyword but a depth-0 selection set\n * counts as one, because anonymous shorthand is an operation.\n */\nfunction countOperations(document: string): number {\n\tconst { text, hasSelection } = topLevelText(document);\n\tconst matches = text.match(OPERATION_KEYWORD)?.length ?? 0;\n\tif (matches > 0) return matches;\n\treturn hasSelection ? 1 : 0;\n}\n\n/**\n * Deepest body nesting traversed. A real batch is one array of operation objects, so\n * anything past this is a client sending nesting for its own sake.\n *\n * The bound is what keeps {@link analyzeGraphQLRequest}'s \"never throws\" contract honest:\n * a caller handing over `req.body` straight from a JSON body parser can pass `[[[[…]]]]`\n * nested tens of thousands deep, and an unbounded descent raises `RangeError: Maximum\n * call stack size exceeded` inside a function documented not to throw. The raw-text path\n * never had this exposure — its `JSON.parse` failure is caught below — so only the\n * already-parsed path was affected. Same unbounded-cost shape `payload.ts` caps with\n * `MAX_COUNTER_STACK`.\n */\nconst MAX_BODY_DEPTH = 8;\n\n/**\n * Extract the GraphQL documents from a request body of any accepted shape.\n *\n * Reports whether the walk completed, because both ways of stopping early —\n * {@link MAX_BODY_DEPTH}, and batch text that will not parse — otherwise return an\n * empty or short list that reads as an innocent request. Measured against this\n * package before the flags existed: 150 operations wrapped ten arrays deep extracted\n * `0` documents and satisfied every limit, and `\"[\".repeat(2500)` prefixed to a real\n * 150-operation batch extracted `1`. Both now surface as `exceeded` entries.\n *\n * @param body - Parsed body, or the raw JSON text.\n * @returns The documents found, plus the two early-stop flags.\n */\ninterface Extraction {\n\t/** Every `query` string found, in order. */\n\treadonly documents: readonly string[];\n\t/** Set when the walk hit {@link MAX_BODY_DEPTH} and stopped descending. */\n\treadonly tooDeep: boolean;\n\t/** Set when a `[`-prefixed string was not valid JSON, so a batch could not be read. */\n\treadonly malformed: boolean;\n}\n\nfunction documentsFrom(body: unknown): Extraction {\n\tconst documents: string[] = [];\n\tlet tooDeep = false;\n\tlet malformed = false;\n\n\t// A local walk rather than a recursive return, so accumulating a large batch stays\n\t// linear instead of re-copying the array at every level.\n\tconst walk = (value: unknown, depth: number): void => {\n\t\tif (depth > MAX_BODY_DEPTH) {\n\t\t\ttooDeep = true;\n\t\t\treturn;\n\t\t}\n\n\t\tif (typeof value === \"string\") {\n\t\t\tconst trimmed = value.trim();\n\t\t\tif (trimmed.startsWith(\"{\") || trimmed.startsWith(\"[\")) {\n\t\t\t\ttry {\n\t\t\t\t\twalk(JSON.parse(trimmed) as unknown, depth + 1);\n\t\t\t\t\treturn;\n\t\t\t\t} catch {\n\t\t\t\t\t// A `{`-prefixed string that is not JSON is ordinary anonymous-shorthand\n\t\t\t\t\t// GraphQL — `{ user { id } }` — so it falls through as a document. A\n\t\t\t\t\t// `[`-prefixed one cannot be: no GraphQL document starts with `[`, so this\n\t\t\t\t\t// is a batch we failed to read, and calling it \"one document\" undercounts\n\t\t\t\t\t// it to exactly the degree an attacker chooses.\n\t\t\t\t\tif (trimmed.startsWith(\"[\")) {\n\t\t\t\t\t\tmalformed = true;\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tdocuments.push(value);\n\t\t\treturn;\n\t\t}\n\n\t\tif (Array.isArray(value)) {\n\t\t\tfor (const entry of value) walk(entry, depth + 1);\n\t\t\treturn;\n\t\t}\n\n\t\tif (typeof value === \"object\" && value !== null) {\n\t\t\tconst query = (value as { query?: unknown }).query;\n\t\t\tif (typeof query === \"string\") documents.push(query);\n\t\t}\n\t};\n\n\twalk(body, 0);\n\treturn { documents, tooDeep, malformed };\n}\n\n/**\n * Measure a whole GraphQL *request*, including batches.\n *\n * {@link analyzeQueryComplexity} measures one document, which leaves two ways past it,\n * both measured against this package:\n *\n * - The documented call, `analyzeQueryComplexity(req.body.query, …)`, reads `undefined`\n * when a client posts an **array** batch — so a 250-operation request measured\n * `{ depth: 0, fields: 0, withinLimits: true }`. A total pass that does not even trip\n * the length bound.\n * - Passing the raw body instead does not help: the scanner skips everything between\n * JSON quotes, so the same batch measured `fields: 0`.\n *\n * Alias batching *inside* one document is already bounded and needs nothing here — 300\n * aliased selections report `exceeded: [\"aliases\", \"fields\"]`.\n *\n * Accepts a parsed body (`{ query }`, or an array of them) or the raw JSON text, and\n * delegates per-document measurement to {@link analyzeQueryComplexity}, so existing\n * callers and limits keep their meaning.\n *\n * Still a bound rather than a parser: run a cost-analysis plugin in the server too.\n *\n * @param body - The request body, parsed or raw.\n * @param limits - Per-document limits, plus `maxOperations` and `maxDocuments`.\n * @returns The measured {@link GraphQLRequestAnalysis}. Never throws.\n *\n * @example\n * ```ts\n * const analysis = analyzeGraphQLRequest(req.body);\n * if (!analysis.withinLimits) {\n * return new Response(\"Request rejected: \" + analysis.exceeded.join(\", \"), { status: 400 });\n * }\n * ```\n */\nexport function analyzeGraphQLRequest(\n\tbody: unknown,\n\tlimits: GraphQLRequestLimits = {},\n): GraphQLRequestAnalysis {\n\tconst { maxOperations, maxDocuments } = { ...DEFAULT_REQUEST_LIMITS, ...limits };\n\n\tconst { documents, tooDeep, malformed } = documentsFrom(body);\n\tconst measured = documents.map((document) => analyzeQueryComplexity(document, limits));\n\tconst operations = documents.reduce((total, document) => total + countOperations(document), 0);\n\n\tconst empty: QueryComplexity = {\n\t\tdepth: 0,\n\t\taliases: 0,\n\t\tfields: 0,\n\t\tlength: 0,\n\t\twithinLimits: true,\n\t\texceeded: [],\n\t};\n\n\t// The worst document decides, so one oversized member of a batch cannot hide behind\n\t// the rest.\n\tconst worst = measured.reduce<QueryComplexity>(\n\t\t(currentWorst, complexity) =>\n\t\t\tcomplexity.exceeded.length > currentWorst.exceeded.length ||\n\t\t\tcomplexity.fields > currentWorst.fields\n\t\t\t\t? complexity\n\t\t\t\t: currentWorst,\n\t\tempty,\n\t);\n\n\tconst exceeded = [...worst.exceeded];\n\tif (documents.length > maxDocuments) exceeded.push(\"documents\");\n\tif (operations > maxOperations) exceeded.push(\"operations\");\n\t// Fail closed. Everything above measures what was extracted, and these two say the\n\t// extraction was incomplete — so the counts are lower bounds, not measurements, and\n\t// reporting `withinLimits: true` off them would be asserting something never checked.\n\tif (tooDeep) exceeded.push(\"bodyDepth\");\n\tif (malformed) exceeded.push(\"malformedBody\");\n\n\treturn {\n\t\tdocuments: documents.length,\n\t\toperations,\n\t\tworst,\n\t\twithinLimits: exceeded.length === 0,\n\t\texceeded,\n\t};\n}\n\n//#endregion\n"],"mappings":"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"}
@@ -0,0 +1,92 @@
1
+ //#region src/controls/redirect.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 Allowlisted resolution of redirect and forward destinations
19
+ * (CWE-601) — decides whether a caller-supplied `next` value may be placed in a
20
+ * `Location` header.
21
+ *
22
+ * @module @resq-systems/security/controls/redirect
23
+ */
24
+ /** Why a redirect target was refused. */
25
+ type RedirectRejectionReason =
26
+ /** Not a string, or empty after trimming. */
27
+ "malformed" |
28
+ /** Longer than the configured bound. */
29
+ "too_long" |
30
+ /** Contains a character that lets the target escape the origin. */
31
+ "control_character" |
32
+ /** Opens a network-path reference and would leave the site. */
33
+ "opens_authority" |
34
+ /** Absolute URL using a scheme other than http or https. */
35
+ "unsupported_scheme" |
36
+ /** Absolute URL whose host is not allowlisted. */
37
+ "host_not_allowed";
38
+ /** Outcome of {@link resolveRedirectTarget}. */
39
+ type RedirectVerdict = {
40
+ readonly allowed: true;
41
+ readonly target: string;
42
+ } | {
43
+ readonly allowed: false;
44
+ readonly reason: RedirectRejectionReason;
45
+ };
46
+ /** Policy for {@link resolveRedirectTarget}. */
47
+ interface RedirectPolicyOptions {
48
+ /**
49
+ * Hosts an absolute target may point at, compared case-insensitively against the
50
+ * parsed host. Omit to refuse every absolute URL — the safer default, and the right
51
+ * one for a `next=` parameter.
52
+ */
53
+ readonly allowedHosts?: readonly string[];
54
+ /**
55
+ * Longest target accepted. Defaults to 2048: comfortably above any real return path,
56
+ * and below the length at which proxies begin truncating a `Location` value.
57
+ */
58
+ readonly maxLength?: number;
59
+ }
60
+ /**
61
+ * Decide whether a caller-supplied value may be used as a redirect destination.
62
+ *
63
+ * The classic post-login `?next=` bug (CWE-601): a value that looks like a path but
64
+ * resolves to another origin, so the site itself delivers the victim to the attacker
65
+ * with its own credibility attached.
66
+ *
67
+ * An **allowlist**, per the OWASP Unvalidated Redirects and Forwards cheat sheet. Two
68
+ * things are accepted: a same-site path beginning with a single slash, and — only when
69
+ * `allowedHosts` names the host — an absolute `http`/`https` URL. Everything else is
70
+ * refused with a reason, including the schemes that never belong in a `Location` header.
71
+ *
72
+ * Percent-encoded control bytes are **accepted deliberately**: `%0d%0a` stays literal in
73
+ * a `Location` value and does not split a header, so refusing it would reject ordinary
74
+ * URLs whose paths carry encoded data.
75
+ *
76
+ * @param target - Untrusted destination, typically a query parameter.
77
+ * @param options - Policy. Absolute URLs are refused unless `allowedHosts` names the host.
78
+ * @returns A discriminated verdict: the trimmed target, or the reason it was refused.
79
+ *
80
+ * @example
81
+ * ```ts
82
+ * resolveRedirectTarget("/dashboard?tab=recent");
83
+ * // { allowed: true, target: "/dashboard?tab=recent" }
84
+ *
85
+ * resolveRedirectTarget("https://partner.example/sso", { allowedHosts: ["partner.example"] });
86
+ * // { allowed: true, target: "https://partner.example/sso" }
87
+ * ```
88
+ */
89
+ declare function resolveRedirectTarget(target: string, options?: RedirectPolicyOptions): RedirectVerdict;
90
+ //#endregion
91
+ export { RedirectPolicyOptions, RedirectRejectionReason, RedirectVerdict, resolveRedirectTarget };
92
+ //# sourceMappingURL=redirect.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redirect.d.mts","names":[],"sources":["../../src/controls/redirect.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;KA2BY;;;;;;;;;;;;;;KAeA;WACE;WAAwB;;WACxB;WAAyB,QAAQ;;;UAG9B;;;;;;WAMP;;;;;WAKA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAkEM,sBACf,gBACA,UAAS,wBACP"}
@@ -0,0 +1,110 @@
1
+ //#region src/controls/redirect.ts
2
+ /** Default bound on a redirect target. */
3
+ const DEFAULT_MAX_LENGTH = 2048;
4
+ /**
5
+ * Characters that must never appear in a redirect target.
6
+ *
7
+ * C0, C1, and the two Unicode line terminators. Tab, LF and CR are the load-bearing
8
+ * members: the URL parser strips them *before* resolving, so a tab between the leading
9
+ * slash and a host resolves to a network-path reference and leaves the site — while the
10
+ * authority test below, which reads only the literal leading characters, sees an
11
+ * ordinary path.
12
+ *
13
+ * Sweeping U+0000-U+3000 for targets shaped `/<cp>/host` finds exactly five code points
14
+ * that escape the origin: tab, LF, CR, and the two slashes. The authority test catches
15
+ * the slashes and none of the first three, and all three of those are control
16
+ * characters. That is why this test runs first; reordering the two reopens the hole
17
+ * silently.
18
+ */
19
+ const UNSAFE_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
20
+ /**
21
+ * A network-path reference: two leading slashes, in either direction.
22
+ *
23
+ * Inlined rather than imported from `sanitize.ts`, which statically imports `effect` —
24
+ * an optional peer. Importing it here would make the entire `./controls` subpath fail to
25
+ * load for a consumer who never installed `effect`.
26
+ */
27
+ const OPENS_AUTHORITY = /^[/\\]{2}/;
28
+ /**
29
+ * Decide whether a caller-supplied value may be used as a redirect destination.
30
+ *
31
+ * The classic post-login `?next=` bug (CWE-601): a value that looks like a path but
32
+ * resolves to another origin, so the site itself delivers the victim to the attacker
33
+ * with its own credibility attached.
34
+ *
35
+ * An **allowlist**, per the OWASP Unvalidated Redirects and Forwards cheat sheet. Two
36
+ * things are accepted: a same-site path beginning with a single slash, and — only when
37
+ * `allowedHosts` names the host — an absolute `http`/`https` URL. Everything else is
38
+ * refused with a reason, including the schemes that never belong in a `Location` header.
39
+ *
40
+ * Percent-encoded control bytes are **accepted deliberately**: `%0d%0a` stays literal in
41
+ * a `Location` value and does not split a header, so refusing it would reject ordinary
42
+ * URLs whose paths carry encoded data.
43
+ *
44
+ * @param target - Untrusted destination, typically a query parameter.
45
+ * @param options - Policy. Absolute URLs are refused unless `allowedHosts` names the host.
46
+ * @returns A discriminated verdict: the trimmed target, or the reason it was refused.
47
+ *
48
+ * @example
49
+ * ```ts
50
+ * resolveRedirectTarget("/dashboard?tab=recent");
51
+ * // { allowed: true, target: "/dashboard?tab=recent" }
52
+ *
53
+ * resolveRedirectTarget("https://partner.example/sso", { allowedHosts: ["partner.example"] });
54
+ * // { allowed: true, target: "https://partner.example/sso" }
55
+ * ```
56
+ */
57
+ function resolveRedirectTarget(target, options = {}) {
58
+ if (typeof target !== "string") return {
59
+ allowed: false,
60
+ reason: "malformed"
61
+ };
62
+ const trimmed = target.trim();
63
+ if (trimmed.length === 0) return {
64
+ allowed: false,
65
+ reason: "malformed"
66
+ };
67
+ const maxLength = options.maxLength ?? DEFAULT_MAX_LENGTH;
68
+ if (trimmed.length > maxLength) return {
69
+ allowed: false,
70
+ reason: "too_long"
71
+ };
72
+ if (UNSAFE_CHARS.test(trimmed)) return {
73
+ allowed: false,
74
+ reason: "control_character"
75
+ };
76
+ if (OPENS_AUTHORITY.test(trimmed)) return {
77
+ allowed: false,
78
+ reason: "opens_authority"
79
+ };
80
+ if (trimmed.startsWith("/")) return {
81
+ allowed: true,
82
+ target: trimmed
83
+ };
84
+ let parsed;
85
+ try {
86
+ parsed = new URL(trimmed);
87
+ } catch {
88
+ return {
89
+ allowed: true,
90
+ target: trimmed
91
+ };
92
+ }
93
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return {
94
+ allowed: false,
95
+ reason: "unsupported_scheme"
96
+ };
97
+ const allowedHosts = options.allowedHosts ?? [];
98
+ const host = parsed.host.toLowerCase();
99
+ return allowedHosts.some((candidate) => candidate.trim().toLowerCase() === host) ? {
100
+ allowed: true,
101
+ target: trimmed
102
+ } : {
103
+ allowed: false,
104
+ reason: "host_not_allowed"
105
+ };
106
+ }
107
+ //#endregion
108
+ export { resolveRedirectTarget };
109
+
110
+ //# sourceMappingURL=redirect.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redirect.mjs","names":[],"sources":["../../src/controls/redirect.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 Allowlisted resolution of redirect and forward destinations\n * (CWE-601) — decides whether a caller-supplied `next` value may be placed in a\n * `Location` header.\n *\n * @module @resq-systems/security/controls/redirect\n */\n\n//#region Types\n\n/** Why a redirect target was refused. */\nexport type RedirectRejectionReason =\n\t/** Not a string, or empty after trimming. */\n\t| \"malformed\"\n\t/** Longer than the configured bound. */\n\t| \"too_long\"\n\t/** Contains a character that lets the target escape the origin. */\n\t| \"control_character\"\n\t/** Opens a network-path reference and would leave the site. */\n\t| \"opens_authority\"\n\t/** Absolute URL using a scheme other than http or https. */\n\t| \"unsupported_scheme\"\n\t/** Absolute URL whose host is not allowlisted. */\n\t| \"host_not_allowed\";\n\n/** Outcome of {@link resolveRedirectTarget}. */\nexport type RedirectVerdict =\n\t| { readonly allowed: true; readonly target: string }\n\t| { readonly allowed: false; readonly reason: RedirectRejectionReason };\n\n/** Policy for {@link resolveRedirectTarget}. */\nexport interface RedirectPolicyOptions {\n\t/**\n\t * Hosts an absolute target may point at, compared case-insensitively against the\n\t * parsed host. Omit to refuse every absolute URL — the safer default, and the right\n\t * one for a `next=` parameter.\n\t */\n\treadonly allowedHosts?: readonly string[];\n\t/**\n\t * Longest target accepted. Defaults to 2048: comfortably above any real return path,\n\t * and below the length at which proxies begin truncating a `Location` value.\n\t */\n\treadonly maxLength?: number;\n}\n\n//#endregion\n\n//#region Implementation\n\n/** Default bound on a redirect target. */\nconst DEFAULT_MAX_LENGTH = 2048;\n\n/**\n * Characters that must never appear in a redirect target.\n *\n * C0, C1, and the two Unicode line terminators. Tab, LF and CR are the load-bearing\n * members: the URL parser strips them *before* resolving, so a tab between the leading\n * slash and a host resolves to a network-path reference and leaves the site — while the\n * authority test below, which reads only the literal leading characters, sees an\n * ordinary path.\n *\n * Sweeping U+0000-U+3000 for targets shaped `/<cp>/host` finds exactly five code points\n * that escape the origin: tab, LF, CR, and the two slashes. The authority test catches\n * the slashes and none of the first three, and all three of those are control\n * characters. That is why this test runs first; reordering the two reopens the hole\n * silently.\n */\n// biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are the bypass\nconst UNSAFE_CHARS = /[\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029]/;\n\n/**\n * A network-path reference: two leading slashes, in either direction.\n *\n * Inlined rather than imported from `sanitize.ts`, which statically imports `effect` —\n * an optional peer. Importing it here would make the entire `./controls` subpath fail to\n * load for a consumer who never installed `effect`.\n */\nconst OPENS_AUTHORITY = /^[/\\\\]{2}/;\n\n/**\n * Decide whether a caller-supplied value may be used as a redirect destination.\n *\n * The classic post-login `?next=` bug (CWE-601): a value that looks like a path but\n * resolves to another origin, so the site itself delivers the victim to the attacker\n * with its own credibility attached.\n *\n * An **allowlist**, per the OWASP Unvalidated Redirects and Forwards cheat sheet. Two\n * things are accepted: a same-site path beginning with a single slash, and — only when\n * `allowedHosts` names the host — an absolute `http`/`https` URL. Everything else is\n * refused with a reason, including the schemes that never belong in a `Location` header.\n *\n * Percent-encoded control bytes are **accepted deliberately**: `%0d%0a` stays literal in\n * a `Location` value and does not split a header, so refusing it would reject ordinary\n * URLs whose paths carry encoded data.\n *\n * @param target - Untrusted destination, typically a query parameter.\n * @param options - Policy. Absolute URLs are refused unless `allowedHosts` names the host.\n * @returns A discriminated verdict: the trimmed target, or the reason it was refused.\n *\n * @example\n * ```ts\n * resolveRedirectTarget(\"/dashboard?tab=recent\");\n * // { allowed: true, target: \"/dashboard?tab=recent\" }\n *\n * resolveRedirectTarget(\"https://partner.example/sso\", { allowedHosts: [\"partner.example\"] });\n * // { allowed: true, target: \"https://partner.example/sso\" }\n * ```\n */\nexport function resolveRedirectTarget(\n\ttarget: string,\n\toptions: RedirectPolicyOptions = {},\n): RedirectVerdict {\n\tif (typeof target !== \"string\") return { allowed: false, reason: \"malformed\" };\n\n\t// Trimmed once, at entry. Leading whitespace otherwise survives the relative-path\n\t// test and reaches the absolute parse, where `new URL` trims it anyway — so a\n\t// space-prefixed network-path reference would be judged by a different branch than\n\t// the bare one.\n\tconst trimmed = target.trim();\n\tif (trimmed.length === 0) return { allowed: false, reason: \"malformed\" };\n\n\tconst maxLength = options.maxLength ?? DEFAULT_MAX_LENGTH;\n\tif (trimmed.length > maxLength) return { allowed: false, reason: \"too_long\" };\n\n\t// Order matters — see UNSAFE_CHARS. This must precede the authority test.\n\tif (UNSAFE_CHARS.test(trimmed)) return { allowed: false, reason: \"control_character\" };\n\n\tif (OPENS_AUTHORITY.test(trimmed)) return { allowed: false, reason: \"opens_authority\" };\n\n\t// A single leading slash is a same-site path, and the two tests above have already\n\t// ruled out everything that could make it resolve elsewhere.\n\tif (trimmed.startsWith(\"/\")) return { allowed: true, target: trimmed };\n\n\tlet parsed: URL;\n\ttry {\n\t\tparsed = new URL(trimmed);\n\t} catch {\n\t\t// Neither absolute nor rooted — a bare relative path such as \"dashboard\", which\n\t\t// resolves against the current directory and cannot change origin.\n\t\treturn { allowed: true, target: trimmed };\n\t}\n\n\tif (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") {\n\t\treturn { allowed: false, reason: \"unsupported_scheme\" };\n\t}\n\n\t// Compared against the *parsed* host, so userinfo cannot disguise the destination:\n\t// a URL whose userinfo is a trusted name still parses with the attacker's host.\n\tconst allowedHosts = options.allowedHosts ?? [];\n\tconst host = parsed.host.toLowerCase();\n\tconst permitted = allowedHosts.some((candidate) => candidate.trim().toLowerCase() === host);\n\n\treturn permitted\n\t\t? { allowed: true, target: trimmed }\n\t\t: { allowed: false, reason: \"host_not_allowed\" };\n}\n\n//#endregion\n"],"mappings":";;AAkEA,MAAM,qBAAqB;;;;;;;;;;;;;;;;AAkB3B,MAAM,eAAe;;;;;;;;AASrB,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BxB,SAAgB,sBACf,QACA,UAAiC,CAAC,GAChB;CAClB,IAAI,OAAO,WAAW,UAAU,OAAO;EAAE,SAAS;EAAO,QAAQ;CAAY;CAM7E,MAAM,UAAU,OAAO,KAAK;CAC5B,IAAI,QAAQ,WAAW,GAAG,OAAO;EAAE,SAAS;EAAO,QAAQ;CAAY;CAEvE,MAAM,YAAY,QAAQ,aAAa;CACvC,IAAI,QAAQ,SAAS,WAAW,OAAO;EAAE,SAAS;EAAO,QAAQ;CAAW;CAG5E,IAAI,aAAa,KAAK,OAAO,GAAG,OAAO;EAAE,SAAS;EAAO,QAAQ;CAAoB;CAErF,IAAI,gBAAgB,KAAK,OAAO,GAAG,OAAO;EAAE,SAAS;EAAO,QAAQ;CAAkB;CAItF,IAAI,QAAQ,WAAW,GAAG,GAAG,OAAO;EAAE,SAAS;EAAM,QAAQ;CAAQ;CAErE,IAAI;CACJ,IAAI;EACH,SAAS,IAAI,IAAI,OAAO;CACzB,QAAQ;EAGP,OAAO;GAAE,SAAS;GAAM,QAAQ;EAAQ;CACzC;CAEA,IAAI,OAAO,aAAa,WAAW,OAAO,aAAa,UACtD,OAAO;EAAE,SAAS;EAAO,QAAQ;CAAqB;CAKvD,MAAM,eAAe,QAAQ,gBAAgB,CAAC;CAC9C,MAAM,OAAO,OAAO,KAAK,YAAY;CAGrC,OAFkB,aAAa,MAAM,cAAc,UAAU,KAAK,CAAC,CAAC,YAAY,MAAM,IAEvE,IACZ;EAAE,SAAS;EAAM,QAAQ;CAAQ,IACjC;EAAE,SAAS;EAAO,QAAQ;CAAmB;AACjD"}