@zudojs/security 1.3.0 → 1.3.1
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`
|
|
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
|
|
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`:
|
|
19
|
-
*
|
|
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 = /[
|
|
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
|
|
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):
|
|
53
|
-
`
|
|
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
|
package/dist/csrf/csrf.core.d.ts
CHANGED
|
@@ -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
|
package/dist/csrf/csrf.core.js
CHANGED
|
@@ -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
|
-
|
|
244
|
-
|
|
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
|
-
/**
|
|
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