@zudojs/security 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -219,6 +219,14 @@ if (requiresCsrfProtection(request.method)) {
219
219
  }
220
220
  ```
221
221
 
222
+ **Which methods skip the check.** Only GET, HEAD, OPTIONS and TRACE, matched
223
+ exactly after upper-casing (no trimming). Every other method is verified —
224
+ including an empty, padded or unknown one (`""`, `" "`, `"POST "`, `"FOO"`),
225
+ and CONNECT. A configured `methods` list can exempt a *standard* method it
226
+ leaves out (`methods: ["DELETE"]` exempts POST), never an unknown or malformed
227
+ one. Before 1.3.1 any method not in the list skipped the check, so
228
+ `csrf.verify({ method: "FOO" })` returned `true` with no token at all.
229
+
222
230
  **What returns `false` and what throws.** `csrf.verify`, `verifyDoubleSubmit`
223
231
  and `validateCsrfToken` return `false` for anything wrong with the request: a
224
232
  missing, malformed, non-string, forged, expired or mismatched token, a token
@@ -228,7 +236,8 @@ a secret shorter than 32 characters — the same minimum `generateCsrfToken`
228
236
  enforces, so a verifier given `process.env.CSRF_SECRET ?? ""` cannot accept
229
237
  tokens signed with an empty key — or an empty or invalid `methods` list. A
230
238
  throw from a CSRF check therefore means "fix your configuration", never "this
231
- request is bad".
239
+ request is bad". `createCsrfProtection` and `generateCsrfCookie` also throw
240
+ `ValidationError` for a cookie `path` that breaks the `Path` rules below.
232
241
 
233
242
  The cookie is `Secure` and `HttpOnly` by default, which suits the synchroniser
234
243
  token pattern where the server renders the token into the page. For the
@@ -287,7 +296,12 @@ an unsafe name, attribute, `Max-Age` or `Expires` throws. `SameSite=None` and
287
296
  `Domain` must be a hostname — labels of letters, digits and hyphens separated
288
297
  by dots, with an optional leading dot (`example.com`, `.example.com`) — and
289
298
  anything else (spaces, colons, backslashes, empty labels) throws
290
- `ValidationError`. `Path` may not contain a control character, `;` or `,`.
299
+ `ValidationError`. `Path` must be printable ASCII (0x20–0x7E) with no `;` or
300
+ `,`: a control character or any non-ASCII character (`"/ä"`) throws —
301
+ percent-encode it instead (`"/%C3%A4"`). This is RFC 6265's `path-value`
302
+ (any US-ASCII character except controls and `;`) plus the `,` this package has
303
+ always refused; space is inside that grammar, so `"/a b"` is accepted.
304
+ Before 1.3.1 non-ASCII was accepted.
291
305
  Previously only a real CR, LF, NUL, `;` or `,` was refused, so the literal text
292
306
  `a\r\nX-Evil: 1` was written into `Domain=` unchanged.
293
307
 
@@ -20,8 +20,9 @@ export declare function assertCookieDomain(domain: string): void;
20
20
  * Validates a cookie `Path` attribute.
21
21
  *
22
22
  * @param path - The Path value.
23
- * @throws {ValidationError} when it contains a control character, DEL, `;`
24
- * or `,`.
23
+ * @throws {ValidationError} when it contains a character outside printable
24
+ * ASCII (0x20–0x7E: a control character, DEL or any non-ASCII character),
25
+ * `;` or `,`. Space is allowed, as RFC 6265 allows it.
25
26
  */
26
27
  export declare function assertCookiePath(path: string): void;
27
28
  //# sourceMappingURL=cookie.attribute.d.ts.map
@@ -15,11 +15,16 @@ const DOMAIN_PATTERN = new RegExp(`^\\.?${LABEL}(?:\\.${LABEL})*$`);
15
15
  /** Longest hostname DNS allows, excluding the optional leading dot. */
16
16
  const MAX_DOMAIN_LENGTH = 253;
17
17
  /**
18
- * Characters never allowed in `Path`: controls (CR and LF included), DEL,
19
- * `;` which would end the attribute, and `,` which some parsers split
18
+ * Characters never allowed in `Path`: anything outside printable ASCII
19
+ * (controls, CR and LF included, DEL, and every non-ASCII character), `;`
20
+ * which would end the attribute, and `,` which some parsers split
20
21
  * `Set-Cookie` on.
22
+ *
23
+ * RFC 6265 §4.1.1 defines `path-value` as any CHAR (US-ASCII) except CTLs
24
+ * or `;`, so non-ASCII such as `"/ä"` is outside the grammar — percent-encode
25
+ * it (`"/%C3%A4"`). Space (0x20) is inside the grammar and stays allowed.
21
26
  */
22
- const PATH_UNSAFE = /[\x00-\x1F\x7F;,]/;
27
+ const PATH_UNSAFE = /[^\x20-\x7E]|[;,]/;
23
28
  /**
24
29
  * Validates a cookie `Domain` attribute as a hostname: labels of
25
30
  * `[A-Za-z0-9-]` separated by dots, with an optional leading dot.
@@ -44,13 +49,15 @@ export function assertCookieDomain(domain) {
44
49
  * Validates a cookie `Path` attribute.
45
50
  *
46
51
  * @param path - The Path value.
47
- * @throws {ValidationError} when it contains a control character, DEL, `;`
48
- * or `,`.
52
+ * @throws {ValidationError} when it contains a character outside printable
53
+ * ASCII (0x20–0x7E: a control character, DEL or any non-ASCII character),
54
+ * `;` or `,`. Space is allowed, as RFC 6265 allows it.
49
55
  */
50
56
  export function assertCookiePath(path) {
51
57
  if (typeof path !== "string" || PATH_UNSAFE.test(path)) {
52
- throw new ValidationError(`Cookie Path contains invalid characters (injection risk): control ` +
53
- `characters, ";" and "," are not allowed, got ${JSON.stringify(path)}`);
58
+ throw new ValidationError(`Cookie Path contains invalid characters (injection risk): only ` +
59
+ `printable ASCII is allowed (percent-encode anything else), and ";" ` +
60
+ `and "," are not, got ${JSON.stringify(path)}`);
54
61
  }
55
62
  }
56
63
  //# sourceMappingURL=cookie.attribute.js.map
@@ -100,6 +100,15 @@ export declare function verifyDoubleSubmit(cookieToken: string | undefined, requ
100
100
  *
101
101
  * @param method - The HTTP method.
102
102
  * @param config - Optional CSRF configuration.
103
+ * The rule fails closed. A method skips the check only when, after
104
+ * upper-casing and with no trimming, it is exactly one of the safe methods
105
+ * (GET, HEAD, OPTIONS, TRACE) or — when `methods` is configured — a standard
106
+ * HTTP method the configured list deliberately leaves out (`methods:
107
+ * ["DELETE"]` exempts POST). Anything else — an empty or padded string
108
+ * (`""`, `"POST "`), an unknown method (`"FOO"`), CONNECT under the default
109
+ * list — requires protection. Before 1.3.1 every method not in `methods`
110
+ * skipped the check, so `""` and `"FOO"` passed `verify()` with no token.
111
+ *
103
112
  * @returns True if CSRF protection is required. A missing or non-string
104
113
  * method is treated as requiring it, so a malformed request fails closed.
105
114
  */
@@ -155,6 +164,8 @@ export interface CsrfCookieOptions extends Omit<CsrfConfig, "secret"> {
155
164
  * @param token - The CSRF token to store.
156
165
  * @param config - Optional CSRF and cookie configuration.
157
166
  * @returns The Set-Cookie header value.
167
+ * @throws {ValidationError} when `path` breaks the cookie `Path` rules
168
+ * (see `serializeCookie`).
158
169
  */
159
170
  export declare function generateCsrfCookie(token: string, config?: Partial<CsrfCookieOptions>): string;
160
171
  /** Cookie-shaping options for {@link createCsrfProtection}. */
@@ -216,6 +227,7 @@ export interface CsrfProtection {
216
227
  * @throws {ConfigurationError} when the secret is missing or shorter than
217
228
  * {@link MIN_CSRF_SECRET_LENGTH}, or when `methods` is present but empty,
218
229
  * not an array, or contains a non-method entry.
230
+ * @throws {ValidationError} when `path` breaks the cookie `Path` rules.
219
231
  */
220
232
  export declare function createCsrfProtection(config: CsrfProtectionOptions): CsrfProtection;
221
233
  //# sourceMappingURL=csrf.core.d.ts.map
@@ -35,17 +35,17 @@
35
35
  * ```
