@zudojs/security 0.1.0 → 1.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 (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +254 -11
  3. package/dist/body/body.core.d.ts +50 -2
  4. package/dist/body/body.core.js +150 -19
  5. package/dist/body/index.d.ts +1 -1
  6. package/dist/body/index.js +1 -1
  7. package/dist/cookie/cookie.core.d.ts +12 -0
  8. package/dist/cookie/cookie.core.js +98 -8
  9. package/dist/cors/cors.core.d.ts +8 -1
  10. package/dist/cors/cors.core.js +66 -9
  11. package/dist/csrf/csrf.core.d.ts +150 -9
  12. package/dist/csrf/csrf.core.js +199 -41
  13. package/dist/csrf/index.d.ts +2 -1
  14. package/dist/csrf/index.js +1 -1
  15. package/dist/header/header.core.js +18 -5
  16. package/dist/headers/headers.core.js +36 -4
  17. package/dist/index.d.ts +14 -9
  18. package/dist/index.js +10 -8
  19. package/dist/input/index.d.ts +1 -1
  20. package/dist/input/index.js +1 -1
  21. package/dist/input/input.core.d.ts +27 -0
  22. package/dist/input/input.core.js +125 -37
  23. package/dist/rateLimit/index.d.ts +2 -1
  24. package/dist/rateLimit/index.js +1 -1
  25. package/dist/rateLimit/rateLimit.core.d.ts +56 -11
  26. package/dist/rateLimit/rateLimit.core.js +160 -47
  27. package/dist/rateLimit/rateLimit.namespace.d.ts +2 -1
  28. package/dist/rateLimit/rateLimit.namespace.js +2 -1
  29. package/dist/types/index.d.ts +1 -1
  30. package/dist/types/security.type.d.ts +36 -19
  31. package/dist/types/security.type.js +26 -11
  32. package/dist/url/index.d.ts +2 -1
  33. package/dist/url/index.js +1 -1
  34. package/dist/url/url.core.d.ts +58 -2
  35. package/dist/url/url.core.js +270 -43
  36. package/package.json +20 -13
  37. package/dist/body/body.core.d.ts.map +0 -1
  38. package/dist/body/body.core.js.map +0 -1
  39. package/dist/body/index.d.ts.map +0 -1
  40. package/dist/body/index.js.map +0 -1
  41. package/dist/cookie/cookie.core.d.ts.map +0 -1
  42. package/dist/cookie/cookie.core.js.map +0 -1
  43. package/dist/cookie/index.d.ts.map +0 -1
  44. package/dist/cookie/index.js.map +0 -1
  45. package/dist/cors/cors.core.d.ts.map +0 -1
  46. package/dist/cors/cors.core.js.map +0 -1
  47. package/dist/cors/cors.namespace.d.ts.map +0 -1
  48. package/dist/cors/cors.namespace.js.map +0 -1
  49. package/dist/cors/index.d.ts.map +0 -1
  50. package/dist/cors/index.js.map +0 -1
  51. package/dist/csrf/csrf.core.d.ts.map +0 -1
  52. package/dist/csrf/csrf.core.js.map +0 -1
  53. package/dist/csrf/index.d.ts.map +0 -1
  54. package/dist/csrf/index.js.map +0 -1
  55. package/dist/header/header.core.d.ts.map +0 -1
  56. package/dist/header/header.core.js.map +0 -1
  57. package/dist/header/index.d.ts.map +0 -1
  58. package/dist/header/index.js.map +0 -1
  59. package/dist/headers/headers.core.d.ts.map +0 -1
  60. package/dist/headers/headers.core.js.map +0 -1
  61. package/dist/headers/index.d.ts.map +0 -1
  62. package/dist/headers/index.js.map +0 -1
  63. package/dist/index.d.ts.map +0 -1
  64. package/dist/index.js.map +0 -1
  65. package/dist/input/index.d.ts.map +0 -1
  66. package/dist/input/index.js.map +0 -1
  67. package/dist/input/input.core.d.ts.map +0 -1
  68. package/dist/input/input.core.js.map +0 -1
  69. package/dist/rateLimit/index.d.ts.map +0 -1
  70. package/dist/rateLimit/index.js.map +0 -1
  71. package/dist/rateLimit/rateLimit.core.d.ts.map +0 -1
  72. package/dist/rateLimit/rateLimit.core.js.map +0 -1
  73. package/dist/rateLimit/rateLimit.namespace.d.ts.map +0 -1
  74. package/dist/rateLimit/rateLimit.namespace.js.map +0 -1
  75. package/dist/types/index.d.ts.map +0 -1
  76. package/dist/types/index.js.map +0 -1
  77. package/dist/types/security.type.d.ts.map +0 -1
  78. package/dist/types/security.type.js.map +0 -1
  79. package/dist/url/index.d.ts.map +0 -1
  80. package/dist/url/index.js.map +0 -1
  81. package/dist/url/url.core.d.ts.map +0 -1
  82. package/dist/url/url.core.js.map +0 -1
@@ -2,8 +2,23 @@
2
2
  * @zudojs/security — CSRF Protection
3
3
  *
4
4
  * Generates and validates CSRF tokens for state-changing requests.
5
+ *
6
+ * Two patterns are supported, both built on the same signed token:
7
+ *
8
+ * - **Synchroniser token** (recommended). The server stores the token in an
9
+ * `HttpOnly` cookie via {@link generateCsrfCookie} and renders the same
10
+ * token into the page or form. The browser returns it in a header or field,
11
+ * and {@link verifyDoubleSubmit} compares the two. Script on the page never
12
+ * needs to read the cookie, so `HttpOnly` stays on.
13
+ * - **Double-submit cookie.** Client-side script reads the cookie and echoes
14
+ * it into a header. This requires `httpOnly: false` on the cookie — pass it
15
+ * explicitly, and understand that any XSS on the origin can then read the
16
+ * token.
17
+ *
18
+ * Bind the token to a session wherever you have one: without `sessionId`, a
19
+ * token minted for one user validates for every other user.
5
20
  */
6
- import { createHash, randomBytes } from "node:crypto";
21
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
7
22
  /** Default token expiration (1 hour). */
8
23
  const DEFAULT_EXPIRATION = 3600;
9
24
  /** Default cookie name for CSRF token. */
@@ -14,34 +29,82 @@ const DEFAULT_HEADER_NAME = "x-csrf-token";
14
29
  const SAFE_METHODS = ["GET", "HEAD", "OPTIONS", "TRACE"];
15
30
  /** Default methods that require CSRF protection. */
16
31
  const DEFAULT_METHODS = ["POST", "PUT", "PATCH", "DELETE"];
32
+ /**
33
+ * Minimum accepted secret length, in characters.
34
+ *
35
+ * The signature is HMAC-SHA256, so a secret shorter than the 32-byte output
36
+ * adds no strength beyond its own length. `"CSRF secret cannot be empty"` was
37
+ * the only check, which accepted a one-character secret in silence.
38
+ */
39
+ export const MIN_CSRF_SECRET_LENGTH = 32;
40
+ /** Rejects a secret too short to be worth signing with. */
41
+ function assertUsableSecret(secret) {
42
+ if (secret.length === 0) {
43
+ throw new Error("CSRF secret cannot be empty: pass a random string of at least " +
44
+ `${MIN_CSRF_SECRET_LENGTH} characters, e.g. randomBytes(32).toString("hex")`);
45
+ }
46
+ if (secret.length < MIN_CSRF_SECRET_LENGTH) {
47
+ throw new Error(`CSRF secret is too short: got ${secret.length} characters, expected at least ` +
48
+ `${MIN_CSRF_SECRET_LENGTH}. The signature is HMAC-SHA256, so a shorter ` +
49
+ 'secret adds no strength. Generate one with randomBytes(32).toString("hex").');
50
+ }
51
+ }
52
+ /**
53
+ * Computes the token signature.
54
+ *
55
+ * HMAC-SHA256 rather than `sha256(payload + secret)`: the plain-hash form is a
56
+ * secret-suffix construction with no security proof, and truncating it to 64
57
+ * bits leaves far too little margin.
58
+ */
59
+ function sign(secret, expiresAt, random, sessionId) {
60
+ return createHmac("sha256", secret)
61
+ .update(`${expiresAt}:${random}:${sessionId}`)
62
+ .digest("hex");
63
+ }
64
+ /** Constant-time string comparison that does not leak length via early exit. */
65
+ function safeEqual(a, b) {
66
+ const bufA = Buffer.from(a, "utf8");
67
+ const bufB = Buffer.from(b, "utf8");
68
+ if (bufA.length !== bufB.length) {
69
+ // Still burn a comparison so the timing profile does not distinguish a
70
+ // length mismatch from a content mismatch.
71
+ timingSafeEqual(bufA, bufA);
72
+ return false;
73
+ }
74
+ return timingSafeEqual(bufA, bufB);
75
+ }
17
76
  /**
18
77
  * Generates a cryptographically secure CSRF token.
19
78
  *
20
79
  * @param secret - The secret key for HMAC generation.
21
- * @param expiration - Optional expiration in seconds (default: 3600).
22
- * @returns The CSRF token string.
80
+ * @param options - Expiration and session binding, or a bare expiration in
81
+ * seconds for backwards compatibility.
82
+ * @returns The CSRF token string, in the form `expiresAt:random:signature`.
23
83
  */
24
- export function generateCsrfToken(secret, expiration) {
25
- const ttl = expiration ?? DEFAULT_EXPIRATION;
84
+ export function generateCsrfToken(secret, options) {
85
+ assertUsableSecret(secret);
86
+ const opts = typeof options === "number" ? { expiration: options } : (options ?? {});
87
+ const ttl = opts.expiration ?? DEFAULT_EXPIRATION;
88
+ if (!Number.isFinite(ttl) || ttl <= 0) {
89
+ throw new RangeError(`CSRF expiration must be positive, got: ${ttl}`);
90
+ }
26
91
  const expiresAt = Math.floor(Date.now() / 1000) + ttl;
27
92
  const random = randomBytes(16).toString("hex");
28
- const payload = `${expiresAt}:${random}`;
29
- const signature = createHash("sha256")
30
- .update(`${payload}:${secret}`)
31
- .digest("hex")
32
- .slice(0, 16);
33
- return `${payload}:${signature}`;
93
+ const signature = sign(secret, expiresAt, random, opts.sessionId ?? "");
94
+ return `${expiresAt}:${random}:${signature}`;
34
95
  }
35
96
  /**
36
97
  * Validates a CSRF token.
37
98
  *
38
99
  * @param token - The CSRF token to validate.
39
100
  * @param secret - The secret key for verification.
40
- * @param expiration - Optional expiration in seconds (default: 3600).
41
- * @returns True if the token is valid and not expired.
101
+ * @param options - Maximum lifetime and session binding, or a bare expiration
102
+ * in seconds for backwards compatibility.
103
+ * @returns True if the token is valid, unexpired, and bound to this session.
42
104
  */
43
- export function validateCsrfToken(token, secret, expiration) {
44
- const ttl = expiration ?? DEFAULT_EXPIRATION;
105
+ export function validateCsrfToken(token, secret, options) {
106
+ const opts = typeof options === "number" ? { expiration: options } : (options ?? {});
107
+ const maxTtl = opts.expiration ?? DEFAULT_EXPIRATION;
45
108
  const parts = token.split(":");
46
109
  if (parts.length !== 3) {
47
110
  return false;
@@ -50,30 +113,50 @@ export function validateCsrfToken(token, secret, expiration) {
50
113
  if (!expiresAtStr || !random || !providedSignature) {
51
114
  return false;
52
115
  }
53
- const expiresAt = parseInt(expiresAtStr, 10);
54
- if (isNaN(expiresAt)) {
116
+ if (!/^\d+$/.test(expiresAtStr)) {
117
+ return false;
118
+ }
119
+ const expiresAt = Number(expiresAtStr);
120
+ if (!Number.isSafeInteger(expiresAt)) {
55
121
  return false;
56
122
  }
57
- // Check expiration
58
123
  const now = Math.floor(Date.now() / 1000);
124
+ // Expired.
59
125
  if (now > expiresAt) {
60
126
  return false;
61
127
  }
62
- // Verify signature
63
- const payload = `${expiresAt}:${random}`;
64
- const expectedSignature = createHash("sha256")
65
- .update(`${payload}:${secret}`)
66
- .digest("hex")
67
- .slice(0, 16);
68
- // Constant-time comparison
69
- if (providedSignature.length !== expectedSignature.length) {
128
+ // Issued with a longer lifetime than this caller is willing to honour. The
129
+ // expiry is inside the signed payload, so it cannot be forged — but a token
130
+ // minted with a ten-year TTL should not be accepted by a route that expects
131
+ // one hour.
132
+ if (expiresAt - now > maxTtl) {
70
133
  return false;
71
134
  }
72
- let result = 0;
73
- for (let i = 0; i < providedSignature.length; i++) {
74
- result |= providedSignature.charCodeAt(i) ^ expectedSignature.charCodeAt(i);
135
+ const expectedSignature = sign(secret, expiresAt, random, opts.sessionId ?? "");
136
+ return safeEqual(providedSignature, expectedSignature);
137
+ }
138
+ /**
139
+ * Verifies a state-changing request under the double-submit / synchroniser
140
+ * token pattern.
141
+ *
142
+ * Both tokens must be present, identical, and individually valid. Comparing
143
+ * the two alone is not enough — an attacker who can set a cookie on the origin
144
+ * could otherwise supply a matching pair of their own.
145
+ *
146
+ * @param cookieToken - Token taken from the CSRF cookie.
147
+ * @param requestToken - Token taken from the request header or form field.
148
+ * @param secret - The secret key for verification.
149
+ * @param options - Maximum lifetime and session binding.
150
+ * @returns True when the request carries a matching, valid token.
151
+ */
152
+ export function verifyDoubleSubmit(cookieToken, requestToken, secret, options) {
153
+ if (!cookieToken || !requestToken) {
154
+ return false;
155
+ }
156
+ if (!safeEqual(cookieToken, requestToken)) {
157
+ return false;
75
158
  }
76
- return result === 0;
159
+ return validateCsrfToken(cookieToken, secret, options);
77
160
  }
78
161
  /**
79
162
  * Checks if a request method requires CSRF protection.
@@ -92,13 +175,22 @@ export function requiresCsrfProtection(method, config) {
92
175
  /**
93
176
  * Extracts the CSRF token from request headers.
94
177
  *
178
+ * Lookup is case-insensitive: HTTP header names are, and a raw header bag is
179
+ * not guaranteed to arrive lowercased.
180
+ *
95
181
  * @param headers - Request headers.
96
182
  * @param headerName - The header name to look for.
97
183
  * @returns The CSRF token, or undefined.
98
184
  */
99
185
  export function extractCsrfTokenFromHeaders(headers, headerName) {
100
- const name = headerName ?? DEFAULT_HEADER_NAME;
101
- const value = headers[name];
186
+ const name = (headerName ?? DEFAULT_HEADER_NAME).toLowerCase();
187
+ let value;
188
+ for (const key of Object.keys(headers)) {
189
+ if (key.toLowerCase() === name) {
190
+ value = headers[key];
191
+ break;
192
+ }
193
+ }
102
194
  if (typeof value === "string") {
103
195
  return value;
104
196
  }
@@ -129,21 +221,87 @@ export function extractCsrfTokenFromCookies(cookieHeader, cookieName) {
129
221
  return cookie?.value || undefined;
130
222
  }
131
223
  /**
132
- * Generates Set-Cookie header for CSRF token.
224
+ * Generates a Set-Cookie header for a CSRF token.
133
225
  *
134
226
  * @param token - The CSRF token to store.
135
- * @param config - Optional CSRF configuration.
227
+ * @param config - Optional CSRF and cookie configuration.
136
228
  * @returns The Set-Cookie header value.
137
229
  */
138
230
  export function generateCsrfCookie(token, config) {
139
231
  const name = config?.cookieName ?? DEFAULT_COOKIE_NAME;
140
232
  const ttl = config?.expiration ?? DEFAULT_EXPIRATION;
141
- return [
142
- `${name}=${token}`,
143
- "Path=/",
144
- "HttpOnly",
145
- "SameSite=Strict",
146
- `Max-Age=${ttl}`,
147
- ].join("; ");
233
+ const httpOnly = config?.httpOnly ?? true;
234
+ const secure = config?.secure ?? true;
235
+ const path = config?.path ?? "/";
236
+ const parts = [`${name}=${token}`, `Path=${path}`];
237
+ if (httpOnly) {
238
+ parts.push("HttpOnly");
239
+ }
240
+ if (secure) {
241
+ parts.push("Secure");
242
+ }
243
+ parts.push("SameSite=Strict", `Max-Age=${ttl}`);
244
+ return parts.join("; ");
245
+ }
246
+ /**
247
+ * Binds a {@link CsrfConfig} to the CSRF primitives.
248
+ *
249
+ * Every field of `CsrfConfig` was previously inert. `secret` — the one
250
+ * *required* field — was never read by anything: each function took the secret
251
+ * as a positional argument instead. `headerName` was likewise never read, so a
252
+ * caller who configured it still had `x-csrf-token` looked up. Reaching the
253
+ * configured names meant passing them again, by hand, at four separate call
254
+ * sites.
255
+ *
256
+ * This composes the existing functions; it introduces no new token format.
257
+ *
258
+ * @param config - Secret, lifetime, cookie/header names, protected methods.
259
+ * @returns Protection bound to that configuration.
260
+ * @throws {Error} when the secret is missing or shorter than
261
+ * {@link MIN_CSRF_SECRET_LENGTH}.
262
+ */
263
+ export function createCsrfProtection(config) {
264
+ assertUsableSecret(config.secret);
265
+ const expiration = config.expiration ?? DEFAULT_EXPIRATION;
266
+ const cookieName = config.cookieName ?? DEFAULT_COOKIE_NAME;
267
+ const headerName = config.headerName ?? DEFAULT_HEADER_NAME;
268
+ return {
269
+ issue(options) {
270
+ const token = generateCsrfToken(config.secret, {
271
+ expiration,
272
+ ...(options?.sessionId !== undefined
273
+ ? { sessionId: options.sessionId }
274
+ : {}),
275
+ });
276
+ return {
277
+ token,
278
+ setCookie: generateCsrfCookie(token, {
279
+ cookieName,
280
+ expiration,
281
+ ...(config.httpOnly !== undefined
282
+ ? { httpOnly: config.httpOnly }
283
+ : {}),
284
+ ...(config.secure !== undefined ? { secure: config.secure } : {}),
285
+ ...(config.path !== undefined ? { path: config.path } : {}),
286
+ }),
287
+ };
288
+ },
289
+ verify(request, options) {
290
+ if (!requiresCsrfProtection(request.method, config)) {
291
+ return true;
292
+ }
293
+ const cookieToken = extractCsrfTokenFromCookies(request.cookieHeader ?? "", cookieName);
294
+ const requestToken = extractCsrfTokenFromHeaders(request.headers ?? {}, headerName);
295
+ return verifyDoubleSubmit(cookieToken, requestToken, config.secret, {
296
+ expiration,
297
+ ...(options?.sessionId !== undefined
298
+ ? { sessionId: options.sessionId }
299
+ : {}),
300
+ });
301
+ },
302
+ requiresProtection(method) {
303
+ return requiresCsrfProtection(method, config);
304
+ },
305
+ };
148
306
  }
149
307
  //# sourceMappingURL=csrf.core.js.map
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * @zudojs/security — CSRF Protection Barrel
3
3
  */
4
- export { generateCsrfToken, validateCsrfToken, requiresCsrfProtection, extractCsrfTokenFromHeaders, extractCsrfTokenFromCookies, generateCsrfCookie, } from "./csrf.core.js";
4
+ export { generateCsrfToken, validateCsrfToken, verifyDoubleSubmit, requiresCsrfProtection, extractCsrfTokenFromHeaders, extractCsrfTokenFromCookies, generateCsrfCookie, createCsrfProtection, MIN_CSRF_SECRET_LENGTH, } from "./csrf.core.js";
5
+ export type { CsrfTokenOptions, CsrfCookieOptions, CsrfProtection, CsrfProtectionOptions, IssuedCsrfToken, CsrfVerifiableRequest, } from "./csrf.core.js";
5
6
  //# sourceMappingURL=index.d.ts.map
@@ -1,5 +1,5 @@
1
1
  /**
2
2
  * @zudojs/security — CSRF Protection Barrel
3
3
  */
4
- export { generateCsrfToken, validateCsrfToken, requiresCsrfProtection, extractCsrfTokenFromHeaders, extractCsrfTokenFromCookies, generateCsrfCookie, } from "./csrf.core.js";
4
+ export { generateCsrfToken, validateCsrfToken, verifyDoubleSubmit, requiresCsrfProtection, extractCsrfTokenFromHeaders, extractCsrfTokenFromCookies, generateCsrfCookie, createCsrfProtection, MIN_CSRF_SECRET_LENGTH, } from "./csrf.core.js";
5
5
  //# sourceMappingURL=index.js.map
@@ -22,9 +22,14 @@ const DEFAULT_BLOCKED_HEADERS = [
22
22
  */
23
23
  const CRLF_PATTERN = /[\r\n]/;
24
24
  /**
25
- * Null byte pattern.
25
+ * Null byte patterns.
26
+ *
27
+ * The plain form is for {@link RegExp.test}; the `g` form is for
28
+ * {@link String.replace}, which without the flag would strip only the first
29
+ * occurrence.
26
30
  */
27
31
  const NULL_BYTE_PATTERN = /\x00/;
32
+ const NULL_BYTE_PATTERN_GLOBAL = /\x00/g;
28
33
  /**
29
34
  * Validates a single header name.
30
35
  *
@@ -101,16 +106,23 @@ export function validateHeaders(headers, config) {
101
106
  errors.push(nameError);
102
107
  continue;
103
108
  }
109
+ if (config?.blockHopByHop === true && isHopByHopHeader(name)) {
110
+ errors.push(`Header "${name}" is hop-by-hop and must not be forwarded (RFC 9110 s7.6.1)`);
111
+ continue;
112
+ }
104
113
  // Validate values (may be array for Set-Cookie, etc.)
105
114
  const values = Array.isArray(value) ? value : [value];
106
115
  for (const v of values) {
116
+ // Size is accumulated whether or not the value is otherwise valid.
117
+ // Skipping invalid values under-counted exactly the headers most likely
118
+ // to breach `maxTotalSize`, so an oversized header could evade the total
119
+ // bound by also being malformed.
120
+ totalSize +=
121
+ Buffer.byteLength(name, "utf8") + Buffer.byteLength(v, "utf8");
107
122
  const valueError = validateHeaderValue(name, v, config);
108
123
  if (valueError) {
109
124
  errors.push(valueError);
110
- continue;
111
125
  }
112
- totalSize +=
113
- Buffer.byteLength(name, "utf8") + Buffer.byteLength(v, "utf8");
114
126
  }
115
127
  }
116
128
  // Check total size
@@ -131,7 +143,7 @@ export function validateHeaders(headers, config) {
131
143
  */
132
144
  export function sanitizeHeaderValue(value) {
133
145
  // Strip null bytes
134
- let sanitized = value.replace(NULL_BYTE_PATTERN, "");
146
+ let sanitized = value.replace(NULL_BYTE_PATTERN_GLOBAL, "");
135
147
  // Strip CRLF (strip both \r and \n)
136
148
  sanitized = sanitized.replace(/[\r\n]/g, "");
137
149
  return sanitized.length > 0 ? sanitized : undefined;
@@ -148,6 +160,7 @@ export function isHopByHopHeader(name) {
148
160
  "keep-alive",
149
161
  "proxy-authenticate",
150
162
  "proxy-authorization",
163
+ "proxy-connection",
151
164
  "te",
152
165
  "trailer",
153
166
  "transfer-encoding",
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Generates security-related HTTP response headers with secure defaults.
5
5
  */
6
+ import { randomBytes } from "node:crypto";
6
7
  /** Security header names. */
7
8
  export const SECURITY_HEADER_NAMES = {
8
9
  CONTENT_SECURITY_POLICY: "Content-Security-Policy",
@@ -16,15 +17,33 @@ export const SECURITY_HEADER_NAMES = {
16
17
  OPENER_POLICY: "Cross-Origin-Opener-Policy",
17
18
  RESOURCE_POLICY: "Cross-Origin-Resource-Policy",
18
19
  };
20
+ /**
21
+ * Default Content-Security-Policy.
22
+ *
23
+ * Deliberately restrictive: no scripts, styles, or frames from anywhere, and
24
+ * no plugin content. An app that serves a UI must replace this — the point of
25
+ * the default is that a service which forgets to set one is still locked down.
26
+ */
27
+ const DEFAULT_CSP = "default-src 'self'; script-src 'self'; style-src 'self'; " +
28
+ "img-src 'self' data:; object-src 'none'; frame-ancestors 'none'; " +
29
+ "base-uri 'self'; form-action 'self'";
30
+ /** Default HSTS: two years, all subdomains, preload-eligible. */
31
+ const DEFAULT_HSTS = "max-age=63072000; includeSubDomains; preload";
32
+ /** Default Permissions-Policy: deny the high-risk capabilities. */
33
+ const DEFAULT_PERMISSIONS_POLICY = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), " +
34
+ "magnetometer=(), microphone=(), payment=(), usb=()";
19
35
  /** Default security headers. */
20
36
  const DEFAULT_HEADERS = {
21
37
  [SECURITY_HEADER_NAMES.CONTENT_TYPE_OPTIONS]: "nosniff",
22
38
  [SECURITY_HEADER_NAMES.FRAME_OPTIONS]: "DENY",
23
- [SECURITY_HEADER_NAMES.XSS_PROTECTION]: "1; mode=block",
39
+ [SECURITY_HEADER_NAMES.XSS_PROTECTION]: "0",
24
40
  [SECURITY_HEADER_NAMES.REFERRER_POLICY]: "strict-origin-when-cross-origin",
25
41
  [SECURITY_HEADER_NAMES.DNS_PREFETCH_CONTROL]: "off",
26
42
  [SECURITY_HEADER_NAMES.OPENER_POLICY]: "same-origin",
27
43
  [SECURITY_HEADER_NAMES.RESOURCE_POLICY]: "same-origin",
44
+ [SECURITY_HEADER_NAMES.CONTENT_SECURITY_POLICY]: DEFAULT_CSP,
45
+ [SECURITY_HEADER_NAMES.HSTS]: DEFAULT_HSTS,
46
+ [SECURITY_HEADER_NAMES.PERMISSIONS_POLICY]: DEFAULT_PERMISSIONS_POLICY,
28
47
  };
29
48
  /**
30
49
  * Generates security headers with secure defaults.
@@ -34,6 +53,15 @@ const DEFAULT_HEADERS = {
34
53
  */
35
54
  export function generateSecurityHeaders(config) {
36
55
  const headers = { ...DEFAULT_HEADERS };
56
+ // Config values end up verbatim in a response header, so a stray CRLF here
57
+ // would split the response exactly as an attacker-supplied one would.
58
+ if (config) {
59
+ for (const [key, value] of Object.entries(config)) {
60
+ if (typeof value === "string" && /[\r\n\x00]/.test(value)) {
61
+ throw new Error(`Security header "${key}" contains CRLF or null bytes (injection risk)`);
62
+ }
63
+ }
64
+ }
37
65
  if (config?.contentSecurityPolicy) {
38
66
  headers[SECURITY_HEADER_NAMES.CONTENT_SECURITY_POLICY] =
39
67
  config.contentSecurityPolicy;
@@ -93,8 +121,10 @@ export function getMissingSecurityHeaders(headers) {
93
121
  * @returns The base64-encoded nonce.
94
122
  */
95
123
  export function generateCspNonce(nonceLength) {
96
- const { randomBytes } = require("node:crypto");
97
124
  const length = nonceLength ?? 16;
125
+ if (!Number.isInteger(length) || length < 16) {
126
+ throw new RangeError(`CSP nonce length must be an integer of at least 16 bytes, got: ${length}`);
127
+ }
98
128
  return randomBytes(length).toString("base64");
99
129
  }
100
130
  /**
@@ -118,7 +148,9 @@ export function validateCspDirective(directive) {
118
148
  if (directive.includes("'unsafe-hashes'")) {
119
149
  warnings.push("unsafe-hashes weakens CSP");
120
150
  }
121
- // Return first warning as info (not error)
122
- return warnings.length > 0 ? warnings[0] : undefined;
151
+ // Every warning, not just the first. A policy carrying all three used to
152
+ // report only `unsafe-inline`, so fixing that one made the function fall
153
+ // silent while `unsafe-eval` and `unsafe-hashes` were still in place.
154
+ return warnings.length > 0 ? warnings.join("; ") : undefined;
123
155
  }
124
156
  //# sourceMappingURL=headers.core.js.map
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * @example
11
11
  * ```ts
12
- * import { validateHeaders, validateUrl, generateSecurityHeaders } from '@zudojs/security';
12
+ * import { validateHeaders, validateRequestTarget, generateSecurityHeaders } from '@zudojs/security';
13
13
  *
14
14
  * // Validate request headers
15
15
  * const result = validateHeaders(request.headers);
@@ -17,8 +17,10 @@
17
17
  * throw new Error(result.errors.join(', '));
18
18
  * }
19
19
  *
20
- * // Validate URL
21
- * const urlResult = validateUrl(request.url);
20
+ * // Validate the request target. Node's `request.url` is origin-form
21
+ * // ("/users?page=1"), which has no scheme — `validateUrl` needs an absolute
22
+ * // URL and reports every such target as malformed.
23
+ * const targetResult = validateRequestTarget(request.url);
22
24
  *
23
25
  * // Add security headers
24
26
  * const headers = generateSecurityHeaders();
@@ -26,19 +28,22 @@
26
28
  *
27
29
  * @packageDocumentation
28
30
  */
29
- export type { HeaderSecurityConfig, HeaderValidationResult, BodyLimitConfig, BodyLimitPresets, UrlValidationConfig, UrlValidationResult, CookieSecurityConfig, ParsedCookie, CorsConfig, CsrfConfig, RateLimitConfig, RateLimitRequest, RateLimitResponse, RateLimitResult, SecurityHeadersConfig, RequestValidationConfig, InputSanitizationConfig, } from "./types/security.type.js";
31
+ export type { HeaderSecurityConfig, HeaderValidationResult, BodyLimitConfig, BodyLimitPresets, UrlValidationConfig, UrlValidationResult, CookieSecurityConfig, ParsedCookie, CorsConfig, CsrfConfig, RateLimitConfig, RateLimitRequest, RateLimitResponse, RateLimitResult, SecurityHeadersConfig, InputSanitizationConfig, } from "./types/security.type.js";
30
32
  export { PROTOTYPE_POLLUTION_KEYS, SQL_INJECTION_PATTERNS, XSS_PATTERNS, } from "./types/security.type.js";
31
33
  export { validateHeaderName, validateHeaderValue, validateHeaders, sanitizeHeaderValue, isHopByHopHeader, } from "./header/index.js";
32
- export { validateUrl, normalizePath, validateRequestTarget, isSafeUrl, } from "./url/index.js";
33
- export { DEFAULT_BODY_LIMITS, validateContentLength, validateBodySize, getBodyLimitForContentType, validateBodyLimitConfig, createBodySizeChecker, } from "./body/index.js";
34
+ export { validateUrl, normalizePath, validateRequestTarget, isSafeUrl, isPrivateHostname, containsTraversal, fullyDecodeUri, } from "./url/index.js";
35
+ export type { RequestTargetConfig } from "./url/index.js";
36
+ export { DEFAULT_BODY_LIMITS, parseMediaType, validateBodyFraming, validateContentLength, validateBodySize, getBodyLimitForContentType, validateBodyLimitConfig, resolveBodyLimit, createBodySizeChecker, } from "./body/index.js";
34
37
  export { parseCookieHeader, serializeCookie, createSecureCookie, validateCookieName, validateCookieValue, stripSensitiveCookies, } from "./cookie/index.js";
35
38
  export type { CorsHeaders } from "./cors/index.js";
36
39
  export { isOriginAllowed, generatePreflightHeaders, generateSimpleHeaders, isMethodAllowed, getDisallowedHeaders, } from "./cors/index.js";
37
40
  export { cors } from "./cors/cors.namespace.js";
38
- export { generateCsrfToken, validateCsrfToken, requiresCsrfProtection, extractCsrfTokenFromHeaders, extractCsrfTokenFromCookies, generateCsrfCookie, } from "./csrf/index.js";
39
- export { defaultKeyGenerator, defaultHandler, createRateLimiter, extractClientIp, } from "./rateLimit/index.js";
41
+ export { generateCsrfToken, validateCsrfToken, verifyDoubleSubmit, requiresCsrfProtection, extractCsrfTokenFromHeaders, extractCsrfTokenFromCookies, generateCsrfCookie, createCsrfProtection, MIN_CSRF_SECRET_LENGTH, } from "./csrf/index.js";
42
+ export type { CsrfTokenOptions, CsrfCookieOptions, CsrfProtection, CsrfProtectionOptions, IssuedCsrfToken, CsrfVerifiableRequest, } from "./csrf/index.js";
43
+ export { defaultKeyGenerator, defaultHandler, retryAfterSeconds, createRateLimiter, extractClientIp, } from "./rateLimit/index.js";
44
+ export type { RateLimiterOptions, ClientIpOptions } from "./rateLimit/index.js";
40
45
  export { rateLimit } from "./rateLimit/rateLimit.namespace.js";
41
46
  export { SECURITY_HEADER_NAMES } from "./headers/index.js";
42
47
  export { generateSecurityHeaders, getMissingSecurityHeaders, generateCspNonce, validateCspDirective, } from "./headers/index.js";
43
- export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, detectThreats, escapeHtml, stripHtml, } from "./input/index.js";
48
+ export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, withoutStickyFlags, detectThreats, escapeHtml, stripHtml, } from "./input/index.js";
44
49
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * @example
11
11
  * ```ts
12
- * import { validateHeaders, validateUrl, generateSecurityHeaders } from '@zudojs/security';
12
+ * import { validateHeaders, validateRequestTarget, generateSecurityHeaders } from '@zudojs/security';
13
13
  *
14
14
  * // Validate request headers
15
15
  * const result = validateHeaders(request.headers);
@@ -17,8 +17,10 @@
17
17
  * throw new Error(result.errors.join(', '));
18
18
  * }
19
19
  *
20
- * // Validate URL
21
- * const urlResult = validateUrl(request.url);
20
+ * // Validate the request target. Node's `request.url` is origin-form
21
+ * // ("/users?page=1"), which has no scheme — `validateUrl` needs an absolute
22
+ * // URL and reports every such target as malformed.
23
+ * const targetResult = validateRequestTarget(request.url);
22
24
  *
23
25
  * // Add security headers
24
26
  * const headers = generateSecurityHeaders();
@@ -30,21 +32,21 @@ export { PROTOTYPE_POLLUTION_KEYS, SQL_INJECTION_PATTERNS, XSS_PATTERNS, } from
30
32
  /* ─── Header Security ────────────────────────────────────────────────────── */
31
33
  export { validateHeaderName, validateHeaderValue, validateHeaders, sanitizeHeaderValue, isHopByHopHeader, } from "./header/index.js";
32
34
  /* ─── URL Validation ─────────────────────────────────────────────────────── */
33
- export { validateUrl, normalizePath, validateRequestTarget, isSafeUrl, } from "./url/index.js";
35
+ export { validateUrl, normalizePath, validateRequestTarget, isSafeUrl, isPrivateHostname, containsTraversal, fullyDecodeUri, } from "./url/index.js";
34
36
  /* ─── Body Validation ────────────────────────────────────────────────────── */
35
- export { DEFAULT_BODY_LIMITS, validateContentLength, validateBodySize, getBodyLimitForContentType, validateBodyLimitConfig, createBodySizeChecker, } from "./body/index.js";
37
+ export { DEFAULT_BODY_LIMITS, parseMediaType, validateBodyFraming, validateContentLength, validateBodySize, getBodyLimitForContentType, validateBodyLimitConfig, resolveBodyLimit, createBodySizeChecker, } from "./body/index.js";
36
38
  /* ─── Cookie Security ────────────────────────────────────────────────────── */
37
39
  export { parseCookieHeader, serializeCookie, createSecureCookie, validateCookieName, validateCookieValue, stripSensitiveCookies, } from "./cookie/index.js";
38
40
  export { isOriginAllowed, generatePreflightHeaders, generateSimpleHeaders, isMethodAllowed, getDisallowedHeaders, } from "./cors/index.js";
39
41
  export { cors } from "./cors/cors.namespace.js";
40
42
  /* ─── CSRF ───────────────────────────────────────────────────────────────── */
41
- export { generateCsrfToken, validateCsrfToken, requiresCsrfProtection, extractCsrfTokenFromHeaders, extractCsrfTokenFromCookies, generateCsrfCookie, } from "./csrf/index.js";
43
+ export { generateCsrfToken, validateCsrfToken, verifyDoubleSubmit, requiresCsrfProtection, extractCsrfTokenFromHeaders, extractCsrfTokenFromCookies, generateCsrfCookie, createCsrfProtection, MIN_CSRF_SECRET_LENGTH, } from "./csrf/index.js";
42
44
  /* ─── Rate Limiting ──────────────────────────────────────────────────────── */
43
- export { defaultKeyGenerator, defaultHandler, createRateLimiter, extractClientIp, } from "./rateLimit/index.js";
45
+ export { defaultKeyGenerator, defaultHandler, retryAfterSeconds, createRateLimiter, extractClientIp, } from "./rateLimit/index.js";
44
46
  export { rateLimit } from "./rateLimit/rateLimit.namespace.js";
45
47
  /* ─── Security Headers ───────────────────────────────────────────────────── */
46
48
  export { SECURITY_HEADER_NAMES } from "./headers/index.js";
47
49
  export { generateSecurityHeaders, getMissingSecurityHeaders, generateCspNonce, validateCspDirective, } from "./headers/index.js";
48
50
  /* ─── Input Sanitization ─────────────────────────────────────────────────── */
49
- export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, detectThreats, escapeHtml, stripHtml, } from "./input/index.js";
51
+ export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, withoutStickyFlags, detectThreats, escapeHtml, stripHtml, } from "./input/index.js";
50
52
  //# sourceMappingURL=index.js.map
@@ -1,5 +1,5 @@
1
1
  /**
2
2
  * @zudojs/security — Input Sanitization Barrel
3
3
  */
4
- export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, detectThreats, escapeHtml, stripHtml, } from "./input.core.js";
4
+ export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, withoutStickyFlags, detectThreats, escapeHtml, stripHtml, } from "./input.core.js";
5
5
  //# sourceMappingURL=index.d.ts.map
@@ -1,5 +1,5 @@
1
1
  /**
2
2
  * @zudojs/security — Input Sanitization Barrel
3
3
  */
4
- export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, detectThreats, escapeHtml, stripHtml, } from "./input.core.js";
4
+ export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, withoutStickyFlags, detectThreats, escapeHtml, stripHtml, } from "./input.core.js";
5
5
  //# sourceMappingURL=index.js.map
@@ -7,6 +7,9 @@ import type { InputSanitizationConfig } from "../types/security.type.js";
7
7
  /**
8
8
  * Checks if a string contains SQL injection patterns.
9
9
  *
10
+ * A heuristic with a high false-positive rate on ordinary prose — never the
11
+ * only defence against injection. Parameterise your queries.
12
+ *
10
13
  * @param input - The string to check.
11
14
  * @returns True if SQL injection patterns are detected.
12
15
  */
@@ -36,6 +39,10 @@ export declare function sanitizeString(input: string, config?: InputSanitization
36
39
  /**
37
40
  * Sanitizes an object by recursively cleaning its values.
38
41
  *
42
+ * Cycles and over-deep structures are handled: a repeated reference or a
43
+ * branch past `config.maxDepth` (default 32) becomes `undefined` instead of
44
+ * exhausting the stack.
45
+ *
39
46
  * @param obj - The object to sanitize.
40
47
  * @param config - Optional sanitization configuration.
41
48
  * @returns The sanitized object.
@@ -49,6 +56,13 @@ export declare function sanitizeObject<T extends Record<string, unknown>>(obj: T
49
56
  * @returns True if the string is safe.
50
57
  */
51
58
  export declare function isSafeString(input: string, allowedPattern?: RegExp): boolean;
59
+ /**
60
+ * Returns an equivalent regex with the `g` and `y` flags removed.
61
+ *
62
+ * Both flags make `test` stateful via `lastIndex`; for a one-shot boolean
63
+ * check they only introduce order-dependent results.
64
+ */
65
+ export declare function withoutStickyFlags(pattern: RegExp): RegExp;
52
66
  /**
53
67
  * Checks for common attack patterns in a string.
54
68
  *
@@ -59,6 +73,11 @@ export declare function detectThreats(input: string): string[];
59
73
  /**
60
74
  * HTML-escapes a string to prevent XSS.
61
75
  *
76
+ * Escapes the five characters that matter in element text and quoted attribute
77
+ * values. It does not make a string safe for an unquoted attribute, inside a
78
+ * `<script>` or `<style>` block, or in a URL position — those contexts need
79
+ * their own encoding.
80
+ *
62
81
  * @param input - The string to escape.
63
82
  * @returns The escaped string.
64
83
  */
@@ -66,6 +85,14 @@ export declare function escapeHtml(input: string): string;
66
85
  /**
67
86
  * Strips HTML tags from a string.
68
87
  *
88
+ * This removes tag syntax; it is **not** an HTML sanitizer. The result is safe
89
+ * to treat as plain text, but must still be escaped with {@link escapeHtml}
90
+ * before being inserted back into a document — use a dedicated sanitizer if
91
+ * you need to keep markup.
92
+ *
93
+ * An unterminated `<` consumes the remainder of the input, which is the safe
94
+ * direction: a truncated tag never survives into the output.
95
+ *
69
96
  * @param input - The string to strip.
70
97
  * @returns The string with HTML tags removed.
71
98
  */