@zudojs/security 1.0.0 → 1.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 (38) hide show
  1. package/README.md +56 -5
  2. package/dist/body/body.core.d.ts +6 -0
  3. package/dist/body/body.core.js +24 -4
  4. package/dist/body/body.guard.d.ts +19 -0
  5. package/dist/body/body.guard.js +26 -0
  6. package/dist/cookie/cookie.core.d.ts +10 -6
  7. package/dist/cookie/cookie.core.js +22 -20
  8. package/dist/cookie/cookie.sensitive.d.ts +27 -0
  9. package/dist/cookie/cookie.sensitive.js +61 -0
  10. package/dist/cookie/index.d.ts +1 -0
  11. package/dist/cookie/index.js +1 -0
  12. package/dist/cors/cors.core.js +2 -1
  13. package/dist/csrf/csrf.core.d.ts +3 -1
  14. package/dist/csrf/csrf.core.js +19 -5
  15. package/dist/headers/headers.core.js +2 -1
  16. package/dist/index.d.ts +4 -4
  17. package/dist/index.js +3 -3
  18. package/dist/input/input.core.js +10 -1
  19. package/dist/input/input.decode.d.ts +25 -0
  20. package/dist/input/input.decode.js +72 -0
  21. package/dist/rateLimit/index.d.ts +9 -2
  22. package/dist/rateLimit/index.js +6 -1
  23. package/dist/rateLimit/rateLimit.clientIp.d.ts +40 -0
  24. package/dist/rateLimit/rateLimit.clientIp.js +63 -0
  25. package/dist/rateLimit/rateLimit.clientKey.d.ts +51 -0
  26. package/dist/rateLimit/rateLimit.clientKey.js +104 -0
  27. package/dist/rateLimit/rateLimit.core.d.ts +5 -30
  28. package/dist/rateLimit/rateLimit.core.js +37 -84
  29. package/dist/rateLimit/rateLimit.namespace.d.ts +2 -1
  30. package/dist/rateLimit/rateLimit.namespace.js +2 -1
  31. package/dist/types/security.type.d.ts +1 -1
  32. package/dist/types/security.type.js +3 -0
  33. package/dist/url/index.d.ts +1 -0
  34. package/dist/url/index.js +1 -0
  35. package/dist/url/url.core.js +12 -27
  36. package/dist/url/url.ipv6.d.ts +36 -0
  37. package/dist/url/url.ipv6.js +88 -0
  38. package/package.json +7 -3
package/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Security primitives for input validation, header security, CORS, CSRF protection, rate limiting, and security headers.
4
4
 