36
36
  */
37
37
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
38
+ import { HTTP_METHODS } from "@zudojs/constants";
38
39
  import { ConfigurationError } from "@zudojs/errors";
40
+ import { assertCookiePath } from "../cookie/cookie.attribute.js";
39
41
  /** Default token expiration (1 hour). */
40
42
  const DEFAULT_EXPIRATION = 3600;
41
43
  /** Default cookie name for CSRF token. */
42
44
  const DEFAULT_COOKIE_NAME = "_csrf";
43
45
  /** Default header name for CSRF token. */
44
46
  const DEFAULT_HEADER_NAME = "x-csrf-token";
45
- /** Methods that require CSRF protection. */
47
+ /** Methods that never require CSRF protection (RFC 9110 safe methods). */
46
48
  const SAFE_METHODS = ["GET", "HEAD", "OPTIONS", "TRACE"];
47
- /** Default methods that require CSRF protection. */
48
- const DEFAULT_METHODS = ["POST", "PUT", "PATCH", "DELETE"];
49
49
  /**
50
50
  * Minimum accepted secret length, in characters.
51
51
  *
@@ -225,6 +225,15 @@ export function verifyDoubleSubmit(cookieToken, requestToken, secret, options) {
225
225
  *
226
226
  * @param method - The HTTP method.
227
227
  * @param config - Optional CSRF configuration.
228
+ * The rule fails closed. A method skips the check only when, after
229
+ * upper-casing and with no trimming, it is exactly one of the safe methods
230
+ * (GET, HEAD, OPTIONS, TRACE) or — when `methods` is configured — a standard
231
+ * HTTP method the configured list deliberately leaves out (`methods:
232
+ * ["DELETE"]` exempts POST). Anything else — an empty or padded string
233
+ * (`""`, `"POST "`), an unknown method (`"FOO"`), CONNECT under the default
234
+ * list — requires protection. Before 1.3.1 every method not in `methods`
235
+ * skipped the check, so `""` and `"FOO"` passed `verify()` with no token.
236
+ *
228
237
  * @returns True if CSRF protection is required. A missing or non-string
229
238
  * method is treated as requiring it, so a malformed request fails closed.
230
239
  */
