@resq-systems/security 1.0.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +236 -33
  2. package/lib/controls/address.d.mts +142 -0
  3. package/lib/controls/address.d.mts.map +1 -0
  4. package/lib/controls/address.mjs +533 -0
  5. package/lib/controls/address.mjs.map +1 -0
  6. package/lib/controls/csrf.d.mts +91 -0
  7. package/lib/controls/csrf.d.mts.map +1 -0
  8. package/lib/controls/csrf.mjs +200 -0
  9. package/lib/controls/csrf.mjs.map +1 -0
  10. package/lib/controls/index.d.mts +8 -0
  11. package/lib/controls/index.mjs +8 -0
  12. package/lib/controls/origin.d.mts +95 -0
  13. package/lib/controls/origin.d.mts.map +1 -0
  14. package/lib/controls/origin.mjs +156 -0
  15. package/lib/controls/origin.mjs.map +1 -0
  16. package/lib/controls/payload.d.mts +84 -0
  17. package/lib/controls/payload.d.mts.map +1 -0
  18. package/lib/controls/payload.mjs +147 -0
  19. package/lib/controls/payload.mjs.map +1 -0
  20. package/lib/controls/query.d.mts +157 -0
  21. package/lib/controls/query.d.mts.map +1 -0
  22. package/lib/controls/query.mjs +368 -0
  23. package/lib/controls/query.mjs.map +1 -0
  24. package/lib/controls/redirect.d.mts +92 -0
  25. package/lib/controls/redirect.d.mts.map +1 -0
  26. package/lib/controls/redirect.mjs +110 -0
  27. package/lib/controls/redirect.mjs.map +1 -0
  28. package/lib/controls/upload.d.mts +108 -0
  29. package/lib/controls/upload.d.mts.map +1 -0
  30. package/lib/controls/upload.mjs +374 -0
  31. package/lib/controls/upload.mjs.map +1 -0
  32. package/lib/crypto.d.mts +18 -5
  33. package/lib/crypto.d.mts.map +1 -1
  34. package/lib/crypto.mjs +35 -24
  35. package/lib/crypto.mjs.map +1 -1
  36. package/lib/hash.d.mts +51 -6
  37. package/lib/hash.d.mts.map +1 -1
  38. package/lib/hash.mjs +51 -6
  39. package/lib/hash.mjs.map +1 -1
  40. package/lib/index.d.mts +17 -2
  41. package/lib/index.mjs +19 -2
  42. package/lib/paths.d.mts +92 -0
  43. package/lib/paths.d.mts.map +1 -0
  44. package/lib/paths.mjs +140 -0
  45. package/lib/paths.mjs.map +1 -0
  46. package/lib/sanitize.d.mts +137 -35
  47. package/lib/sanitize.d.mts.map +1 -1
  48. package/lib/sanitize.mjs +170 -46
  49. package/lib/sanitize.mjs.map +1 -1
  50. package/lib/threats/capec.generated.d.mts +59 -0
  51. package/lib/threats/capec.generated.d.mts.map +1 -0
  52. package/lib/threats/capec.generated.mjs +644 -0
  53. package/lib/threats/capec.generated.mjs.map +1 -0
  54. package/lib/threats/engine.d.mts +94 -0
  55. package/lib/threats/engine.d.mts.map +1 -0
  56. package/lib/threats/engine.mjs +167 -0
  57. package/lib/threats/engine.mjs.map +1 -0
  58. package/lib/threats/index.d.mts +11 -0
  59. package/lib/threats/index.mjs +11 -0
  60. package/lib/threats/rules/datastore.d.mts +13 -0
  61. package/lib/threats/rules/datastore.d.mts.map +1 -0
  62. package/lib/threats/rules/datastore.mjs +366 -0
  63. package/lib/threats/rules/datastore.mjs.map +1 -0
  64. package/lib/threats/rules/index.d.mts +54 -0
  65. package/lib/threats/rules/index.d.mts.map +1 -0
  66. package/lib/threats/rules/index.mjs +121 -0
  67. package/lib/threats/rules/index.mjs.map +1 -0
  68. package/lib/threats/rules/markup.d.mts +28 -0
  69. package/lib/threats/rules/markup.d.mts.map +1 -0
  70. package/lib/threats/rules/markup.mjs +373 -0
  71. package/lib/threats/rules/markup.mjs.map +1 -0
  72. package/lib/threats/rules/protocol.d.mts +49 -0
  73. package/lib/threats/rules/protocol.d.mts.map +1 -0
  74. package/lib/threats/rules/protocol.mjs +175 -0
  75. package/lib/threats/rules/protocol.mjs.map +1 -0
  76. package/lib/threats/rules/system.d.mts +19 -0
  77. package/lib/threats/rules/system.d.mts.map +1 -0
  78. package/lib/threats/rules/system.mjs +455 -0
  79. package/lib/threats/rules/system.mjs.map +1 -0
  80. package/lib/threats/rules/web.d.mts +26 -0
  81. package/lib/threats/rules/web.d.mts.map +1 -0
  82. package/lib/threats/rules/web.mjs +412 -0
  83. package/lib/threats/rules/web.mjs.map +1 -0
  84. package/lib/threats/scoring.d.mts +59 -0
  85. package/lib/threats/scoring.d.mts.map +1 -0
  86. package/lib/threats/scoring.mjs +111 -0
  87. package/lib/threats/scoring.mjs.map +1 -0
  88. package/lib/threats/types.d.mts +245 -0
  89. package/lib/threats/types.d.mts.map +1 -0
  90. package/lib/threats/types.mjs +52 -0
  91. package/lib/threats/types.mjs.map +1 -0
  92. package/lib/threats/variants.d.mts +57 -0
  93. package/lib/threats/variants.d.mts.map +1 -0
  94. package/lib/threats/variants.mjs +144 -0
  95. package/lib/threats/variants.mjs.map +1 -0
  96. package/lib/unicode/confusables.d.mts +82 -0
  97. package/lib/unicode/confusables.d.mts.map +1 -0
  98. package/lib/unicode/confusables.mjs +954 -0
  99. package/lib/unicode/confusables.mjs.map +1 -0
  100. package/lib/unicode/index.d.mts +126 -0
  101. package/lib/unicode/index.d.mts.map +1 -0
  102. package/lib/unicode/index.mjs +288 -0
  103. package/lib/unicode/index.mjs.map +1 -0
  104. package/lib/validators.d.mts +341 -164
  105. package/lib/validators.d.mts.map +1 -1
  106. package/lib/validators.mjs +519 -338
  107. package/lib/validators.mjs.map +1 -1
  108. package/package.json +35 -8
