@zudojs/security 1.2.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 +44 -8
- package/dist/cookie/cookie.attribute.d.ts +28 -0
- package/dist/cookie/cookie.attribute.js +63 -0
- package/dist/cookie/cookie.core.js +3 -10
- package/dist/csrf/csrf.core.d.ts +36 -3
- package/dist/csrf/csrf.core.js +68 -11
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/input/index.d.ts +1 -0
- package/dist/input/index.js +1 -0
- package/dist/input/input.unsafeKey.d.ts +19 -0
- package/dist/input/input.unsafeKey.js +61 -0
- package/dist/types/security.type.d.ts +7 -1
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -155,14 +155,23 @@ Two patterns, both on the same HMAC-SHA256 token. Bind the token to a session
|
|
|
155
155
|
wherever you have one — an unbound token is valid for every user.
|
|
156
156
|
|
|
157
157
|
The shortest correct version binds your configuration once. `secret` must be at
|
|
158
|
-
least 32 characters — the signature is HMAC-SHA256,
|
|
159
|
-
strength
|
|
158
|
+
least 32 characters (`MIN_CSRF_SECRET_LENGTH`) — the signature is HMAC-SHA256,
|
|
159
|
+
so a shorter one adds no strength. Read it from the environment and refuse to
|
|
160
|
+
start without it. Never write `process.env.CSRF_SECRET ?? "some-string"`: a
|
|
161
|
+
hard-coded fallback is a secret anyone can read in your source, and a short
|
|
162
|
+
one throws at the first call. Generate one with
|
|
163
|
+
`node -e 'console.log(require("node:crypto").randomBytes(32).toString("hex"))'`.
|
|
160
164
|
|
|
161
165
|
```typescript
|
|
162
|
-
import { createCsrfProtection } from "@zudojs/security";
|
|
166
|
+
import { createCsrfProtection, MIN_CSRF_SECRET_LENGTH } from "@zudojs/security";
|
|
167
|
+
|
|
168
|
+
const secret = process.env.CSRF_SECRET;
|
|
169
|
+
if (!secret || secret.length < MIN_CSRF_SECRET_LENGTH) {
|
|
170
|
+
throw new Error(`CSRF_SECRET must be set to at least ${MIN_CSRF_SECRET_LENGTH} characters`);
|
|
171
|
+
}
|
|
163
172
|
|
|
164
173
|
const csrf = createCsrfProtection({
|
|
165
|
-
secret
|
|
174
|
+
secret,
|
|
166
175
|
cookieName: "app_csrf",
|
|
167
176
|
headerName: "x-app-csrf",
|
|
168
177
|
expiration: 3600,
|
|
@@ -210,10 +219,25 @@ if (requiresCsrfProtection(request.method)) {
|
|
|
210
219
|
}
|
|
211
220
|
```
|
|
212
221
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
+
|
|
230
|
+
**What returns `false` and what throws.** `csrf.verify`, `verifyDoubleSubmit`
|
|
231
|
+
and `validateCsrfToken` return `false` for anything wrong with the request: a
|
|
232
|
+
missing, malformed, non-string, forged, expired or mismatched token, a token
|
|
233
|
+
bound to another session, or a request with no method (treated as needing
|
|
234
|
+
protection). They throw `ConfigurationError` only for a configuration mistake:
|
|
235
|
+
a secret shorter than 32 characters — the same minimum `generateCsrfToken`
|
|
236
|
+
enforces, so a verifier given `process.env.CSRF_SECRET ?? ""` cannot accept
|
|
237
|
+
tokens signed with an empty key — or an empty or invalid `methods` list. A
|
|
238
|
+
throw from a CSRF check therefore means "fix your configuration", never "this
|
|
239
|
+
request is bad". `createCsrfProtection` and `generateCsrfCookie` also throw
|
|
240
|
+
`ValidationError` for a cookie `path` that breaks the `Path` rules below.
|
|
217
241
|
|
|
218
242
|
The cookie is `Secure` and `HttpOnly` by default, which suits the synchroniser
|
|
219
243
|
token pattern where the server renders the token into the page. For the
|
|
@@ -269,6 +293,18 @@ could never apply to it.
|
|
|
269
293
|
an unsafe name, attribute, `Max-Age` or `Expires` throws. `SameSite=None` and
|
|
270
294
|
`Partitioned` require `Secure`.
|
|
271
295
|
|
|
296
|
+
`Domain` must be a hostname — labels of letters, digits and hyphens separated
|
|
297
|
+
by dots, with an optional leading dot (`example.com`, `.example.com`) — and
|
|
298
|
+
anything else (spaces, colons, backslashes, empty labels) throws
|
|
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.
|
|
305
|
+
Previously only a real CR, LF, NUL, `;` or `,` was refused, so the literal text
|
|
306
|
+
`a\r\nX-Evil: 1` was written into `Domain=` unchanged.
|
|
307
|
+
|
|
272
308
|
`parseCookieHeader` validates on the way in too. A name that is not an RFC 6265
|
|
273
309
|
token, or a value carrying a control character, is reported in `errors` and
|
|
274
310
|
kept out of `cookies` — it used to check only length, so a malformed cookie
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/security — Cookie attribute validation
|
|
3
|
+
*
|
|
4
|
+
* `Domain` and `Path` end up verbatim inside a `Set-Cookie` header, so each is
|
|
5
|
+
* held to a grammar rather than to a deny-list of a few characters. A
|
|
6
|
+
* deny-list of real CR/LF let through the literal text `a\r\nX-Evil: 1`
|
|
7
|
+
* (backslashes, spaces and a colon), which reaches any code that later
|
|
8
|
+
* unescapes or re-emits the header.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Validates a cookie `Domain` attribute as a hostname: labels of
|
|
12
|
+
* `[A-Za-z0-9-]` separated by dots, with an optional leading dot.
|
|
13
|
+
*
|
|
14
|
+
* @param domain - The Domain value.
|
|
15
|
+
* @throws {ValidationError} for anything else — spaces, colons, backslashes,
|
|
16
|
+
* control characters, empty labels, or an over-long name.
|
|
17
|
+
*/
|
|
18
|
+
export declare function assertCookieDomain(domain: string): void;
|
|
19
|
+
/**
|
|
20
|
+
* Validates a cookie `Path` attribute.
|
|
21
|
+
*
|
|
22
|
+
* @param path - The Path value.
|
|
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.
|
|
26
|
+
*/
|
|
27
|
+
export declare function assertCookiePath(path: string): void;
|
|
28
|
+
//# sourceMappingURL=cookie.attribute.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/security — Cookie attribute validation
|
|
3
|
+
*
|
|
4
|
+
* `Domain` and `Path` end up verbatim inside a `Set-Cookie` header, so each is
|
|
5
|
+
* held to a grammar rather than to a deny-list of a few characters. A
|
|
6
|
+
* deny-list of real CR/LF let through the literal text `a\r\nX-Evil: 1`
|
|
7
|
+
* (backslashes, spaces and a colon), which reaches any code that later
|
|
8
|
+
* unescapes or re-emits the header.
|
|
9
|
+
*/
|
|
10
|
+
import { ValidationError } from "@zudojs/errors";
|
|
11
|
+
/** One hostname label: 1–63 letters, digits or hyphens. */
|
|
12
|
+
const LABEL = "[A-Za-z0-9-]{1,63}";
|
|
13
|
+
/** A hostname with an optional leading dot: `example.com`, `.example.com`. */
|
|
14
|
+
const DOMAIN_PATTERN = new RegExp(`^\\.?${LABEL}(?:\\.${LABEL})*$`);
|
|
15
|
+
/** Longest hostname DNS allows, excluding the optional leading dot. */
|
|
16
|
+
const MAX_DOMAIN_LENGTH = 253;
|
|
17
|
+
/**
|
|
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
|
|
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.
|
|
26
|
+
*/
|
|
27
|
+
const PATH_UNSAFE = /[^\x20-\x7E]|[;,]/;
|
|
28
|
+
/**
|
|
29
|
+
* Validates a cookie `Domain` attribute as a hostname: labels of
|
|
30
|
+
* `[A-Za-z0-9-]` separated by dots, with an optional leading dot.
|
|
31
|
+
*
|
|
32
|
+
* @param domain - The Domain value.
|
|
33
|
+
* @throws {ValidationError} for anything else — spaces, colons, backslashes,
|
|
34
|
+
* control characters, empty labels, or an over-long name.
|
|
35
|
+
*/
|
|
36
|
+
export function assertCookieDomain(domain) {
|
|
37
|
+
const bare = typeof domain === "string" && domain.startsWith(".")
|
|
38
|
+
? domain.slice(1)
|
|
39
|
+
: domain;
|
|
40
|
+
if (typeof domain !== "string" ||
|
|
41
|
+
!DOMAIN_PATTERN.test(domain) ||
|
|
42
|
+
bare.length > MAX_DOMAIN_LENGTH) {
|
|
43
|
+
throw new ValidationError(`Cookie Domain contains invalid characters (injection risk): it must be ` +
|
|
44
|
+
`a hostname (letters, digits, hyphens and dots, optionally with a ` +
|
|
45
|
+
`leading dot), got ${JSON.stringify(domain)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Validates a cookie `Path` attribute.
|
|
50
|
+
*
|
|
51
|
+
* @param path - The Path value.
|
|
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.
|
|
55
|
+
*/
|
|
56
|
+
export function assertCookiePath(path) {
|
|
57
|
+
if (typeof path !== "string" || PATH_UNSAFE.test(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)}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=cookie.attribute.js.map
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { ValidationError } from "@zudojs/errors";
|
|
7
7
|
import { DEFAULT_SENSITIVE_COOKIE_NAMES, isSensitiveCookieName, } from "./cookie.sensitive.js";
|
|
8
|
+
import { assertCookieDomain, assertCookiePath } from "./cookie.attribute.js";
|
|
8
9
|
/** Maximum cookie header size (4KB). */
|
|
9
10
|
const MAX_COOKIE_HEADER_SIZE = 4096;
|
|
10
11
|
/** Maximum number of cookies. */
|
|
@@ -24,8 +25,6 @@ const COOKIE_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
|
24
25
|
* quote, comma, semicolon, and backslash.
|
|
25
26
|
*/
|
|
26
27
|
const COOKIE_VALUE_PATTERN = /^[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*$/;
|
|
27
|
-
/** Characters that must never reach an attribute value. */
|
|
28
|
-
const ATTRIBUTE_UNSAFE = /[;,\r\n\x00]/;
|
|
29
28
|
/**
|
|
30
29
|
* Control characters that are never acceptable inside a parsed cookie value,
|
|
31
30
|
* however lenient the rest of the read path is.
|
|
@@ -121,11 +120,11 @@ export function serializeCookie(cookie, config) {
|
|
|
121
120
|
}
|
|
122
121
|
const parts = [`${cookie.name}=${encodedValue}`];
|
|
123
122
|
if (cookie.path) {
|
|
124
|
-
|
|
123
|
+
assertCookiePath(cookie.path);
|
|
125
124
|
parts.push(`Path=${cookie.path}`);
|
|
126
125
|
}
|
|
127
126
|
if (cookie.domain) {
|
|
128
|
-
|
|
127
|
+
assertCookieDomain(cookie.domain);
|
|
129
128
|
parts.push(`Domain=${cookie.domain}`);
|
|
130
129
|
}
|
|
131
130
|
if (cookie.maxAge !== undefined) {
|
|
@@ -165,12 +164,6 @@ export function serializeCookie(cookie, config) {
|
|
|
165
164
|
}
|
|
166
165
|
return parts.join("; ");
|
|
167
166
|
}
|
|
168
|
-
/** Throws when an attribute value could terminate the attribute or the header. */
|
|
169
|
-
function assertSafeAttribute(attribute, value) {
|
|
170
|
-
if (ATTRIBUTE_UNSAFE.test(value)) {
|
|
171
|
-
throw new ValidationError(`Cookie ${attribute} contains invalid characters (injection risk): ${JSON.stringify(value)}`);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
167
|
/**
|
|
175
168
|
* Creates a Set-Cookie header value with secure defaults.
|
|
176
169
|
*
|
package/dist/csrf/csrf.core.d.ts
CHANGED
|
@@ -17,6 +17,22 @@
|
|
|
17
17
|
*
|
|
18
18
|
* Bind the token to a session wherever you have one: without `sessionId`, a
|
|
19
19
|
* token minted for one user validates for every other user.
|
|
20
|
+
*
|
|
21
|
+
* Error contract: the verifiers (`validateCsrfToken`, `verifyDoubleSubmit`,
|
|
22
|
+
* `CsrfProtection.verify`) return `false` for anything wrong with the
|
|
23
|
+
* *request* — a missing, malformed, non-string, forged, expired or
|
|
24
|
+
* mismatched token, or a request with no method. They throw
|
|
25
|
+
* `ConfigurationError` only for a *configuration* mistake: a secret shorter
|
|
26
|
+
* than {@link MIN_CSRF_SECRET_LENGTH} (32) characters, or an unusable
|
|
27
|
+
* `methods` list. Load the secret from the environment and refuse to start
|
|
28
|
+
* without it; never fall back to a hard-coded string:
|
|
29
|
+
*
|
|
30
|
+
* ```typescript
|
|
31
|
+
* const secret = process.env.CSRF_SECRET;
|
|
32
|
+
* if (!secret || secret.length < MIN_CSRF_SECRET_LENGTH) {
|
|
33
|
+
* throw new Error("Set CSRF_SECRET to at least 32 random characters.");
|
|
34
|
+
* }
|
|
35
|
+
* ```
|
|
20
36
|
*/
|
|
21
37
|
import type { CsrfConfig } from "../types/security.type.js";
|
|
22
38
|
/**
|
|
@@ -73,7 +89,10 @@ export declare function validateCsrfToken(token: string, secret: string, options
|
|
|
73
89
|
* @param requestToken - Token taken from the request header or form field.
|
|
74
90
|
* @param secret - The secret key for verification.
|
|
75
91
|
* @param options - Maximum lifetime and session binding.
|
|
76
|
-
* @returns True when the request carries a matching, valid token
|
|
92
|
+
* @returns True when the request carries a matching, valid token; `false`
|
|
93
|
+
* for a missing, non-string, mismatched, forged or expired one.
|
|
94
|
+
* @throws {ConfigurationError} only when the secret is empty or shorter than
|
|
95
|
+
* {@link MIN_CSRF_SECRET_LENGTH} — a misconfiguration, never a bad token.
|
|
77
96
|
*/
|
|
78
97
|
export declare function verifyDoubleSubmit(cookieToken: string | undefined, requestToken: string | undefined, secret: string, options?: number | CsrfTokenOptions): boolean;
|
|
79
98
|
/**
|
|
@@ -81,7 +100,17 @@ export declare function verifyDoubleSubmit(cookieToken: string | undefined, requ
|
|
|
81
100
|
*
|
|
82
101
|
* @param method - The HTTP method.
|
|
83
102
|
* @param config - Optional CSRF configuration.
|
|
84
|
-
*
|
|
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
|
+
*
|
|
112
|
+
* @returns True if CSRF protection is required. A missing or non-string
|
|
113
|
+
* method is treated as requiring it, so a malformed request fails closed.
|
|
85
114
|
*/
|
|
86
115
|
export declare function requiresCsrfProtection(method: string, config?: CsrfConfig): boolean;
|
|
87
116
|
/**
|
|
@@ -135,6 +164,8 @@ export interface CsrfCookieOptions extends Omit<CsrfConfig, "secret"> {
|
|
|
135
164
|
* @param token - The CSRF token to store.
|
|
136
165
|
* @param config - Optional CSRF and cookie configuration.
|
|
137
166
|
* @returns The Set-Cookie header value.
|
|
167
|
+
* @throws {ValidationError} when `path` breaks the cookie `Path` rules
|
|
168
|
+
* (see `serializeCookie`).
|
|
138
169
|
*/
|
|
139
170
|
export declare function generateCsrfCookie(token: string, config?: Partial<CsrfCookieOptions>): string;
|
|
140
171
|
/** Cookie-shaping options for {@link createCsrfProtection}. */
|
|
@@ -170,7 +201,8 @@ export interface CsrfProtection {
|
|
|
170
201
|
* Verify a request under the double-submit pattern.
|
|
171
202
|
*
|
|
172
203
|
* Returns `true` for a method that does not require protection, so it can be
|
|
173
|
-
* called unconditionally
|
|
204
|
+
* called unconditionally, and `false` — never a throw — for a request whose
|
|
205
|
+
* token is missing, malformed, forged, expired or for another session.
|
|
174
206
|
*/
|
|
175
207
|
verify(request: CsrfVerifiableRequest, options?: {
|
|
176
208
|
readonly sessionId?: string;
|
|
@@ -195,6 +227,7 @@ export interface CsrfProtection {
|
|
|
195
227
|
* @throws {ConfigurationError} when the secret is missing or shorter than
|
|
196
228
|
* {@link MIN_CSRF_SECRET_LENGTH}, or when `methods` is present but empty,
|
|
197
229
|
* not an array, or contains a non-method entry.
|
|
230
|
+
* @throws {ValidationError} when `path` breaks the cookie `Path` rules.
|
|
198
231
|
*/
|
|
199
232
|
export declare function createCsrfProtection(config: CsrfProtectionOptions): CsrfProtection;
|
|
200
233
|
//# sourceMappingURL=csrf.core.d.ts.map
|
package/dist/csrf/csrf.core.js
CHANGED
|
@@ -17,19 +17,35 @@
|
|
|
17
17
|
*
|
|
18
18
|
* Bind the token to a session wherever you have one: without `sessionId`, a
|
|
19
19
|
* token minted for one user validates for every other user.
|
|
20
|
+
*
|
|
21
|
+
* Error contract: the verifiers (`validateCsrfToken`, `verifyDoubleSubmit`,
|
|
22
|
+
* `CsrfProtection.verify`) return `false` for anything wrong with the
|
|
23
|
+
* *request* — a missing, malformed, non-string, forged, expired or
|
|
24
|
+
* mismatched token, or a request with no method. They throw
|
|
25
|
+
* `ConfigurationError` only for a *configuration* mistake: a secret shorter
|
|
26
|
+
* than {@link MIN_CSRF_SECRET_LENGTH} (32) characters, or an unusable
|
|
27
|
+
* `methods` list. Load the secret from the environment and refuse to start
|
|
28
|
+
* without it; never fall back to a hard-coded string:
|
|
29
|
+
*
|
|
30
|
+
* ```typescript
|
|
31
|
+
* const secret = process.env.CSRF_SECRET;
|
|
32
|
+
* if (!secret || secret.length < MIN_CSRF_SECRET_LENGTH) {
|
|
33
|
+
* throw new Error("Set CSRF_SECRET to at least 32 random characters.");
|
|
34
|
+
* }
|
|
35
|
+
* ```
|
|
20
36
|
*/
|
|
21
37
|
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
38
|
+
import { HTTP_METHODS } from "@zudojs/constants";
|
|
22
39
|
import { ConfigurationError } from "@zudojs/errors";
|
|
40
|
+
import { assertCookiePath } from "../cookie/cookie.attribute.js";
|
|
23
41
|
/** Default token expiration (1 hour). */
|
|
24
42
|
const DEFAULT_EXPIRATION = 3600;
|
|
25
43
|
/** Default cookie name for CSRF token. */
|
|
26
44
|
const DEFAULT_COOKIE_NAME = "_csrf";
|
|
27
45
|
/** Default header name for CSRF token. */
|
|
28
46
|
const DEFAULT_HEADER_NAME = "x-csrf-token";
|
|
29
|
-
/** Methods that require CSRF protection. */
|
|
47
|
+
/** Methods that never require CSRF protection (RFC 9110 safe methods). */
|
|
30
48
|
const SAFE_METHODS = ["GET", "HEAD", "OPTIONS", "TRACE"];
|
|
31
|
-
/** Default methods that require CSRF protection. */
|
|
32
|
-
const DEFAULT_METHODS = ["POST", "PUT", "PATCH", "DELETE"];
|
|
33
49
|
/**
|
|
34
50
|
* Minimum accepted secret length, in characters.
|
|
35
51
|
*
|
|
@@ -183,11 +199,20 @@ export function validateCsrfToken(token, secret, options) {
|
|
|
183
199
|
* @param requestToken - Token taken from the request header or form field.
|
|
184
200
|
* @param secret - The secret key for verification.
|
|
185
201
|
* @param options - Maximum lifetime and session binding.
|
|
186
|
-
* @returns True when the request carries a matching, valid token
|
|
202
|
+
* @returns True when the request carries a matching, valid token; `false`
|
|
203
|
+
* for a missing, non-string, mismatched, forged or expired one.
|
|
204
|
+
* @throws {ConfigurationError} only when the secret is empty or shorter than
|
|
205
|
+
* {@link MIN_CSRF_SECRET_LENGTH} — a misconfiguration, never a bad token.
|
|
187
206
|
*/
|
|
188
207
|
export function verifyDoubleSubmit(cookieToken, requestToken, secret, options) {
|
|
189
208
|
assertUsableSecret(secret);
|
|
190
|
-
|
|
209
|
+
// Both values come from the request. A non-string (a parsed-body number, an
|
|
210
|
+
// array from a header bag) used to reach `Buffer.from` and throw a
|
|
211
|
+
// TypeError out of a function whose contract is a boolean.
|
|
212
|
+
if (typeof cookieToken !== "string" ||
|
|
213
|
+
typeof requestToken !== "string" ||
|
|
214
|
+
cookieToken.length === 0 ||
|
|
215
|
+
requestToken.length === 0) {
|
|
191
216
|
return false;
|
|
192
217
|
}
|
|
193
218
|
if (!safeEqual(cookieToken, requestToken)) {
|
|
@@ -200,19 +225,38 @@ export function verifyDoubleSubmit(cookieToken, requestToken, secret, options) {
|
|
|
200
225
|
*
|
|
201
226
|
* @param method - The HTTP method.
|
|
202
227
|
* @param config - Optional CSRF configuration.
|
|
203
|
-
*
|
|
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
|
+
*
|
|
237
|
+
* @returns True if CSRF protection is required. A missing or non-string
|
|
238
|
+
* method is treated as requiring it, so a malformed request fails closed.
|
|
204
239
|
*/
|
|
205
240
|
export function requiresCsrfProtection(method, config) {
|
|
206
241
|
assertUsableMethods(config?.methods);
|
|
207
|
-
if (
|
|
208
|
-
return
|
|
242
|
+
if (typeof method !== "string") {
|
|
243
|
+
return true;
|
|
209
244
|
}
|
|
210
245
|
// HTTP methods are case-sensitive on the wire but configured by hand;
|
|
211
246
|
// comparing a lower-case `methods: ["post"]` against the upper-cased
|
|
212
247
|
// request method used to protect nothing, silently.
|
|
213
248
|
const upper = method.toUpperCase();
|
|
214
|
-
|
|
215
|
-
|
|
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);
|
|
216
260
|
}
|
|
217
261
|
/**
|
|
218
262
|
* Extracts the CSRF token from request headers.
|
|
@@ -226,6 +270,8 @@ export function requiresCsrfProtection(method, config) {
|
|
|
226
270
|
*/
|
|
227
271
|
export function extractCsrfTokenFromHeaders(headers, headerName) {
|
|
228
272
|
const name = (headerName ?? DEFAULT_HEADER_NAME).toLowerCase();
|
|
273
|
+
if (headers === null || typeof headers !== "object")
|
|
274
|
+
return undefined;
|
|
229
275
|
let value;
|
|
230
276
|
for (const key of Object.keys(headers)) {
|
|
231
277
|
if (key.toLowerCase() === name) {
|
|
@@ -236,7 +282,7 @@ export function extractCsrfTokenFromHeaders(headers, headerName) {
|
|
|
236
282
|
if (typeof value === "string") {
|
|
237
283
|
return value;
|
|
238
284
|
}
|
|
239
|
-
if (Array.isArray(value) && value
|
|
285
|
+
if (Array.isArray(value) && typeof value[0] === "string") {
|
|
240
286
|
return value[0];
|
|
241
287
|
}
|
|
242
288
|
return undefined;
|
|
@@ -250,6 +296,8 @@ export function extractCsrfTokenFromHeaders(headers, headerName) {
|
|
|
250
296
|
*/
|
|
251
297
|
export function extractCsrfTokenFromCookies(cookieHeader, cookieName) {
|
|
252
298
|
const name = cookieName ?? DEFAULT_COOKIE_NAME;
|
|
299
|
+
if (typeof cookieHeader !== "string")
|
|
300
|
+
return undefined;
|
|
253
301
|
const cookies = cookieHeader.split(";").map((pair) => {
|
|
254
302
|
const eqIndex = pair.indexOf("=");
|
|
255
303
|
if (eqIndex === -1)
|
|
@@ -268,6 +316,8 @@ export function extractCsrfTokenFromCookies(cookieHeader, cookieName) {
|
|
|
268
316
|
* @param token - The CSRF token to store.
|
|
269
317
|
* @param config - Optional CSRF and cookie configuration.
|
|
270
318
|
* @returns The Set-Cookie header value.
|
|
319
|
+
* @throws {ValidationError} when `path` breaks the cookie `Path` rules
|
|
320
|
+
* (see `serializeCookie`).
|
|
271
321
|
*/
|
|
272
322
|
export function generateCsrfCookie(token, config) {
|
|
273
323
|
const name = config?.cookieName ?? DEFAULT_COOKIE_NAME;
|
|
@@ -275,6 +325,7 @@ export function generateCsrfCookie(token, config) {
|
|
|
275
325
|
const httpOnly = config?.httpOnly ?? true;
|
|
276
326
|
const secure = config?.secure ?? true;
|
|
277
327
|
const path = config?.path ?? "/";
|
|
328
|
+
assertCookiePath(path);
|
|
278
329
|
const parts = [`${name}=${token}`, `Path=${path}`];
|
|
279
330
|
if (httpOnly) {
|
|
280
331
|
parts.push("HttpOnly");
|
|
@@ -302,10 +353,13 @@ export function generateCsrfCookie(token, config) {
|
|
|
302
353
|
* @throws {ConfigurationError} when the secret is missing or shorter than
|
|
303
354
|
* {@link MIN_CSRF_SECRET_LENGTH}, or when `methods` is present but empty,
|
|
304
355
|
* not an array, or contains a non-method entry.
|
|
356
|
+
* @throws {ValidationError} when `path` breaks the cookie `Path` rules.
|
|
305
357
|
*/
|
|
306
358
|
export function createCsrfProtection(config) {
|
|
307
359
|
assertUsableSecret(config.secret);
|
|
308
360
|
assertUsableMethods(config.methods);
|
|
361
|
+
if (config.path !== undefined)
|
|
362
|
+
assertCookiePath(config.path);
|
|
309
363
|
const expiration = config.expiration ?? DEFAULT_EXPIRATION;
|
|
310
364
|
const cookieName = config.cookieName ?? DEFAULT_COOKIE_NAME;
|
|
311
365
|
const headerName = config.headerName ?? DEFAULT_HEADER_NAME;
|
|
@@ -331,6 +385,9 @@ export function createCsrfProtection(config) {
|
|
|
331
385
|
};
|
|
332
386
|
},
|
|
333
387
|
verify(request, options) {
|
|
388
|
+
if (request === null || typeof request !== "object") {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
334
391
|
if (!requiresCsrfProtection(request.method, config)) {
|
|
335
392
|
return true;
|
|
336
393
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -45,5 +45,5 @@ export type { RateLimiterOptions, ClientIpOptions, IpKeyOptions, } from "./rateL
|
|
|
45
45
|
export { rateLimit } from "./rateLimit/rateLimit.namespace.js";
|
|
46
46
|
export { SECURITY_HEADER_NAMES } from "./headers/index.js";
|
|
47
47
|
export { generateSecurityHeaders, getMissingSecurityHeaders, generateCspNonce, validateCspDirective, } from "./headers/index.js";
|
|
48
|
-
export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, withoutStickyFlags, detectThreats, escapeHtml, stripHtml, } from "./input/index.js";
|
|
48
|
+
export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, withoutStickyFlags, detectThreats, escapeHtml, stripHtml, findUnsafeKey, } from "./input/index.js";
|
|
49
49
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
CHANGED
|
@@ -48,5 +48,5 @@ export { rateLimit } from "./rateLimit/rateLimit.namespace.js";
|
|
|
48
48
|
export { SECURITY_HEADER_NAMES } from "./headers/index.js";
|
|
49
49
|
export { generateSecurityHeaders, getMissingSecurityHeaders, generateCspNonce, validateCspDirective, } from "./headers/index.js";
|
|
50
50
|
/* ─── Input Sanitization ─────────────────────────────────────────────────── */
|
|
51
|
-
export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, withoutStickyFlags, detectThreats, escapeHtml, stripHtml, } from "./input/index.js";
|
|
51
|
+
export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, withoutStickyFlags, detectThreats, escapeHtml, stripHtml, findUnsafeKey, } from "./input/index.js";
|
|
52
52
|
//# sourceMappingURL=index.js.map
|
package/dist/input/index.d.ts
CHANGED
|
@@ -2,4 +2,5 @@
|
|
|
2
2
|
* @zudojs/security — Input Sanitization Barrel
|
|
3
3
|
*/
|
|
4
4
|
export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, withoutStickyFlags, detectThreats, escapeHtml, stripHtml, } from "./input.core.js";
|
|
5
|
+
export { findUnsafeKey } from "./input.unsafeKey.js";
|
|
5
6
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/input/index.js
CHANGED
|
@@ -2,4 +2,5 @@
|
|
|
2
2
|
* @zudojs/security — Input Sanitization Barrel
|
|
3
3
|
*/
|
|
4
4
|
export { containsSqlInjection, containsXss, containsPrototypePollution, sanitizeString, sanitizeObject, isSafeString, withoutStickyFlags, detectThreats, escapeHtml, stripHtml, } from "./input.core.js";
|
|
5
|
+
export { findUnsafeKey } from "./input.unsafeKey.js";
|
|
5
6
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep detection of prototype-polluting keys in decoded, untrusted data
|
|
3
|
+
* (objects and arrays). `containsPrototypePollution` covers strings.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Returns the first `__proto__`, `constructor` or `prototype` key found
|
|
7
|
+
* anywhere in `value`, or `undefined` when there is none.
|
|
8
|
+
*
|
|
9
|
+
* `JSON.parse` keeps such a key as an ordinary own property, so it reaches
|
|
10
|
+
* handlers intact; the moment one is copied with `Object.assign`, a
|
|
11
|
+
* `for…in` merge or a bracket assignment, it replaces the target's
|
|
12
|
+
* prototype. Transports refuse a frame that carries one rather than
|
|
13
|
+
* silently dropping it.
|
|
14
|
+
*
|
|
15
|
+
* Walks plain objects and arrays only, iteratively (no recursion limit)
|
|
16
|
+
* and cycle-safe, so it is safe on any decoded or in-process value.
|
|
17
|
+
*/
|
|
18
|
+
export declare function findUnsafeKey(value: unknown): string | undefined;
|
|
19
|
+
//# sourceMappingURL=input.unsafeKey.d.ts.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep detection of prototype-polluting keys in decoded, untrusted data
|
|
3
|
+
* (objects and arrays). `containsPrototypePollution` covers strings.
|
|
4
|
+
*/
|
|
5
|
+
import { SCHEMA_FORBIDDEN_KEYS } from "@zudojs/constants";
|
|
6
|
+
function isTraversable(value) {
|
|
7
|
+
if (typeof value !== "object" || value === null) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
if (Array.isArray(value)) {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
const prototype = Object.getPrototypeOf(value);
|
|
14
|
+
return prototype === Object.prototype || prototype === null;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Returns the first `__proto__`, `constructor` or `prototype` key found
|
|
18
|
+
* anywhere in `value`, or `undefined` when there is none.
|
|
19
|
+
*
|
|
20
|
+
* `JSON.parse` keeps such a key as an ordinary own property, so it reaches
|
|
21
|
+
* handlers intact; the moment one is copied with `Object.assign`, a
|
|
22
|
+
* `for…in` merge or a bracket assignment, it replaces the target's
|
|
23
|
+
* prototype. Transports refuse a frame that carries one rather than
|
|
24
|
+
* silently dropping it.
|
|
25
|
+
*
|
|
26
|
+
* Walks plain objects and arrays only, iteratively (no recursion limit)
|
|
27
|
+
* and cycle-safe, so it is safe on any decoded or in-process value.
|
|
28
|
+
*/
|
|
29
|
+
export function findUnsafeKey(value) {
|
|
30
|
+
if (!isTraversable(value)) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
const seen = new WeakSet();
|
|
34
|
+
const pending = [value];
|
|
35
|
+
while (pending.length > 0) {
|
|
36
|
+
const node = pending.pop();
|
|
37
|
+
if (seen.has(node)) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
seen.add(node);
|
|
41
|
+
if (Array.isArray(node)) {
|
|
42
|
+
for (const child of node) {
|
|
43
|
+
if (isTraversable(child)) {
|
|
44
|
+
pending.push(child);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
for (const key of Object.keys(node)) {
|
|
50
|
+
if (SCHEMA_FORBIDDEN_KEYS.has(key)) {
|
|
51
|
+
return key;
|
|
52
|
+
}
|
|
53
|
+
const child = node[key];
|
|
54
|
+
if (isTraversable(child)) {
|
|
55
|
+
pending.push(child);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=input.unsafeKey.js.map
|
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/security",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
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,13 +24,13 @@
|
|
|
24
24
|
"!dist/.tsbuildinfo"
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@zudojs/errors": "1.
|
|
28
|
-
"@zudojs/constants": "1.1.
|
|
27
|
+
"@zudojs/errors": "1.3.0",
|
|
28
|
+
"@zudojs/constants": "1.1.2"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"typescript": "7.0.2",
|
|
32
|
-
"vitest": "^
|
|
33
|
-
"@types/node": "^26.
|
|
32
|
+
"vitest": "^5.0.1",
|
|
33
|
+
"@types/node": "^26.6.2"
|
|
34
34
|
},
|
|
35
35
|
"engines": {
|
|
36
36
|
"node": ">=24.0.0"
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"csrf",
|
|
46
46
|
"rate-limiting"
|
|
47
47
|
],
|
|
48
|
-
"homepage": "https://
|
|
48
|
+
"homepage": "https://zudojs.oyinlola.site/docs/packages-security",
|
|
49
49
|
"bugs": {
|
|
50
50
|
"url": "https://github.com/oyinlola-tech/zudo/issues"
|
|
51
51
|
},
|