@@ -233,15 +242,21 @@ export function requiresCsrfProtection(method, config) {
233
242
  if (typeof method !== "string") {
234
243
  return true;
235
244
  }
236
- if (SAFE_METHODS.includes(method.toUpperCase())) {
237
- return false;
238
- }
239
245
  // HTTP methods are case-sensitive on the wire but configured by hand;
240
246
  // comparing a lower-case `methods: ["post"]` against the upper-cased
241
247
  // request method used to protect nothing, silently.
242
248
  const upper = method.toUpperCase();
243
- const methods = config?.methods ?? DEFAULT_METHODS;
244
- return methods.some((m) => String(m).toUpperCase() === upper);
249
+ if (SAFE_METHODS.includes(upper)) {
250
+ return false;
251
+ }
252
+ const configured = config?.methods;
253
+ if (configured === undefined) {
254
+ return true;
255
+ }
256
+ if (configured.some((m) => String(m).toUpperCase() === upper)) {
257
+ return true;
258
+ }
259
+ return !HTTP_METHODS.has(upper);
245
260
  }
246
261
  /**
247
262
  * Extracts the CSRF token from request headers.
@@ -301,6 +316,8 @@ export function extractCsrfTokenFromCookies(cookieHeader, cookieName) {
301
316
  * @param token - The CSRF token to store.
302
317
  * @param config - Optional CSRF and cookie configuration.
303
318
  * @returns The Set-Cookie header value.
319
+ * @throws {ValidationError} when `path` breaks the cookie `Path` rules
320
+ * (see `serializeCookie`).
304
321
  */
305
322
  export function generateCsrfCookie(token, config) {
306
323
  const name = config?.cookieName ?? DEFAULT_COOKIE_NAME;
@@ -308,6 +325,7 @@ export function generateCsrfCookie(token, config) {
308
325
  const httpOnly = config?.httpOnly ?? true;
309
326
  const secure = config?.secure ?? true;
310
327
  const path = config?.path ?? "/";
328
+ assertCookiePath(path);
311
329
  const parts = [`${name}=${token}`, `Path=${path}`];
312
330
  if (httpOnly) {
313
331
  parts.push("HttpOnly");
@@ -335,10 +353,13 @@ export function generateCsrfCookie(token, config) {
335
353
  * @throws {ConfigurationError} when the secret is missing or shorter than
336
354
  * {@link MIN_CSRF_SECRET_LENGTH}, or when `methods` is present but empty,
337
355
  * not an array, or contains a non-method entry.
356
+ * @throws {ValidationError} when `path` breaks the cookie `Path` rules.
338
357
  */
339
358
  export function createCsrfProtection(config) {
340
359
  assertUsableSecret(config.secret);
341
360
  assertUsableMethods(config.methods);
361
+ if (config.path !== undefined)
362
+ assertCookiePath(config.path);
342
363
  const expiration = config.expiration ?? DEFAULT_EXPIRATION;
343
364
  const cookieName = config.cookieName ?? DEFAULT_COOKIE_NAME;
344
365
  const headerName = config.headerName ?? DEFAULT_HEADER_NAME;
@@ -114,7 +114,13 @@ export interface CsrfConfig {
114
114
  readonly cookieName?: string;
115
115
  /** Header name for the CSRF token. */
116
116
  readonly headerName?: string;
117
- /** Methods that require CSRF protection. */
117
+ /**
118
+ * Methods that require CSRF protection (default: POST, PUT, PATCH, DELETE).
119
+ *
120
+ * GET, HEAD, OPTIONS and TRACE never require it. A standard HTTP method
121
+ * left out of this list is exempt; an unknown, empty or malformed method
122
+ * always requires protection, whatever this list says.
123
+ */
118
124
  readonly methods?: readonly string[];
119
125
  }
120
126
  /** Configuration for rate limiting. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/security",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "Security primitives for input validation, header security, CORS, CSRF protection, rate limiting, and security headers.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -24,8 +24,8 @@
24
24
  "!dist/.tsbuildinfo"
25
25
  ],
26
26
  "dependencies": {
27
- "@zudojs/errors": "1.3.0",
28
- "@zudojs/constants": "1.1.2"
27
+ "@zudojs/errors": "1.3.1",
28
+ "@zudojs/constants": "1.1.3"
29
29
  },
30
30
  "devDependencies": {
31
31
  "typescript": "7.0.2",