5
+ <!-- zudo-docs:start -->
6
+
7
+ **Documentation:** [zudojs.oyinlola.site/docs/packages-security](https://zudojs.oyinlola.site/docs/packages-security) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-security.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
8
+
9
+ <!-- zudo-docs:end -->
10
+
5
11
  ## Installation
6
12
 
7
13
  ```bash
@@ -100,6 +106,20 @@ own rate-limit bucket. Set `trustProxy` to the number of proxies you actually
100
106
  operate; entries are then read in from the right, and everything to the left of
101
107
  your own hops is ignored.
102
108
 
109
+ The address it returns has any port and IPv6 brackets removed (some proxies
110
+ append `client-ip:port`, which used to give every TCP connection its own
111
+ bucket). The default key generator then keys IPv4 as-is, IPv4-mapped IPv6 as
112
+ IPv4, and other IPv6 by its **/64**, so one host cannot rotate through its own
113
+ prefix. Use `createIpKeyGenerator({ ipv6PrefixLength })` for another prefix,
114
+ and `ipRateLimitKey(ip)` to compute the key yourself; `getCount(ip)` and
115
+ `reset(ip)` accept the raw address.
116
+
117
+ **The default key generator throws a `ConfigurationError` when `ip` is
118
+ missing or is not an address** (including the `"unknown"` placeholder
119
+ `extractClientIp` returns when it has nothing to go on). Those requests used
120
+ to share one `"unknown"` bucket, so one client could starve everyone else.
121
+ Always pass `remoteAddress`, or supply your own `keyGenerator`.
122
+
103
123
  ## CORS
104
124
 
105
125
  ```typescript
@@ -153,6 +173,7 @@ const { token, setCookie } = csrf.issue({ sessionId });
153
173
  setHeader("Set-Cookie", setCookie);
154
174
 
155
175
  // Verify — safe to call on every request; safe methods return true.
176
+ // `methods` (default POST, PUT, PATCH, DELETE) is matched case-insensitively.
156
177
  if (!csrf.verify(
157
178
  { method: request.method, headers: request.headers, cookieHeader: request.headers.cookie },
158
179
  { sessionId },
@@ -189,6 +210,11 @@ if (requiresCsrfProtection(request.method)) {
189
210
  }
190
211
  ```
191
212
 
213
+ `validateCsrfToken` and `verifyDoubleSubmit` hold the secret to the same
214
+ 32-character minimum as `generateCsrfToken`, and throw `ConfigurationError`
215
+ otherwise — a verifier given `process.env.CSRF_SECRET ?? ""` used to accept
216
+ tokens signed with an empty key.
217
+
192
218
  The cookie is `Secure` and `HttpOnly` by default, which suits the synchroniser
193
219
  token pattern where the server renders the token into the page. For the
194
220
  double-submit pattern — where client script reads the cookie back — pass
@@ -209,8 +235,18 @@ isSafeUrl("gopher://internal/"); // false — protocol not allowlisted
209
235
  ```
210
236
 
211
237
  Addresses are range-checked numerically (127/8, 10/8, 172.16/12, 192.168/16,
212
- 169.254/16, 100.64/10, 0/8, `::1`, `fc00::/7`, `fe80::/10`), and only `http:`
213
- and `https:` are permitted unless you widen `allowedProtocols`.
238
+ 169.254/16, 100.64/10, 0/8, `::1`, `fc00::/7`, `fe80::/10`, `fec0::/10`,
239
+ `ff00::/8`), and only `http:` and `https:` are permitted unless you widen
240
+ `allowedProtocols`. IPv6 forms that embed an IPv4 address — `::a.b.c.d`,
241
+ `::ffff:a.b.c.d`, `::ffff:0:a.b.c.d`, NAT64 `64:ff9b::/96` and 6to4
242
+ `2002::/16` — are judged as that IPv4 address, whatever spelling the URL
243
+ parser gives them; the local-use NAT64 prefix `64:ff9b:1::/48` is refused.
244
+
245
+ The building blocks are exported for other guards: `expandIpv6(address)`
246
+ returns the eight 16-bit groups (or `undefined`), `embeddedIpv4(groups)`
247
+ returns the embedded IPv4 octets for the forms above (or `undefined`), and
248
+ `isNonPublicIpv6Range(groups)` is `true` for `fc00::/7`, `fe80::/10`,
249
+ `fec0::/10`, `ff00::/8` and `64:ff9b:1::/48`.
214
250
 
215
251
  **This cannot stop DNS rebinding.** A public hostname may resolve to a private
216
252
  address, and may resolve differently between the check and the connection. For
@@ -240,6 +276,13 @@ reached the caller with `errors: []`. Values are otherwise accepted leniently
240
276
  (spaces, commas and quoted-string wrappers are common in the wild); use
241
277
  `validateCookieValue` where you want the strict `cookie-octet` rule.
242
278
 
279
+ `stripSensitiveCookies(header)` removes a cookie when a sensitive word appears
280
+ anywhere in its name as a whole word (split on `.`, `-`, `_` and camelCase,
281
+ after dropping a `__Host-`/`__Secure-` prefix). The defaults
282
+ (`DEFAULT_SENSITIVE_COOKIE_NAMES`) cover `session`, `sid`, `token`, `auth`,
283
+ `jwt`, `csrf` and the framework defaults `connect.sid`, `PHPSESSID` and
284
+ `JSESSIONID`; `theme` or `sidebar` survive.
285
+
243
286
  ```typescript
244
287
  createSecureCookie("sid", value, { maxAge: 3600 });
245
288
  // sid=…; Max-Age=3600; Secure; HttpOnly; SameSite=Lax
@@ -255,9 +298,17 @@ detectThreats(input); // ordered: SQL_INJECTION, XSS, NULL_BYTE, CONTROL_CHARACT
255
298
  escapeHtml(text);
256
299
  ```
257
300
 
258
- `detectThreats` and `containsSqlInjection` are heuristics with a high
259
- false-positive rate on ordinary prose — useful for logging and alerting, never a
260
- substitute for parameterised queries or contextual output encoding.
301
+ `detectThreats`, `containsSqlInjection` and `containsXss` are heuristics with a
302
+ high false-positive rate on ordinary prose — useful for logging and alerting,
303
+ never a substitute for parameterised queries or contextual output encoding.
304
+ `containsXss` decodes HTML character references (`jav&#x61;script:`,
305
+ `javascript&colon;`) and ignores whitespace inside a scheme before matching.
306
+
307
+ Body limits must be finite numbers above zero: `validateBodySize`,
308
+ `validateContentLength` and `createBodySizeChecker` throw `ConfigurationError`
309
+ for `NaN` (what `Number(process.env.BODY_LIMIT)` gives when the variable is
310
+ unset), and `validateBodyLimitConfig` reports it — a `NaN` limit used to allow
311
+ any body.
261
312
  `escapeHtml` covers element text and quoted attribute values; unquoted
262
313
  attributes, `<script>` bodies and URL positions need their own encoding.
263
314
 
@@ -10,7 +10,10 @@ export declare const DEFAULT_BODY_LIMITS: BodyLimitPresets;
10
10
  * Validates the Content-Length header value.
11
11
  *
12
12
  * @param contentLength - The Content-Length header value.
13
+ * @param maxSize - Optional maximum in bytes.
13
14
  * @returns An error message if invalid, or undefined.
15
+ * @throws {ConfigurationError} when `maxSize` is given but is `NaN`,
16
+ * infinite or not above 0.
14
17
  */
15
18
  export declare function validateContentLength(contentLength: string | undefined, maxSize?: number): string | undefined;
16
19
  /**
@@ -32,6 +35,8 @@ export declare function validateBodyFraming(headers: Record<string, string | str
32
35
  * @param maxSize - The maximum allowed size in bytes.
33
36
  * @param contentType - Optional content type for context in error messages.
34
37
  * @returns An error message if too large, or undefined.
38
+ * @throws {ConfigurationError} when `maxSize` is `NaN`, infinite or not
39
+ * above 0 — it used to disable the check.
35
40
  */
36
41
  export declare function validateBodySize(actualSize: number, maxSize?: number, contentType?: string): string | undefined;
37
42
  /**
@@ -91,6 +96,7 @@ export declare function resolveBodyLimit(contentType: string | undefined, rules:
91
96
  * @param maxSize - The maximum body size in bytes.
92
97
  * @param contentType - Optional content type to check against.
93
98
  * @returns A function that checks if a size is within limits.
99
+ * @throws {ConfigurationError} when `maxSize` is not a finite number above 0.
94
100
  */
95
101
  export declare function createBodySizeChecker(maxSize: number, contentType?: string): (size: number) => {
96
102
  allowed: boolean;
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Validates request body size and content type against configurable limits.
5
5
  */
6
+ import { assertBodyLimit, isUsableBodyLimit } from "./body.guard.js";
6
7
  /** Default body limits for common use cases. */
7
8
  export const DEFAULT_BODY_LIMITS = {
8
9
  /** JSON API: 1MB */
@@ -20,7 +21,10 @@ const DEFAULT_MAX_BODY_SIZE = 1_048_576;
20
21
  * Validates the Content-Length header value.
21
22
  *
22
23
  * @param contentLength - The Content-Length header value.
24
+ * @param maxSize - Optional maximum in bytes.
23
25
  * @returns An error message if invalid, or undefined.
26
+ * @throws {ConfigurationError} when `maxSize` is given but is `NaN`,
27
+ * infinite or not above 0.
24
28
  */
25
29
  export function validateContentLength(contentLength, maxSize) {
26
30
  if (contentLength === undefined) {
@@ -36,6 +40,8 @@ export function validateContentLength(contentLength, maxSize) {
36
40
  if (!Number.isSafeInteger(parsed)) {
37
41
  return `Content-Length is not a safe integer: ${contentLength}`;
38
42
  }
43
+ if (maxSize !== undefined)
44
+ assertBodyLimit(maxSize);
39
45
  if (maxSize !== undefined && parsed > maxSize) {
40
46
  return `Content-Length ${parsed} exceeds maximum ${maxSize} bytes`;
41
47
  }
@@ -77,13 +83,19 @@ export function validateBodyFraming(headers, maxSize) {
77
83
  }
78
84
  return validateContentLength(contentLength, maxSize);
79
85
  }
80
- if (typeof transferEncoding === "string") {
81
- const encodings = transferEncoding
86
+ // A repeated Transfer-Encoding field is one comma-separated list (RFC 9110
87
+ // s5.3), so an array is flattened before the final-coding check. It used to
88
+ // be skipped entirely, so `["gzip"]` passed where `"gzip"` was rejected.
89
+ const transferEncodingList = Array.isArray(transferEncoding)
90
+ ? transferEncoding.join(",")
91
+ : transferEncoding;
92
+ if (typeof transferEncodingList === "string") {
93
+ const encodings = transferEncodingList
82
94
  .toLowerCase()
83
95
  .split(",")
84
96
  .map((e) => e.trim());
85
97
  if (encodings.length > 0 && encodings[encodings.length - 1] !== "chunked") {
86
- return `Transfer-Encoding must end with "chunked", got: ${transferEncoding}`;
98
+ return `Transfer-Encoding must end with "chunked", got: ${transferEncodingList}`;
87
99
  }
88
100
  }
89
101
  return undefined;
@@ -95,9 +107,15 @@ export function validateBodyFraming(headers, maxSize) {
95
107
  * @param maxSize - The maximum allowed size in bytes.
96
108
  * @param contentType - Optional content type for context in error messages.
97
109
  * @returns An error message if too large, or undefined.
110
+ * @throws {ConfigurationError} when `maxSize` is `NaN`, infinite or not
111
+ * above 0 — it used to disable the check.
98
112
  */
99
113
  export function validateBodySize(actualSize, maxSize, contentType) {
100
114
  const limit = maxSize ?? DEFAULT_MAX_BODY_SIZE;
115
+ assertBodyLimit(limit);
116
+ if (!Number.isFinite(actualSize) || actualSize < 0) {
117
+ return `Body size is not a valid byte count: ${actualSize}`;
118
+ }
101
119
  if (actualSize > limit) {
102
120
  const context = contentType ? ` for ${contentType}` : "";
103
121
  return `Body size ${actualSize} bytes exceeds maximum ${limit} bytes${context}`;
@@ -178,7 +196,7 @@ export function getBodyLimitForContentType(contentType, presetLimits, purpose) {
178
196
  * @returns An error message if invalid, or undefined.
179
197
  */
180
198
  export function validateBodyLimitConfig(config) {
181
- if (config.maxSize <= 0) {
199
+ if (!isUsableBodyLimit(config.maxSize)) {
182
200
  return `Body limit maxSize must be positive, got: ${config.maxSize}`;
183
201
  }
184
202
  if (config.maxSize > 1_073_741_824) {
@@ -238,8 +256,10 @@ export function resolveBodyLimit(contentType, rules, fallback = DEFAULT_MAX_BODY
238
256
  * @param maxSize - The maximum body size in bytes.
239
257
  * @param contentType - Optional content type to check against.
240
258
  * @returns A function that checks if a size is within limits.
259
+ * @throws {ConfigurationError} when `maxSize` is not a finite number above 0.
241
260
  */
242
261
  export function createBodySizeChecker(maxSize, contentType) {
262
+ assertBodyLimit(maxSize);
243
263
  return (size) => {
244
264
  const error = validateBodySize(size, maxSize, contentType);
245
265
  return {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @zudojs/security — Body limit guards.
3
+ */
4
+ /**
5
+ * True when `value` is usable as a body size limit: a finite number above 0.
6
+ *
7
+ * `NaN` is the case that matters. `Number(process.env.BODY_LIMIT)` with the
8
+ * variable unset yields `NaN`, which slipped past `limit <= 0` and made every
9
+ * `size > limit` comparison false, so an unset limit allowed any body.
10
+ */
11
+ export declare function isUsableBodyLimit(value: unknown): value is number;
12
+ /**
13
+ * Throws unless `value` is a usable body size limit.
14
+ *
15
+ * @throws {ConfigurationError} when `value` is `NaN`, infinite, zero,
16
+ * negative or not a number.
17
+ */
18
+ export declare function assertBodyLimit(value: unknown, name?: string): asserts value is number;
19
+ //# sourceMappingURL=body.guard.d.ts.map
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @zudojs/security — Body limit guards.
3
+ */
4
+ import { ConfigurationError } from "@zudojs/errors";
5
+ /**
6
+ * True when `value` is usable as a body size limit: a finite number above 0.
7
+ *
8
+ * `NaN` is the case that matters. `Number(process.env.BODY_LIMIT)` with the
9
+ * variable unset yields `NaN`, which slipped past `limit <= 0` and made every
10
+ * `size > limit` comparison false, so an unset limit allowed any body.
11
+ */
12
+ export function isUsableBodyLimit(value) {
13
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
14
+ }
15
+ /**
16
+ * Throws unless `value` is a usable body size limit.
17
+ *
18
+ * @throws {ConfigurationError} when `value` is `NaN`, infinite, zero,
19
+ * negative or not a number.
20
+ */
21
+ export function assertBodyLimit(value, name = "maxSize") {
22
+ if (!isUsableBodyLimit(value)) {
23
+ throw new ConfigurationError(`Body limit ${name} must be a finite number of bytes above 0, got: ${String(value)}`);
24
+ }
25
+ }
26
+ //# sourceMappingURL=body.guard.js.map
@@ -27,7 +27,7 @@ export declare function parseCookieHeader(cookieHeader: string, config?: CookieS
27
27
  * @param cookie - The cookie to serialize.
28
28
  * @param config - Optional security configuration for defaults.
29
29
  * @returns The serialized Set-Cookie header value.
30
- * @throws {Error} when the name, value, or an attribute is unsafe.
30
+ * @throws {ValidationError} when the name, value, or an attribute is unsafe.
31
31
  */
32
32
  export declare function serializeCookie(cookie: ParsedCookie, config?: CookieSecurityConfig): string;
33
33
  /**
@@ -38,7 +38,7 @@ export declare function serializeCookie(cookie: ParsedCookie, config?: CookieSec
38
38
  * @param options - Optional cookie attributes.
39
39
  * @param config - Optional security configuration.
40
40
  * @returns The serialized Set-Cookie header value.
41
- * @throws {Error} when the name, value, or an attribute is unsafe.
41
+ * @throws {ValidationError} when the name, value, or an attribute is unsafe.
42
42
  */
43
43
  export declare function createSecureCookie(name: string, value: string, options?: Partial<Omit<ParsedCookie, "name" | "value">>, config?: CookieSecurityConfig): string;
44
44
  /**
@@ -58,12 +58,16 @@ export declare function validateCookieValue(value: string): string | undefined;
58
58
  /**
59
59
  * Strips security-sensitive cookies from a cookie header.
60
60
  *
61
- * Matching is on the whole name and on `name`-prefixed variants (`session`
62
- * also strips `session_id` and `session-token`), because the sensitive cookie
63
- * in a real deployment is rarely named exactly `session`.
61
+ * A cookie is stripped when any configured name appears in its name as a
62
+ * whole word (words are split on `.`, `-`, `_` and camelCase, after a
63
+ * `__Host-`/`__Secure-` prefix is dropped). So `session` strips `session`,
64
+ * `session_id`, `__Host-session` and `next-auth.session-token`, and the
65
+ * defaults also strip `connect.sid`, `PHPSESSID`, `access_token` and
66
+ * `refresh_token`, which the old prefix-only match let through.
64
67
  *
65
68
  * @param cookieHeader - The raw Cookie header.
66
- * @param sensitiveNames - Names of cookies to strip (case-insensitive).
69
+ * @param sensitiveNames - Names of cookies to strip (case-insensitive;
70
+ * default {@link DEFAULT_SENSITIVE_COOKIE_NAMES}).
67
71
  * @returns The cleaned cookie header.
68
72
  */
69
73
  export declare function stripSensitiveCookies(cookieHeader: string, sensitiveNames?: readonly string[]): string;
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * Parses, validates, and serializes HTTP cookies with security best practices.
5
5
  */
6
+ import { ValidationError } from "@zudojs/errors";
7
+ import { DEFAULT_SENSITIVE_COOKIE_NAMES, isSensitiveCookieName, } from "./cookie.sensitive.js";
6
8
  /** Maximum cookie header size (4KB). */
7
9
  const MAX_COOKIE_HEADER_SIZE = 4096;
8
10
  /** Maximum number of cookies. */
@@ -101,12 +103,12 @@ export function parseCookieHeader(cookieHeader, config) {
101
103
  * @param cookie - The cookie to serialize.
102
104
  * @param config - Optional security configuration for defaults.
103
105
  * @returns The serialized Set-Cookie header value.
104
- * @throws {Error} when the name, value, or an attribute is unsafe.
106
+ * @throws {ValidationError} when the name, value, or an attribute is unsafe.
105
107
  */
106
108
  export function serializeCookie(cookie, config) {
107
109
  const nameError = validateCookieName(cookie.name);
108
110
  if (nameError) {
109
- throw new Error(`Cannot serialize cookie: ${nameError}`);
111
+ throw new ValidationError(`Cannot serialize cookie: ${nameError}`);
110
112
  }
111
113
  // Percent-encode unless the value is already a bare cookie-octet string, so
112
114
  // a caller that passes an encoded value does not get it double-encoded.
@@ -115,7 +117,7 @@ export function serializeCookie(cookie, config) {
115
117
  : encodeURIComponent(cookie.value);
116
118
  const valueError = validateCookieValue(encodedValue);
117
119
  if (valueError) {
118
- throw new Error(`Cannot serialize cookie "${cookie.name}": ${valueError}`);
120
+ throw new ValidationError(`Cannot serialize cookie "${cookie.name}": ${valueError}`);
119
121
  }
120
122
  const parts = [`${cookie.name}=${encodedValue}`];
121
123
  if (cookie.path) {
@@ -128,13 +130,13 @@ export function serializeCookie(cookie, config) {
128
130
  }
129
131
  if (cookie.maxAge !== undefined) {
130
132
  if (!Number.isInteger(cookie.maxAge)) {
131
- throw new Error(`Cannot serialize cookie "${cookie.name}": Max-Age must be an integer, got ${cookie.maxAge}`);
133
+ throw new ValidationError(`Cannot serialize cookie "${cookie.name}": Max-Age must be an integer, got ${cookie.maxAge}`);
132
134
  }
133
135
  parts.push(`Max-Age=${cookie.maxAge}`);
134
136
  }
135
137
  if (cookie.expires) {
136
138
  if (Number.isNaN(cookie.expires.getTime())) {
137
- throw new Error(`Cannot serialize cookie "${cookie.name}": Expires is an invalid Date`);
139
+ throw new ValidationError(`Cannot serialize cookie "${cookie.name}": Expires is an invalid Date`);
138
140
  }
139
141
  parts.push(`Expires=${cookie.expires.toUTCString()}`);
140
142
  }
@@ -144,11 +146,11 @@ export function serializeCookie(cookie, config) {
144
146
  // SameSite=None is only honoured on a Secure cookie; without it browsers
145
147
  // reject the cookie outright, which fails as a silent loss of state.
146
148
  if (sameSite === "none" && !secure) {
147
- throw new Error(`Cannot serialize cookie "${cookie.name}": SameSite=None requires the Secure attribute`);
149
+ throw new ValidationError(`Cannot serialize cookie "${cookie.name}": SameSite=None requires the Secure attribute`);
148
150
  }
149
151
  // Partitioned (CHIPS) likewise requires Secure.
150
152
  if (cookie.partitioned && !secure) {
151
- throw new Error(`Cannot serialize cookie "${cookie.name}": Partitioned requires the Secure attribute`);
153
+ throw new ValidationError(`Cannot serialize cookie "${cookie.name}": Partitioned requires the Secure attribute`);
152
154
  }
153
155
  if (secure) {
154
156
  parts.push("Secure");
@@ -166,7 +168,7 @@ export function serializeCookie(cookie, config) {
166
168
  /** Throws when an attribute value could terminate the attribute or the header. */
167
169
  function assertSafeAttribute(attribute, value) {
168
170
  if (ATTRIBUTE_UNSAFE.test(value)) {
169
- throw new Error(`Cookie ${attribute} contains invalid characters (injection risk): ${JSON.stringify(value)}`);
171
+ throw new ValidationError(`Cookie ${attribute} contains invalid characters (injection risk): ${JSON.stringify(value)}`);
170
172
  }
171
173
  }
172
174
  /**
@@ -177,7 +179,7 @@ function assertSafeAttribute(attribute, value) {
177
179
  * @param options - Optional cookie attributes.
178
180
  * @param config - Optional security configuration.
179
181
  * @returns The serialized Set-Cookie header value.
180
- * @throws {Error} when the name, value, or an attribute is unsafe.
182
+ * @throws {ValidationError} when the name, value, or an attribute is unsafe.
181
183
  */
182
184
  export function createSecureCookie(name, value, options, config) {
183
185
  return serializeCookie({ name, value, ...options }, config);
@@ -225,16 +227,19 @@ export function validateCookieValue(value) {
225
227
  /**
226
228
  * Strips security-sensitive cookies from a cookie header.
227
229
  *
228
- * Matching is on the whole name and on `name`-prefixed variants (`session`
229
- * also strips `session_id` and `session-token`), because the sensitive cookie
230
- * in a real deployment is rarely named exactly `session`.
230
+ * A cookie is stripped when any configured name appears in its name as a
231
+ * whole word (words are split on `.`, `-`, `_` and camelCase, after a
232
+ * `__Host-`/`__Secure-` prefix is dropped). So `session` strips `session`,
233
+ * `session_id`, `__Host-session` and `next-auth.session-token`, and the
234
+ * defaults also strip `connect.sid`, `PHPSESSID`, `access_token` and
235
+ * `refresh_token`, which the old prefix-only match let through.
231
236
  *
232
237
  * @param cookieHeader - The raw Cookie header.
233
- * @param sensitiveNames - Names of cookies to strip (case-insensitive).
238
+ * @param sensitiveNames - Names of cookies to strip (case-insensitive;
239
+ * default {@link DEFAULT_SENSITIVE_COOKIE_NAMES}).
234
240
  * @returns The cleaned cookie header.
235
241
  */
236
- export function stripSensitiveCookies(cookieHeader, sensitiveNames = ["session", "token", "auth", "jwt"]) {
237
- const lowerSensitive = sensitiveNames.map((n) => n.toLowerCase());
242
+ export function stripSensitiveCookies(cookieHeader, sensitiveNames = DEFAULT_SENSITIVE_COOKIE_NAMES) {
238
243
  return cookieHeader
239
244
  .split(";")
240
245
  .map((pair) => pair.trim())
@@ -242,11 +247,8 @@ export function stripSensitiveCookies(cookieHeader, sensitiveNames = ["session",
242
247
  const eqIndex = pair.indexOf("=");
243
248
  if (eqIndex === -1)
244
249
  return false;
245
- const name = pair.slice(0, eqIndex).trim().toLowerCase();
246
- return !lowerSensitive.some((sensitive) => name === sensitive ||
247
- name.startsWith(`${sensitive}_`) ||
248
- name.startsWith(`${sensitive}-`) ||
249
- name.startsWith(`${sensitive}.`));
250
+ const name = pair.slice(0, eqIndex).trim();
251
+ return !isSensitiveCookieName(name, sensitiveNames);
250
252
  })
251
253
  .join("; ");
252
254
  }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @zudojs/security — Sensitive cookie name matching.
3
+ */
4
+ /**
5
+ * Default names treated as credentials by `stripSensitiveCookies`.
6
+ *
7
+ * Covers the generic words (`session`, `token`, `auth`, `jwt`, `csrf`) and
8
+ * the framework defaults that contain none of them: `connect.sid`
9
+ * (express-session), `PHPSESSID`, `JSESSIONID`, `ASP.NET_SessionId`.
10
+ */
11
+ export declare const DEFAULT_SENSITIVE_COOKIE_NAMES: readonly string[];
12
+ /**
13
+ * Splits a cookie name into lower-case words: `__Secure-next-auth.session-token`
14
+ * becomes `next auth session token`, `sessionId` becomes `session id`.
15
+ */
16
+ export declare function cookieNameWords(name: string): string[];
17
+ /**
18
+ * True when `sensitive` appears in the cookie name as a whole word or a
19
+ * run of whole words.
20
+ *
21
+ * Matching whole words anywhere in the name — not just as a prefix — is
22
+ * what catches `access_token`, `refresh_token` and `connect.sid`, and
23
+ * dropping the `__Host-`/`__Secure-` prefix first is what catches
24
+ * `__Host-session`. Word boundaries keep `theme` or `sidebar` from matching.
25
+ */
26
+ export declare function isSensitiveCookieName(name: string, sensitiveNames?: readonly string[]): boolean;
27
+ //# sourceMappingURL=cookie.sensitive.d.ts.map
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @zudojs/security — Sensitive cookie name matching.
3
+ */
4
+ /**
5
+ * Default names treated as credentials by `stripSensitiveCookies`.
6
+ *
7
+ * Covers the generic words (`session`, `token`, `auth`, `jwt`, `csrf`) and
8
+ * the framework defaults that contain none of them: `connect.sid`
9
+ * (express-session), `PHPSESSID`, `JSESSIONID`, `ASP.NET_SessionId`.
10
+ */
11
+ export const DEFAULT_SENSITIVE_COOKIE_NAMES = Object.freeze([
12
+ "session",
13
+ "sessionid",
14
+ "sess",
15
+ "sid",
16
+ "phpsessid",
17
+ "jsessionid",
18
+ "token",
19
+ "auth",
20
+ "jwt",
21
+ "csrf",
22
+ "xsrf",
23
+ ]);
24
+ /** Cookie-name prefixes that carry no meaning of their own. */
25
+ const COOKIE_PREFIX = /^__(host|secure)-/;
26
+ /**
27
+ * Splits a cookie name into lower-case words: `__Secure-next-auth.session-token`
28
+ * becomes `next auth session token`, `sessionId` becomes `session id`.
29
+ */
30
+ export function cookieNameWords(name) {
31
+ return name
32
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
33
+ .toLowerCase()
34
+ .replace(COOKIE_PREFIX, "")
35
+ .split(/[^a-z0-9]+/)
36
+ .filter((word) => word.length > 0);
37
+ }
38
+ /**
39
+ * True when `sensitive` appears in the cookie name as a whole word or a
40
+ * run of whole words.
41
+ *
42
+ * Matching whole words anywhere in the name — not just as a prefix — is
43
+ * what catches `access_token`, `refresh_token` and `connect.sid`, and
44
+ * dropping the `__Host-`/`__Secure-` prefix first is what catches
45
+ * `__Host-session`. Word boundaries keep `theme` or `sidebar` from matching.
46
+ */
47
+ export function isSensitiveCookieName(name, sensitiveNames = DEFAULT_SENSITIVE_COOKIE_NAMES) {
48
+ const words = cookieNameWords(name);
49
+ return sensitiveNames.some((sensitive) => {
50
+ const needle = cookieNameWords(sensitive);
51
+ if (needle.length === 0)
52
+ return false;
53
+ for (let start = 0; start + needle.length <= words.length; start++) {
54
+ if (needle.every((word, offset) => words[start + offset] === word)) {
55
+ return true;
56
+ }
57
+ }
58
+ return false;
59
+ });
60
+ }
61
+ //# sourceMappingURL=cookie.sensitive.js.map
@@ -2,4 +2,5 @@
2
2
  * @zudojs/security — Cookie Security Barrel
3
3
  */
4
4
  export { parseCookieHeader, serializeCookie, createSecureCookie, validateCookieName, validateCookieValue, stripSensitiveCookies, } from "./cookie.core.js";
5
+ export { DEFAULT_SENSITIVE_COOKIE_NAMES, isSensitiveCookieName, } from "./cookie.sensitive.js";
5
6
  //# sourceMappingURL=index.d.ts.map
@@ -2,4 +2,5 @@
2
2
  * @zudojs/security — Cookie Security Barrel
3
3
  */
4
4
  export { parseCookieHeader, serializeCookie, createSecureCookie, validateCookieName, validateCookieValue, stripSensitiveCookies, } from "./cookie.core.js";
5
+ export { DEFAULT_SENSITIVE_COOKIE_NAMES, isSensitiveCookieName, } from "./cookie.sensitive.js";
5
6
  //# sourceMappingURL=index.js.map
@@ -4,6 +4,7 @@
4
4
  * Validates and generates CORS headers for cross-origin requests.
5
5
  */
6
6
  import { withoutStickyFlags } from "../input/input.core.js";
7
+ import { ConfigurationError } from "@zudojs/errors";
7
8
  /** Default CORS configuration (restrictive). */
8
9
  const DEFAULT_CORS_CONFIG = {
9
10
  origin: undefined,
@@ -26,7 +27,7 @@ function assertConfigCoherent(config) {
26
27
  const origin = config.origin;
27
28
  const hasWildcard = origin === "*" || (Array.isArray(origin) && origin.includes("*"));
28
29
  if (hasWildcard) {
29
- throw new Error('CORS: credentials cannot be combined with a wildcard origin ("*"). ' +
30
+ throw new ConfigurationError('CORS: credentials cannot be combined with a wildcard origin ("*"). ' +
30
31
  "Enumerate the allowed origins, or supply a function or RegExp.");
31
32
  }
32
33
  }
@@ -57,6 +57,8 @@ export declare function generateCsrfToken(secret: string, options?: number | Csr
57
57
  * @param options - Maximum lifetime and session binding, or a bare expiration
58
58
  * in seconds for backwards compatibility.
59
59
  * @returns True if the token is valid, unexpired, and bound to this session.
60
+ * @throws {ConfigurationError} when the secret is empty or shorter than
61
+ * {@link MIN_CSRF_SECRET_LENGTH}, exactly as `generateCsrfToken` does.
60
62
  */
61
63
  export declare function validateCsrfToken(token: string, secret: string, options?: number | CsrfTokenOptions): boolean;
62
64
  /**
@@ -190,7 +192,7 @@ export interface CsrfProtection {
190
192
  *
191
193
  * @param config - Secret, lifetime, cookie/header names, protected methods.
192
194
  * @returns Protection bound to that configuration.
193
- * @throws {Error} when the secret is missing or shorter than
195
+ * @throws {ConfigurationError} when the secret is missing or shorter than
194
196
  * {@link MIN_CSRF_SECRET_LENGTH}.
195
197
  */
196
198
  export declare function createCsrfProtection(config: CsrfProtectionOptions): CsrfProtection;
@@ -19,6 +19,7 @@
19
19
  * token minted for one user validates for every other user.
20
20
  */
21
21
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
22
+ import { ConfigurationError } from "@zudojs/errors";
22
23
  /** Default token expiration (1 hour). */
23
24
  const DEFAULT_EXPIRATION = 3600;
24
25
  /** Default cookie name for CSRF token. */
@@ -39,12 +40,12 @@ const DEFAULT_METHODS = ["POST", "PUT", "PATCH", "DELETE"];
39
40
  export const MIN_CSRF_SECRET_LENGTH = 32;
40
41
  /** Rejects a secret too short to be worth signing with. */
41
42
  function assertUsableSecret(secret) {
42
- if (secret.length === 0) {
43
- throw new Error("CSRF secret cannot be empty: pass a random string of at least " +
43
+ if (typeof secret !== "string" || secret.length === 0) {
44
+ throw new ConfigurationError("CSRF secret cannot be empty: pass a random string of at least " +
44
45
  `${MIN_CSRF_SECRET_LENGTH} characters, e.g. randomBytes(32).toString("hex")`);
45
46
  }
46
47
  if (secret.length < MIN_CSRF_SECRET_LENGTH) {
47
- throw new Error(`CSRF secret is too short: got ${secret.length} characters, expected at least ` +
48
+ throw new ConfigurationError(`CSRF secret is too short: got ${secret.length} characters, expected at least ` +
48
49
  `${MIN_CSRF_SECRET_LENGTH}. The signature is HMAC-SHA256, so a shorter ` +
49
50
  'secret adds no strength. Generate one with randomBytes(32).toString("hex").');
50
51
  }
@@ -101,10 +102,18 @@ export function generateCsrfToken(secret, options) {
101
102
  * @param options - Maximum lifetime and session binding, or a bare expiration
102
103
  * in seconds for backwards compatibility.
103
104
  * @returns True if the token is valid, unexpired, and bound to this session.
105
+ * @throws {ConfigurationError} when the secret is empty or shorter than
106
+ * {@link MIN_CSRF_SECRET_LENGTH}, exactly as `generateCsrfToken` does.
104
107
  */
105
108
  export function validateCsrfToken(token, secret, options) {
109
+ // Same bar as `generateCsrfToken`: a verifier configured with
110
+ // `process.env.CSRF_SECRET ?? ""` otherwise accepted tokens anyone can sign
111
+ // with an empty HMAC key.
112
+ assertUsableSecret(secret);
106
113
  const opts = typeof options === "number" ? { expiration: options } : (options ?? {});
107
114
  const maxTtl = opts.expiration ?? DEFAULT_EXPIRATION;
115
+ if (typeof token !== "string")
116
+ return false;
108
117
  const parts = token.split(":");
109
118
  if (parts.length !== 3) {
110
119
  return false;
@@ -150,6 +159,7 @@ export function validateCsrfToken(token, secret, options) {
150
159
  * @returns True when the request carries a matching, valid token.
151
160
  */
152
161
  export function verifyDoubleSubmit(cookieToken, requestToken, secret, options) {
162
+ assertUsableSecret(secret);
153
163
  if (!cookieToken || !requestToken) {
154
164
  return false;
155
165
  }
@@ -169,8 +179,12 @@ export function requiresCsrfProtection(method, config) {
169
179
  if (SAFE_METHODS.includes(method.toUpperCase())) {
170
180
  return false;
171
181
  }
182
+ // HTTP methods are case-sensitive on the wire but configured by hand;
183
+ // comparing a lower-case `methods: ["post"]` against the upper-cased
184
+ // request method used to protect nothing, silently.
185
+ const upper = method.toUpperCase();
172
186
  const methods = config?.methods ?? DEFAULT_METHODS;
173
- return methods.includes(method.toUpperCase());
187
+ return methods.some((m) => String(m).toUpperCase() === upper);
174
188
  }
175
189
  /**
176
190
  * Extracts the CSRF token from request headers.
@@ -257,7 +271,7 @@ export function generateCsrfCookie(token, config) {
257
271
  *
258
272
  * @param config - Secret, lifetime, cookie/header names, protected methods.
259
273
  * @returns Protection bound to that configuration.
260
- * @throws {Error} when the secret is missing or shorter than
274
+ * @throws {ConfigurationError} when the secret is missing or shorter than
261
275
  * {@link MIN_CSRF_SECRET_LENGTH}.
262
276
  */
263
277
  export function createCsrfProtection(config) {