@@ -0,0 +1,533 @@
1
+ //#region src/controls/address.ts
2
+ /** Four octets, or `null` when the text is not a dotted-quad IPv4 address. */
3
+ function parseIpv4(host) {
4
+ const parts = host.split(".");
5
+ if (parts.length !== 4) return null;
6
+ const octets = [];
7
+ for (const part of parts) {
8
+ if (part.length === 0 || part.length > 3 || !/^\d+$/.test(part)) return null;
9
+ const value = Number(part);
10
+ if (value > 255) return null;
11
+ octets.push(value);
12
+ }
13
+ return octets;
14
+ }
15
+ /**
16
+ * Eight 16-bit groups, or `null` when the text is not an IPv6 address.
17
+ *
18
+ * Accepts the bracketed, zero-compressed form `URL.hostname` produces, and the
19
+ * dotted-quad tail of an IPv4-mapped address.
20
+ */
21
+ function parseIpv6(host) {
22
+ let text = host;
23
+ if (text.startsWith("[") && text.endsWith("]")) text = text.slice(1, -1);
24
+ const zone = text.indexOf("%");
25
+ if (zone !== -1) text = text.slice(0, zone);
26
+ if (!text.includes(":")) return null;
27
+ let tail = [];
28
+ const lastColon = text.lastIndexOf(":");
29
+ const suffix = text.slice(lastColon + 1);
30
+ if (suffix.includes(".")) {
31
+ const octets = parseIpv4(suffix);
32
+ if (octets === null) return null;
33
+ tail = [octets[0] << 8 | octets[1], octets[2] << 8 | octets[3]];
34
+ text = text.slice(0, lastColon);
35
+ if (!text.endsWith(":")) text += ":";
36
+ text = text.slice(0, -1);
37
+ if (text.length === 0) text = "::";
38
+ }
39
+ const halves = text.split("::");
40
+ if (halves.length > 2) return null;
41
+ const toGroups = (part) => {
42
+ if (part.length === 0) return [];
43
+ const groups = [];
44
+ for (const piece of part.split(":")) {
45
+ if (piece.length === 0 || piece.length > 4 || !/^[0-9a-f]+$/i.test(piece)) return null;
46
+ groups.push(Number.parseInt(piece, 16));
47
+ }
48
+ return groups;
49
+ };
50
+ const head = toGroups(halves[0] ?? "");
51
+ if (head === null) return null;
52
+ if (halves.length === 1) {
53
+ const all = [...head, ...tail];
54
+ return all.length === 8 ? all : null;
55
+ }
56
+ const rest = toGroups(halves[1] ?? "");
57
+ if (rest === null) return null;
58
+ const known = head.length + rest.length + tail.length;
59
+ if (known > 8) return null;
60
+ return [
61
+ ...head,
62
+ ...new Array(8 - known).fill(0),
63
+ ...rest,
64
+ ...tail
65
+ ];
66
+ }
67
+ /** IPv4 special-purpose ranges, as `[network, prefixLength, classification]`. */
68
+ const IPV4_RANGES = [
69
+ [
70
+ [
71
+ 0,
72
+ 0,
73
+ 0,
74
+ 0
75
+ ],
76
+ 8,
77
+ "unspecified"
78
+ ],
79
+ [
80
+ [
81
+ 10,
82
+ 0,
83
+ 0,
84
+ 0
85
+ ],
86
+ 8,
87
+ "private"
88
+ ],
89
+ [
90
+ [
91
+ 100,
92
+ 64,
93
+ 0,
94
+ 0
95
+ ],
96
+ 10,
97
+ "carrier_nat"
98
+ ],
99
+ [
100
+ [
101
+ 127,
102
+ 0,
103
+ 0,
104
+ 0
105
+ ],
106
+ 8,
107
+ "loopback"
108
+ ],
109
+ [
110
+ [
111
+ 169,
112
+ 254,
113
+ 0,
114
+ 0
115
+ ],
116
+ 16,
117
+ "link_local"
118
+ ],
119
+ [
120
+ [
121
+ 172,
122
+ 16,
123
+ 0,
124
+ 0
125
+ ],
126
+ 12,
127
+ "private"
128
+ ],
129
+ [
130
+ [
131
+ 192,
132
+ 0,
133
+ 2,
134
+ 0
135
+ ],
136
+ 24,
137
+ "documentation"
138
+ ],
139
+ [
140
+ [
141
+ 192,
142
+ 88,
143
+ 99,
144
+ 0
145
+ ],
146
+ 24,
147
+ "reserved"
148
+ ],
149
+ [
150
+ [
151
+ 192,
152
+ 0,
153
+ 0,
154
+ 0
155
+ ],
156
+ 24,
157
+ "reserved"
158
+ ],
159
+ [
160
+ [
161
+ 192,
162
+ 168,
163
+ 0,
164
+ 0
165
+ ],
166
+ 16,
167
+ "private"
168
+ ],
169
+ [
170
+ [
171
+ 198,
172
+ 18,
173
+ 0,
174
+ 0
175
+ ],
176
+ 15,
177
+ "benchmarking"
178
+ ],
179
+ [
180
+ [
181
+ 198,
182
+ 51,
183
+ 100,
184
+ 0
185
+ ],
186
+ 24,
187
+ "documentation"
188
+ ],
189
+ [
190
+ [
191
+ 203,
192
+ 0,
193
+ 113,
194
+ 0
195
+ ],
196
+ 24,
197
+ "documentation"
198
+ ],
199
+ [
200
+ [
201
+ 224,
202
+ 0,
203
+ 0,
204
+ 0
205
+ ],
206
+ 4,
207
+ "multicast"
208
+ ],
209
+ [
210
+ [
211
+ 255,
212
+ 255,
213
+ 255,
214
+ 255
215
+ ],
216
+ 32,
217
+ "broadcast"
218
+ ],
219
+ [
220
+ [
221
+ 240,
222
+ 0,
223
+ 0,
224
+ 0
225
+ ],
226
+ 4,
227
+ "reserved"
228
+ ]
229
+ ];
230
+ /** IPv6 special-purpose ranges, as `[groups, prefixLength, classification]`. */
231
+ const IPV6_RANGES = [
232
+ [
233
+ [
234
+ 0,
235
+ 0,
236
+ 0,
237
+ 0,
238
+ 0,
239
+ 0,
240
+ 0,
241
+ 1
242
+ ],
243
+ 128,
244
+ "loopback"
245
+ ],
246
+ [
247
+ [
248
+ 0,
249
+ 0,
250
+ 0,
251
+ 0,
252
+ 0,
253
+ 0,
254
+ 0,
255
+ 0
256
+ ],
257
+ 128,
258
+ "unspecified"
259
+ ],
260
+ [
261
+ [
262
+ 100,
263
+ 65435,
264
+ 0,
265
+ 0,
266
+ 0,
267
+ 0,
268
+ 0,
269
+ 0
270
+ ],
271
+ 96,
272
+ "nat64"
273
+ ],
274
+ [
275
+ [
276
+ 256,
277
+ 0,
278
+ 0,
279
+ 0,
280
+ 0,
281
+ 0,
282
+ 0,
283
+ 0
284
+ ],
285
+ 64,
286
+ "reserved"
287
+ ],
288
+ [
289
+ [
290
+ 8193,
291
+ 3512,
292
+ 0,
293
+ 0,
294
+ 0,
295
+ 0,
296
+ 0,
297
+ 0
298
+ ],
299
+ 32,
300
+ "documentation"
301
+ ],
302
+ [
303
+ [
304
+ 8193,
305
+ 0,
306
+ 0,
307
+ 0,
308
+ 0,
309
+ 0,
310
+ 0,
311
+ 0
312
+ ],
313
+ 32,
314
+ "teredo"
315
+ ],
316
+ [
317
+ [
318
+ 8194,
319
+ 0,
320
+ 0,
321
+ 0,
322
+ 0,
323
+ 0,
324
+ 0,
325
+ 0
326
+ ],
327
+ 16,
328
+ "six_to_four"
329
+ ],
330
+ [
331
+ [
332
+ 64512,
333
+ 0,
334
+ 0,
335
+ 0,
336
+ 0,
337
+ 0,
338
+ 0,
339
+ 0
340
+ ],
341
+ 7,
342
+ "unique_local"
343
+ ],
344
+ [
345
+ [
346
+ 65152,
347
+ 0,
348
+ 0,
349
+ 0,
350
+ 0,
351
+ 0,
352
+ 0,
353
+ 0
354
+ ],
355
+ 10,
356
+ "link_local"
357
+ ],
358
+ [
359
+ [
360
+ 65280,
361
+ 0,
362
+ 0,
363
+ 0,
364
+ 0,
365
+ 0,
366
+ 0,
367
+ 0
368
+ ],
369
+ 8,
370
+ "multicast"
371
+ ]
372
+ ];
373
+ /** Whether `parts` sits inside `network/prefix`, given `bits` per part. */
374
+ function withinPrefix(parts, network, prefix, bits) {
375
+ let remaining = prefix;
376
+ for (let index = 0; index < parts.length && remaining > 0; index++) {
377
+ const width = Math.min(bits, remaining);
378
+ const shift = bits - width;
379
+ if (parts[index] >>> shift !== network[index] >>> shift) return false;
380
+ remaining -= width;
381
+ }
382
+ return true;
383
+ }
384
+ /** IPv4-mapped IPv6 prefix, `::ffff:0:0/96`. */
385
+ const IPV4_MAPPED = [
386
+ 0,
387
+ 0,
388
+ 0,
389
+ 0,
390
+ 0,
391
+ 65535,
392
+ 0,
393
+ 0
394
+ ];
395
+ /** Classify four octets against the IPv4 table. */
396
+ function classifyIpv4(octets) {
397
+ for (const [network, prefix, classification] of IPV4_RANGES) if (withinPrefix(octets, network, prefix, 8)) return classification;
398
+ return "public";
399
+ }
400
+ /**
401
+ * Classify a host as an IP address range, or `null` when it is not an IP literal.
402
+ *
403
+ * `null` is the answer for every domain name, and a caller must treat it as *unknown*
404
+ * rather than safe — conflating the two is the classic fail-open in this kind of check.
405
+ * {@link assertOutboundUrl} handles it explicitly.
406
+ *
407
+ * IPv4-mapped IPv6 addresses are unwrapped and classified by the address they carry, so
408
+ * `::ffff:169.254.169.254` is `link_local` rather than merely "some IPv6 address". That
409
+ * form matters in practice: `new URL("http://[::ffff:169.254.169.254]/").hostname`
410
+ * returns the bracketed, hex-compressed `[::ffff:a9fe:a9fe]`, which a string check misses.
411
+ *
412
+ * @param host - Hostname or IP literal, with or without IPv6 brackets.
413
+ * @returns The classification, or `null` when `host` is not an IP literal.
414
+ *
415
+ * @example
416
+ * ```ts
417
+ * classifyAddress("169.254.169.254"); // "link_local"
418
+ * classifyAddress("172.32.0.1"); // "public" — just outside 172.16/12
419
+ * classifyAddress("[::ffff:a9fe:a9fe]"); // "link_local"
420
+ * classifyAddress("metadata.example.com"); // null — a name, not an address
421
+ * ```
422
+ */
423
+ function classifyAddress(host) {
424
+ if (typeof host !== "string" || host.length === 0) return null;
425
+ const octets = parseIpv4(host);
426
+ if (octets !== null) return classifyIpv4(octets);
427
+ const groups = parseIpv6(host);
428
+ if (groups === null) return null;
429
+ if (withinPrefix(groups, IPV4_MAPPED, 96, 16)) {
430
+ const high = groups[6];
431
+ const low = groups[7];
432
+ return classifyIpv4([
433
+ high >>> 8,
434
+ high & 255,
435
+ low >>> 8,
436
+ low & 255
437
+ ]);
438
+ }
439
+ for (const [network, prefix, classification] of IPV6_RANGES) if (withinPrefix(groups, network, prefix, 16)) return classification;
440
+ return "public";
441
+ }
442
+ /**
443
+ * Whether a host is an IP literal in a publicly routable range.
444
+ *
445
+ * @param host - Hostname or IP literal.
446
+ * @returns `true` only for a routable IP literal. A domain name returns `false`, because
447
+ * this function cannot know what it resolves to.
448
+ */
449
+ function isPubliclyRoutableAddress(host) {
450
+ return classifyAddress(host) === "public";
451
+ }
452
+ /** Default schemes. `https:` only — a server fetching over `http:` is its own problem. */
453
+ const DEFAULT_PROTOCOLS = ["https:"];
454
+ /**
455
+ * Decide whether a server may fetch a caller-supplied URL.
456
+ *
457
+ * The control the SSRF rules name. Those rules match literal addresses inside a string;
458
+ * this decides whether the request should be made at all.
459
+ *
460
+ * **Default deny, exhaustively.** Every path ends in an explicit allow or an explicit
461
+ * refusal. That is deliberate: `classifyAddress` returns `null` for every domain name, so
462
+ * a policy shaped "reject non-public *literals*" silently permits every name — the exact
463
+ * fail-open this control exists to prevent. With no `allowedHosts` and `allowPublicHosts`
464
+ * off, a name is refused.
465
+ *
466
+ * **A pre-connection check, and it cannot be more.** The name is resolved by the network
467
+ * stack after this returns, so DNS may answer differently then (rebinding); redirects
468
+ * need the same check applied per hop; neither is closable by a synchronous function.
469
+ * Network-layer egress control remains the durable fix — this narrows the window rather
470
+ * than shutting it.
471
+ *
472
+ * @param candidate - The URL to fetch, as text or a parsed `URL`.
473
+ * @param policy - See {@link OutboundUrlPolicy}. Defaults refuse everything not named.
474
+ * @returns A discriminated verdict. Never throws.
475
+ *
476
+ * @example
477
+ * ```ts
478
+ * const verdict = assertOutboundUrl(webhookUrl, { allowedHosts: ["hooks.partner.example"] });
479
+ * if (!verdict.allowed) return reject(verdict.reason);
480
+ * await fetch(verdict.url);
481
+ * ```
482
+ */
483
+ function assertOutboundUrl(candidate, policy = {}) {
484
+ let url;
485
+ try {
486
+ url = candidate instanceof URL ? candidate : new URL(String(candidate));
487
+ } catch {
488
+ return {
489
+ allowed: false,
490
+ reason: "malformed"
491
+ };
492
+ }
493
+ if (!(policy.allowedProtocols ?? DEFAULT_PROTOCOLS).includes(url.protocol)) return {
494
+ allowed: false,
495
+ reason: "protocol_not_allowed"
496
+ };
497
+ if (url.port !== "") {
498
+ const port = Number(url.port);
499
+ if (!(policy.allowedPorts ?? []).includes(port)) return {
500
+ allowed: false,
501
+ reason: "port_not_allowed"
502
+ };
503
+ }
504
+ const host = url.hostname.toLowerCase();
505
+ const named = (policy.allowedHosts ?? []).some((allowed) => allowed.trim().toLowerCase() === host);
506
+ const classification = classifyAddress(host);
507
+ if (classification !== null) {
508
+ if (classification !== "public") return {
509
+ allowed: false,
510
+ reason: "address_not_routable"
511
+ };
512
+ return named || policy.allowPublicHosts === true ? {
513
+ allowed: true,
514
+ url,
515
+ classification
516
+ } : {
517
+ allowed: false,
518
+ reason: "host_not_allowed"
519
+ };
520
+ }
521
+ return named || policy.allowPublicHosts === true ? {
522
+ allowed: true,
523
+ url,
524
+ classification: null
525
+ } : {
526
+ allowed: false,
527
+ reason: "host_not_allowed"
528
+ };
529
+ }
530
+ //#endregion
531
+ export { assertOutboundUrl, classifyAddress, isPubliclyRoutableAddress };
532
+
533
+ //# sourceMappingURL=address.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"address.mjs","names":[],"sources":["../../src/controls/address.ts"],"sourcesContent":["/**\n * Copyright 2026 ResQ Systems, Inc.\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * @fileoverview IP address classification and outbound-URL policy — the SSRF control\n * the detection rules name but cannot themselves provide.\n *\n * The signatures in `threats/rules/system.ts` match literal addresses. A hostname whose\n * DNS record resolves to `169.254.169.254` passes every one of them, which is why those\n * rules point here instead.\n *\n * @module @resq-systems/security/controls/address\n */\n\n//#region Types\n\n/**\n * What an address is reserved for, per the IANA special-purpose registries.\n *\n * `public` means \"in no special-purpose range\" — routable on the internet. Every other\n * value is a reason not to fetch it from a server.\n */\nexport type AddressClassification =\n\t| \"unspecified\"\n\t| \"loopback\"\n\t| \"private\"\n\t| \"link_local\"\n\t| \"carrier_nat\"\n\t| \"multicast\"\n\t| \"broadcast\"\n\t| \"documentation\"\n\t| \"benchmarking\"\n\t| \"unique_local\"\n\t| \"teredo\"\n\t| \"six_to_four\"\n\t| \"nat64\"\n\t| \"reserved\"\n\t| \"public\";\n\n/** Why an outbound URL was refused. */\nexport type OutboundRejectionReason =\n\t/** Not parseable as a URL. */\n\t| \"malformed\"\n\t/** Scheme outside the permitted set. */\n\t| \"protocol_not_allowed\"\n\t/** Port outside the permitted set. */\n\t| \"port_not_allowed\"\n\t/** An IP literal in a range that is not publicly routable. */\n\t| \"address_not_routable\"\n\t/** A routable address, or a name, that policy does not permit. */\n\t| \"host_not_allowed\";\n\n/** Outcome of {@link assertOutboundUrl}. */\nexport type OutboundUrlVerdict =\n\t| {\n\t\t\treadonly allowed: true;\n\t\t\treadonly url: URL;\n\t\t\t/** `null` when the host is a name rather than an IP literal. */\n\t\t\treadonly classification: AddressClassification | null;\n\t }\n\t| { readonly allowed: false; readonly reason: OutboundRejectionReason };\n\n/** Policy for {@link assertOutboundUrl}. */\nexport interface OutboundUrlPolicy {\n\t/**\n\t * Hosts permitted regardless of classification, compared case-insensitively against\n\t * the parsed host. The allowlist the OWASP cheat sheet asks for.\n\t */\n\treadonly allowedHosts?: readonly string[];\n\t/** Schemes permitted. Defaults to `[\"https:\"]`. */\n\treadonly allowedProtocols?: readonly string[];\n\t/** Ports permitted in addition to the scheme's default. Defaults to none. */\n\treadonly allowedPorts?: readonly number[];\n\t/**\n\t * Permit any host not in a reserved range — every public address, and every name.\n\t *\n\t * Defaults to `false`, and that default is the point. A name is not an address: this\n\t * function cannot know whether `metadata.example.com` resolves to a public address or\n\t * to `169.254.169.254`. With no allowlist and this flag off, a name is refused.\n\t * Turning it on converts the control from an allowlist into a denylist over literals\n\t * only, which does not stop DNS from pointing inward.\n\t */\n\treadonly allowPublicHosts?: boolean;\n}\n\n//#endregion\n\n//#region Parsing\n\n/** Four octets, or `null` when the text is not a dotted-quad IPv4 address. */\nfunction parseIpv4(host: string): readonly number[] | null {\n\tconst parts = host.split(\".\");\n\tif (parts.length !== 4) return null;\n\n\tconst octets: number[] = [];\n\tfor (const part of parts) {\n\t\t// Rejects empty, signed, hex, octal and over-long forms. `new URL` already\n\t\t// canonicalises those for a parsed hostname; this matters for a caller handing a\n\t\t// raw string straight to `classifyAddress`.\n\t\tif (part.length === 0 || part.length > 3 || !/^\\d+$/.test(part)) return null;\n\t\tconst value = Number(part);\n\t\tif (value > 255) return null;\n\t\toctets.push(value);\n\t}\n\treturn octets;\n}\n\n/**\n * Eight 16-bit groups, or `null` when the text is not an IPv6 address.\n *\n * Accepts the bracketed, zero-compressed form `URL.hostname` produces, and the\n * dotted-quad tail of an IPv4-mapped address.\n */\nfunction parseIpv6(host: string): readonly number[] | null {\n\tlet text = host;\n\tif (text.startsWith(\"[\") && text.endsWith(\"]\")) text = text.slice(1, -1);\n\n\t// A zone identifier is not part of the address.\n\tconst zone = text.indexOf(\"%\");\n\tif (zone !== -1) text = text.slice(0, zone);\n\n\tif (!text.includes(\":\")) return null;\n\n\t// A trailing dotted quad contributes the final two groups.\n\tlet tail: number[] = [];\n\tconst lastColon = text.lastIndexOf(\":\");\n\tconst suffix = text.slice(lastColon + 1);\n\tif (suffix.includes(\".\")) {\n\t\tconst octets = parseIpv4(suffix);\n\t\tif (octets === null) return null;\n\t\ttail = [\n\t\t\t((octets[0] as number) << 8) | (octets[1] as number),\n\t\t\t((octets[2] as number) << 8) | (octets[3] as number),\n\t\t];\n\t\ttext = text.slice(0, lastColon);\n\t\tif (!text.endsWith(\":\")) text += \":\";\n\t\ttext = text.slice(0, -1);\n\t\tif (text.length === 0) text = \"::\";\n\t}\n\n\tconst halves = text.split(\"::\");\n\tif (halves.length > 2) return null;\n\n\tconst toGroups = (part: string): number[] | null => {\n\t\tif (part.length === 0) return [];\n\t\tconst groups: number[] = [];\n\t\tfor (const piece of part.split(\":\")) {\n\t\t\tif (piece.length === 0 || piece.length > 4 || !/^[0-9a-f]+$/i.test(piece)) return null;\n\t\t\tgroups.push(Number.parseInt(piece, 16));\n\t\t}\n\t\treturn groups;\n\t};\n\n\tconst head = toGroups(halves[0] ?? \"\");\n\tif (head === null) return null;\n\n\tif (halves.length === 1) {\n\t\tconst all = [...head, ...tail];\n\t\treturn all.length === 8 ? all : null;\n\t}\n\n\tconst rest = toGroups(halves[1] ?? \"\");\n\tif (rest === null) return null;\n\n\tconst known = head.length + rest.length + tail.length;\n\tif (known > 8) return null;\n\treturn [...head, ...(new Array(8 - known).fill(0) as number[]), ...rest, ...tail];\n}\n\n//#endregion\n\n//#region Classification\n\n/** IPv4 special-purpose ranges, as `[network, prefixLength, classification]`. */\nconst IPV4_RANGES: readonly (readonly [readonly number[], number, AddressClassification])[] = [\n\t[[0, 0, 0, 0], 8, \"unspecified\"],\n\t[[10, 0, 0, 0], 8, \"private\"],\n\t[[100, 64, 0, 0], 10, \"carrier_nat\"],\n\t[[127, 0, 0, 0], 8, \"loopback\"],\n\t[[169, 254, 0, 0], 16, \"link_local\"],\n\t[[172, 16, 0, 0], 12, \"private\"],\n\t[[192, 0, 2, 0], 24, \"documentation\"],\n\t[[192, 88, 99, 0], 24, \"reserved\"],\n\t[[192, 0, 0, 0], 24, \"reserved\"],\n\t[[192, 168, 0, 0], 16, \"private\"],\n\t[[198, 18, 0, 0], 15, \"benchmarking\"],\n\t[[198, 51, 100, 0], 24, \"documentation\"],\n\t[[203, 0, 113, 0], 24, \"documentation\"],\n\t[[224, 0, 0, 0], 4, \"multicast\"],\n\t[[255, 255, 255, 255], 32, \"broadcast\"],\n\t[[240, 0, 0, 0], 4, \"reserved\"],\n];\n\n/** IPv6 special-purpose ranges, as `[groups, prefixLength, classification]`. */\nconst IPV6_RANGES: readonly (readonly [readonly number[], number, AddressClassification])[] = [\n\t[[0, 0, 0, 0, 0, 0, 0, 1], 128, \"loopback\"],\n\t[[0, 0, 0, 0, 0, 0, 0, 0], 128, \"unspecified\"],\n\t[[0x64, 0xff9b, 0, 0, 0, 0, 0, 0], 96, \"nat64\"],\n\t[[0x100, 0, 0, 0, 0, 0, 0, 0], 64, \"reserved\"],\n\t[[0x2001, 0x0db8, 0, 0, 0, 0, 0, 0], 32, \"documentation\"],\n\t// Teredo. Absent from the first draft of this table, which classified it public.\n\t[[0x2001, 0, 0, 0, 0, 0, 0, 0], 32, \"teredo\"],\n\t[[0x2002, 0, 0, 0, 0, 0, 0, 0], 16, \"six_to_four\"],\n\t[[0xfc00, 0, 0, 0, 0, 0, 0, 0], 7, \"unique_local\"],\n\t[[0xfe80, 0, 0, 0, 0, 0, 0, 0], 10, \"link_local\"],\n\t[[0xff00, 0, 0, 0, 0, 0, 0, 0], 8, \"multicast\"],\n];\n\n/** Whether `parts` sits inside `network/prefix`, given `bits` per part. */\nfunction withinPrefix(\n\tparts: readonly number[],\n\tnetwork: readonly number[],\n\tprefix: number,\n\tbits: number,\n): boolean {\n\tlet remaining = prefix;\n\tfor (let index = 0; index < parts.length && remaining > 0; index++) {\n\t\tconst width = Math.min(bits, remaining);\n\t\tconst shift = bits - width;\n\t\tif ((parts[index] as number) >>> shift !== (network[index] as number) >>> shift) return false;\n\t\tremaining -= width;\n\t}\n\treturn true;\n}\n\n/** IPv4-mapped IPv6 prefix, `::ffff:0:0/96`. */\nconst IPV4_MAPPED: readonly number[] = [0, 0, 0, 0, 0, 0xffff, 0, 0];\n\n/** Classify four octets against the IPv4 table. */\nfunction classifyIpv4(octets: readonly number[]): AddressClassification {\n\tfor (const [network, prefix, classification] of IPV4_RANGES) {\n\t\tif (withinPrefix(octets, network, prefix, 8)) return classification;\n\t}\n\treturn \"public\";\n}\n\n/**\n * Classify a host as an IP address range, or `null` when it is not an IP literal.\n *\n * `null` is the answer for every domain name, and a caller must treat it as *unknown*\n * rather than safe — conflating the two is the classic fail-open in this kind of check.\n * {@link assertOutboundUrl} handles it explicitly.\n *\n * IPv4-mapped IPv6 addresses are unwrapped and classified by the address they carry, so\n * `::ffff:169.254.169.254` is `link_local` rather than merely \"some IPv6 address\". That\n * form matters in practice: `new URL(\"http://[::ffff:169.254.169.254]/\").hostname`\n * returns the bracketed, hex-compressed `[::ffff:a9fe:a9fe]`, which a string check misses.\n *\n * @param host - Hostname or IP literal, with or without IPv6 brackets.\n * @returns The classification, or `null` when `host` is not an IP literal.\n *\n * @example\n * ```ts\n * classifyAddress(\"169.254.169.254\"); // \"link_local\"\n * classifyAddress(\"172.32.0.1\"); // \"public\" — just outside 172.16/12\n * classifyAddress(\"[::ffff:a9fe:a9fe]\"); // \"link_local\"\n * classifyAddress(\"metadata.example.com\"); // null — a name, not an address\n * ```\n */\nexport function classifyAddress(host: string): AddressClassification | null {\n\tif (typeof host !== \"string\" || host.length === 0) return null;\n\n\tconst octets = parseIpv4(host);\n\tif (octets !== null) return classifyIpv4(octets);\n\n\tconst groups = parseIpv6(host);\n\tif (groups === null) return null;\n\n\tif (withinPrefix(groups, IPV4_MAPPED, 96, 16)) {\n\t\tconst high = groups[6] as number;\n\t\tconst low = groups[7] as number;\n\t\treturn classifyIpv4([high >>> 8, high & 0xff, low >>> 8, low & 0xff]);\n\t}\n\n\tfor (const [network, prefix, classification] of IPV6_RANGES) {\n\t\tif (withinPrefix(groups, network, prefix, 16)) return classification;\n\t}\n\treturn \"public\";\n}\n\n/**\n * Whether a host is an IP literal in a publicly routable range.\n *\n * @param host - Hostname or IP literal.\n * @returns `true` only for a routable IP literal. A domain name returns `false`, because\n * this function cannot know what it resolves to.\n */\nexport function isPubliclyRoutableAddress(host: string): boolean {\n\treturn classifyAddress(host) === \"public\";\n}\n\n//#endregion\n\n//#region Outbound policy\n\n/** Default schemes. `https:` only — a server fetching over `http:` is its own problem. */\nconst DEFAULT_PROTOCOLS: readonly string[] = [\"https:\"];\n\n/**\n * Decide whether a server may fetch a caller-supplied URL.\n *\n * The control the SSRF rules name. Those rules match literal addresses inside a string;\n * this decides whether the request should be made at all.\n *\n * **Default deny, exhaustively.** Every path ends in an explicit allow or an explicit\n * refusal. That is deliberate: `classifyAddress` returns `null` for every domain name, so\n * a policy shaped \"reject non-public *literals*\" silently permits every name — the exact\n * fail-open this control exists to prevent. With no `allowedHosts` and `allowPublicHosts`\n * off, a name is refused.\n *\n * **A pre-connection check, and it cannot be more.** The name is resolved by the network\n * stack after this returns, so DNS may answer differently then (rebinding); redirects\n * need the same check applied per hop; neither is closable by a synchronous function.\n * Network-layer egress control remains the durable fix — this narrows the window rather\n * than shutting it.\n *\n * @param candidate - The URL to fetch, as text or a parsed `URL`.\n * @param policy - See {@link OutboundUrlPolicy}. Defaults refuse everything not named.\n * @returns A discriminated verdict. Never throws.\n *\n * @example\n * ```ts\n * const verdict = assertOutboundUrl(webhookUrl, { allowedHosts: [\"hooks.partner.example\"] });\n * if (!verdict.allowed) return reject(verdict.reason);\n * await fetch(verdict.url);\n * ```\n */\nexport function assertOutboundUrl(\n\tcandidate: string | URL,\n\tpolicy: OutboundUrlPolicy = {},\n): OutboundUrlVerdict {\n\tlet url: URL;\n\ttry {\n\t\turl = candidate instanceof URL ? candidate : new URL(String(candidate));\n\t} catch {\n\t\treturn { allowed: false, reason: \"malformed\" };\n\t}\n\n\tconst protocols = policy.allowedProtocols ?? DEFAULT_PROTOCOLS;\n\tif (!protocols.includes(url.protocol)) {\n\t\treturn { allowed: false, reason: \"protocol_not_allowed\" };\n\t}\n\n\t// An empty `port` means the scheme default, which is always acceptable.\n\tif (url.port !== \"\") {\n\t\tconst port = Number(url.port);\n\t\tif (!(policy.allowedPorts ?? []).includes(port)) {\n\t\t\treturn { allowed: false, reason: \"port_not_allowed\" };\n\t\t}\n\t}\n\n\tconst host = url.hostname.toLowerCase();\n\tconst named = (policy.allowedHosts ?? []).some(\n\t\t(allowed) => allowed.trim().toLowerCase() === host,\n\t);\n\tconst classification = classifyAddress(host);\n\n\t// An IP literal is judged on its range first: naming a loopback address in an\n\t// allowlist should not turn it into a route back into the host.\n\tif (classification !== null) {\n\t\tif (classification !== \"public\") return { allowed: false, reason: \"address_not_routable\" };\n\t\treturn named || policy.allowPublicHosts === true\n\t\t\t? { allowed: true, url, classification }\n\t\t\t: { allowed: false, reason: \"host_not_allowed\" };\n\t}\n\n\t// A name. Nothing available here can tell what it resolves to.\n\treturn named || policy.allowPublicHosts === true\n\t\t? { allowed: true, url, classification: null }\n\t\t: { allowed: false, reason: \"host_not_allowed\" };\n}\n\n//#endregion\n"],"mappings":"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"}
@@ -0,0 +1,91 @@
1
+ //#region src/controls/csrf.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
+ /** Options for {@link createCsrfToken}. */
18
+ interface CsrfTokenOptions {
19
+ /**
20
+ * Session identifier to bind the token to. Strongly recommended: without it, a
21
+ * token minted by any user verifies for every other user.
22
+ */
23
+ readonly sessionId?: string;
24
+ /** Lifetime in milliseconds. Defaults to two hours. */
25
+ readonly ttlMs?: number;
26
+ }
27
+ /**
28
+ * Mint a signed CSRF token.
29
+ *
30
+ * Send it to the client in a readable cookie *and* require it back in a header or form
31
+ * field. A cross-origin page can cause the cookie to be sent but cannot read it, so it
32
+ * cannot populate the second copy.
33
+ *
34
+ * @param secret - Server-side signing secret. Must be non-empty; use at least 32 bytes
35
+ * of entropy from a secret manager, and never a value shipped to the client.
36
+ * @param options - See {@link CsrfTokenOptions}.
37
+ * @returns An opaque token safe to place in a cookie, header, or hidden form field.
38
+ * @throws {TypeError} If `secret` is empty, if `sessionId` is present but is not a
39
+ * well-formed string, or if `ttlMs` is not a positive integer. Each of the three is
40
+ * a programming error that would otherwise weaken the token silently — a non-string
41
+ * `sessionId` binds every session to the same signature, and a fractional `ttlMs`
42
+ * injects the field separator into the expiry.
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * const token = createCsrfToken(process.env.CSRF_SECRET!, { sessionId: session.id });
47
+ * res.setHeader("Set-Cookie", `csrf=${token}; Path=/; SameSite=Lax`);
48
+ * ```
49
+ */
50
+ declare function createCsrfToken(secret: string, options?: CsrfTokenOptions): string;
51
+ /** Why a CSRF token was rejected. */
52
+ type CsrfFailureReason = "malformed" | "expired" | "signature_mismatch" | "missing_token" | "missing_secret";
53
+ /** Outcome of {@link verifyCsrfToken}. */
54
+ type CsrfVerification = {
55
+ readonly valid: true;
56
+ } | {
57
+ readonly valid: false;
58
+ readonly reason: CsrfFailureReason;
59
+ };
60
+ /** Options for {@link verifyCsrfToken}. */
61
+ interface CsrfVerifyOptions {
62
+ /** Session the token must be bound to. Must match the value used at mint time. */
63
+ readonly sessionId?: string;
64
+ }
65
+ /**
66
+ * Verify a signed CSRF token.
67
+ *
68
+ * Checks the signature in constant time, then the expiry. The failure `reason` is for
69
+ * server-side logging — do not return it to the client, since it distinguishes
70
+ * "expired" from "forged" for anyone probing the endpoint.
71
+ *
72
+ * @param token - The token submitted with the request.
73
+ * @param secret - The same signing secret used at mint time.
74
+ * @param options - See {@link CsrfVerifyOptions}.
75
+ * @returns `{ valid: true }`, or `{ valid: false, reason }`. Never throws.
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * const result = verifyCsrfToken(req.headers["x-csrf-token"], secret, {
80
+ * sessionId: session.id,
81
+ * });
82
+ * if (!result.valid) {
83
+ * logger.warn("csrf rejected", { reason: result.reason });
84
+ * return new Response("Forbidden", { status: 403 });
85
+ * }
86
+ * ```
87
+ */
88
+ declare function verifyCsrfToken(token: string | undefined | null, secret: string, options?: CsrfVerifyOptions): CsrfVerification;
89
+ //#endregion
90
+ export { CsrfFailureReason, CsrfTokenOptions, CsrfVerification, CsrfVerifyOptions, createCsrfToken, verifyCsrfToken };
91
+ //# sourceMappingURL=csrf.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"csrf.d.mts","names":[],"sources":["../../src/controls/csrf.ts"],"mappings":";;;;;;;;;;;;;;;;;UA4DiB;;;;;WAKP;;WAEA;;;;;;;;;;;;;;;;;;;;;;;;;iBA4EM,gBAAgB,gBAAgB,UAAS;;KAmC7C;;KAQA;WACE;;WACA;WAAuB,QAAQ;;;UAG5B;;WAEP;;;;;;;;;;;;;;;;;;;;;;;;;iBAuCM,gBACf,kCACA,gBACA,UAAS,oBACP"}