@zudojs/security 1.1.0 → 1.3.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.
- package/README.md +30 -8
- package/dist/cookie/cookie.attribute.d.ts +27 -0
- package/dist/cookie/cookie.attribute.js +56 -0
- package/dist/cookie/cookie.core.js +3 -10
- package/dist/csrf/csrf.core.d.ts +26 -4
- package/dist/csrf/csrf.core.js +71 -5
- 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.core.d.ts +2 -0
- package/dist/input/input.core.js +24 -0
- package/dist/input/input.unsafeKey.d.ts +19 -0
- package/dist/input/input.unsafeKey.js +61 -0
- package/dist/rateLimit/rateLimit.clientIp.js +12 -4
- package/dist/url/url.core.d.ts +6 -0
- package/dist/url/url.core.js +17 -3
- 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,16 @@ if (requiresCsrfProtection(request.method)) {
|
|
|
210
219
|
}
|
|
211
220
|
```
|
|
212
221
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
222
|
+
**What returns `false` and what throws.** `csrf.verify`, `verifyDoubleSubmit`
|
|
223
|
+
and `validateCsrfToken` return `false` for anything wrong with the request: a
|
|
224
|
+
missing, malformed, non-string, forged, expired or mismatched token, a token
|
|
225
|
+
bound to another session, or a request with no method (treated as needing
|
|
226
|
+
protection). They throw `ConfigurationError` only for a configuration mistake:
|
|
227
|
+
a secret shorter than 32 characters — the same minimum `generateCsrfToken`
|
|
228
|
+
enforces, so a verifier given `process.env.CSRF_SECRET ?? ""` cannot accept
|
|
229
|
+
tokens signed with an empty key — or an empty or invalid `methods` list. A
|
|
230
|
+
throw from a CSRF check therefore means "fix your configuration", never "this
|
|
231
|
+
request is bad".
|
|
217
232
|
|
|
218
233
|
The cookie is `Secure` and `HttpOnly` by default, which suits the synchroniser
|
|
219
234
|
token pattern where the server renders the token into the page. For the
|
|
@@ -269,6 +284,13 @@ could never apply to it.
|
|
|
269
284
|
an unsafe name, attribute, `Max-Age` or `Expires` throws. `SameSite=None` and
|
|
270
285
|
`Partitioned` require `Secure`.
|
|
271
286
|
|
|
287
|
+
`Domain` must be a hostname — labels of letters, digits and hyphens separated
|
|
288
|
+
by dots, with an optional leading dot (`example.com`, `.example.com`) — and
|
|
289
|
+
anything else (spaces, colons, backslashes, empty labels) throws
|
|
290
|
+
`ValidationError`. `Path` may not contain a control character, `;` or `,`.
|
|
291
|
+
Previously only a real CR, LF, NUL, `;` or `,` was refused, so the literal text
|
|
292
|
+
`a\r\nX-Evil: 1` was written into `Domain=` unchanged.
|
|
293
|
+
|
|
272
294
|
`parseCookieHeader` validates on the way in too. A name that is not an RFC 6265
|
|
273
295
|
token, or a value carrying a control character, is reported in `errors` and
|
|
274
296
|
kept out of `cookies` — it used to check only length, so a malformed cookie
|
|
@@ -0,0 +1,27 @@
|
|
|
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 control character, DEL, `;`
|
|
24
|
+
* or `,`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function assertCookiePath(path: string): void;
|
|
27
|
+
//# sourceMappingURL=cookie.attribute.d.ts.map
|
|
@@ -0,0 +1,56 @@
|
|
|
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`: controls (CR and LF included), DEL,
|
|
19
|
+
* `;` which would end the attribute, and `,` which some parsers split
|
|
20
|
+
* `Set-Cookie` on.
|
|
21
|
+
*/
|
|
22
|
+
const PATH_UNSAFE = /[\x00-\x1F\x7F;,]/;
|
|
23
|
+
/**
|
|
24
|
+
* Validates a cookie `Domain` attribute as a hostname: labels of
|
|
25
|
+
* `[A-Za-z0-9-]` separated by dots, with an optional leading dot.
|
|
26
|
+
*
|
|
27
|
+
* @param domain - The Domain value.
|
|
28
|
+
* @throws {ValidationError} for anything else — spaces, colons, backslashes,
|
|
29
|
+
* control characters, empty labels, or an over-long name.
|
|
30
|
+
*/
|
|
31
|
+
export function assertCookieDomain(domain) {
|
|
32
|
+
const bare = typeof domain === "string" && domain.startsWith(".")
|
|
33
|
+
? domain.slice(1)
|
|
34
|
+
: domain;
|
|
35
|
+
if (typeof domain !== "string" ||
|
|
36
|
+
!DOMAIN_PATTERN.test(domain) ||
|
|
37
|
+
bare.length > MAX_DOMAIN_LENGTH) {
|
|
38
|
+
throw new ValidationError(`Cookie Domain contains invalid characters (injection risk): it must be ` +
|
|
39
|
+
`a hostname (letters, digits, hyphens and dots, optionally with a ` +
|
|
40
|
+
`leading dot), got ${JSON.stringify(domain)}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Validates a cookie `Path` attribute.
|
|
45
|
+
*
|
|
46
|
+
* @param path - The Path value.
|
|
47
|
+
* @throws {ValidationError} when it contains a control character, DEL, `;`
|
|
48
|
+
* or `,`.
|
|
49
|
+
*/
|
|
50
|
+
export function assertCookiePath(path) {
|
|
51
|
+
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)}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
//# 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,8 @@ 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
|
-
* @returns True if CSRF protection is required.
|
|
103
|
+
* @returns True if CSRF protection is required. A missing or non-string
|
|
104
|
+
* method is treated as requiring it, so a malformed request fails closed.
|
|
85
105
|
*/
|
|
86
106
|
export declare function requiresCsrfProtection(method: string, config?: CsrfConfig): boolean;
|
|
87
107
|
/**
|
|
@@ -170,7 +190,8 @@ export interface CsrfProtection {
|
|
|
170
190
|
* Verify a request under the double-submit pattern.
|
|
171
191
|
*
|
|
172
192
|
* Returns `true` for a method that does not require protection, so it can be
|
|
173
|
-
* called unconditionally
|
|
193
|
+
* called unconditionally, and `false` — never a throw — for a request whose
|
|
194
|
+
* token is missing, malformed, forged, expired or for another session.
|
|
174
195
|
*/
|
|
175
196
|
verify(request: CsrfVerifiableRequest, options?: {
|
|
176
197
|
readonly sessionId?: string;
|
|
@@ -193,7 +214,8 @@ export interface CsrfProtection {
|
|
|
193
214
|
* @param config - Secret, lifetime, cookie/header names, protected methods.
|
|
194
215
|
* @returns Protection bound to that configuration.
|
|
195
216
|
* @throws {ConfigurationError} when the secret is missing or shorter than
|
|
196
|
-
* {@link MIN_CSRF_SECRET_LENGTH}
|
|
217
|
+
* {@link MIN_CSRF_SECRET_LENGTH}, or when `methods` is present but empty,
|
|
218
|
+
* not an array, or contains a non-method entry.
|
|
197
219
|
*/
|
|
198
220
|
export declare function createCsrfProtection(config: CsrfProtectionOptions): CsrfProtection;
|
|
199
221
|
//# sourceMappingURL=csrf.core.d.ts.map
|
package/dist/csrf/csrf.core.js
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 { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
22
38
|
import { ConfigurationError } from "@zudojs/errors";
|
|
@@ -38,6 +54,33 @@ const DEFAULT_METHODS = ["POST", "PUT", "PATCH", "DELETE"];
|
|
|
38
54
|
* the only check, which accepted a one-character secret in silence.
|
|
39
55
|
*/
|
|
40
56
|
export const MIN_CSRF_SECRET_LENGTH = 32;
|
|
57
|
+
/**
|
|
58
|
+
* Rejects a `methods` list that cannot protect anything.
|
|
59
|
+
*
|
|
60
|
+
* `methods.some(…)` over an empty list is always `false`, and `verify()`
|
|
61
|
+
* answers `true` for a method that needs no protection — so `methods: []`
|
|
62
|
+
* turned CSRF off for every request without an error or a warning. A list
|
|
63
|
+
* built from configuration (`process.env.CSRF_METHODS?.split(",") ?? []`) is
|
|
64
|
+
* empty exactly when the configuration is missing. An empty list is a
|
|
65
|
+
* configuration error, not a blanket grant; omit `methods` for the defaults.
|
|
66
|
+
*/
|
|
67
|
+
function assertUsableMethods(methods) {
|
|
68
|
+
if (methods === undefined)
|
|
69
|
+
return;
|
|
70
|
+
if (!Array.isArray(methods)) {
|
|
71
|
+
throw new ConfigurationError("CSRF methods must be an array of HTTP method names, " +
|
|
72
|
+
"or omitted for the defaults (POST, PUT, PATCH, DELETE).");
|
|
73
|
+
}
|
|
74
|
+
if (methods.length === 0) {
|
|
75
|
+
throw new ConfigurationError("CSRF methods cannot be empty: an empty list protects no request at " +
|
|
76
|
+
"all. Omit `methods` for the defaults (POST, PUT, PATCH, DELETE).");
|
|
77
|
+
}
|
|
78
|
+
for (const method of methods) {
|
|
79
|
+
if (typeof method !== "string" || method.trim().length === 0) {
|
|
80
|
+
throw new ConfigurationError("CSRF methods must be non-empty HTTP method names.");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
41
84
|
/** Rejects a secret too short to be worth signing with. */
|
|
42
85
|
function assertUsableSecret(secret) {
|
|
43
86
|
if (typeof secret !== "string" || secret.length === 0) {
|
|
@@ -156,11 +199,20 @@ export function validateCsrfToken(token, secret, options) {
|
|
|
156
199
|
* @param requestToken - Token taken from the request header or form field.
|
|
157
200
|
* @param secret - The secret key for verification.
|
|
158
201
|
* @param options - Maximum lifetime and session binding.
|
|
159
|
-
* @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.
|
|
160
206
|
*/
|
|
161
207
|
export function verifyDoubleSubmit(cookieToken, requestToken, secret, options) {
|
|
162
208
|
assertUsableSecret(secret);
|
|
163
|
-
|
|
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) {
|
|
164
216
|
return false;
|
|
165
217
|
}
|
|
166
218
|
if (!safeEqual(cookieToken, requestToken)) {
|
|
@@ -173,9 +225,14 @@ export function verifyDoubleSubmit(cookieToken, requestToken, secret, options) {
|
|
|
173
225
|
*
|
|
174
226
|
* @param method - The HTTP method.
|
|
175
227
|
* @param config - Optional CSRF configuration.
|
|
176
|
-
* @returns True if CSRF protection is required.
|
|
228
|
+
* @returns True if CSRF protection is required. A missing or non-string
|
|
229
|
+
* method is treated as requiring it, so a malformed request fails closed.
|
|
177
230
|
*/
|
|
178
231
|
export function requiresCsrfProtection(method, config) {
|
|
232
|
+
assertUsableMethods(config?.methods);
|
|
233
|
+
if (typeof method !== "string") {
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
179
236
|
if (SAFE_METHODS.includes(method.toUpperCase())) {
|
|
180
237
|
return false;
|
|
181
238
|
}
|
|
@@ -198,6 +255,8 @@ export function requiresCsrfProtection(method, config) {
|
|
|
198
255
|
*/
|
|
199
256
|
export function extractCsrfTokenFromHeaders(headers, headerName) {
|
|
200
257
|
const name = (headerName ?? DEFAULT_HEADER_NAME).toLowerCase();
|
|
258
|
+
if (headers === null || typeof headers !== "object")
|
|
259
|
+
return undefined;
|
|
201
260
|
let value;
|
|
202
261
|
for (const key of Object.keys(headers)) {
|
|
203
262
|
if (key.toLowerCase() === name) {
|
|
@@ -208,7 +267,7 @@ export function extractCsrfTokenFromHeaders(headers, headerName) {
|
|
|
208
267
|
if (typeof value === "string") {
|
|
209
268
|
return value;
|
|
210
269
|
}
|
|
211
|
-
if (Array.isArray(value) && value
|
|
270
|
+
if (Array.isArray(value) && typeof value[0] === "string") {
|
|
212
271
|
return value[0];
|
|
213
272
|
}
|
|
214
273
|
return undefined;
|
|
@@ -222,6 +281,8 @@ export function extractCsrfTokenFromHeaders(headers, headerName) {
|
|
|
222
281
|
*/
|
|
223
282
|
export function extractCsrfTokenFromCookies(cookieHeader, cookieName) {
|
|
224
283
|
const name = cookieName ?? DEFAULT_COOKIE_NAME;
|
|
284
|
+
if (typeof cookieHeader !== "string")
|
|
285
|
+
return undefined;
|
|
225
286
|
const cookies = cookieHeader.split(";").map((pair) => {
|
|
226
287
|
const eqIndex = pair.indexOf("=");
|
|
227
288
|
if (eqIndex === -1)
|
|
@@ -272,10 +333,12 @@ export function generateCsrfCookie(token, config) {
|
|
|
272
333
|
* @param config - Secret, lifetime, cookie/header names, protected methods.
|
|
273
334
|
* @returns Protection bound to that configuration.
|
|
274
335
|
* @throws {ConfigurationError} when the secret is missing or shorter than
|
|
275
|
-
* {@link MIN_CSRF_SECRET_LENGTH}
|
|
336
|
+
* {@link MIN_CSRF_SECRET_LENGTH}, or when `methods` is present but empty,
|
|
337
|
+
* not an array, or contains a non-method entry.
|
|
276
338
|
*/
|
|
277
339
|
export function createCsrfProtection(config) {
|
|
278
340
|
assertUsableSecret(config.secret);
|
|
341
|
+
assertUsableMethods(config.methods);
|
|
279
342
|
const expiration = config.expiration ?? DEFAULT_EXPIRATION;
|
|
280
343
|
const cookieName = config.cookieName ?? DEFAULT_COOKIE_NAME;
|
|
281
344
|
const headerName = config.headerName ?? DEFAULT_HEADER_NAME;
|
|
@@ -301,6 +364,9 @@ export function createCsrfProtection(config) {
|
|
|
301
364
|
};
|
|
302
365
|
},
|
|
303
366
|
verify(request, options) {
|
|
367
|
+
if (request === null || typeof request !== "object") {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
304
370
|
if (!requiresCsrfProtection(request.method, config)) {
|
|
305
371
|
return true;
|
|
306
372
|
}
|
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
|
|
@@ -46,6 +46,8 @@ export declare function sanitizeString(input: string, config?: InputSanitization
|
|
|
46
46
|
* @param obj - The object to sanitize.
|
|
47
47
|
* @param config - Optional sanitization configuration.
|
|
48
48
|
* @returns The sanitized object.
|
|
49
|
+
* @throws {ConfigurationError} when `config.maxDepth` is present but is not
|
|
50
|
+
* an integer of 1 or more.
|
|
49
51
|
*/
|
|
50
52
|
export declare function sanitizeObject<T extends Record<string, unknown>>(obj: T, config?: InputSanitizationConfig): T;
|
|
51
53
|
/**
|
package/dist/input/input.core.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Sanitizes user input against common attack patterns.
|
|
5
5
|
*/
|
|
6
|
+
import { ConfigurationError } from "@zudojs/errors";
|
|
6
7
|
import { PROTOTYPE_POLLUTION_KEYS, SQL_INJECTION_PATTERNS, XSS_PATTERNS, } from "../types/security.type.js";
|
|
7
8
|
import { containsObfuscatedScheme, decodeHtmlEntities } from "./input.decode.js";
|
|
8
9
|
/**
|
|
@@ -164,11 +165,34 @@ function isPlainObject(value) {
|
|
|
164
165
|
* @param obj - The object to sanitize.
|
|
165
166
|
* @param config - Optional sanitization configuration.
|
|
166
167
|
* @returns The sanitized object.
|
|
168
|
+
* @throws {ConfigurationError} when `config.maxDepth` is present but is not
|
|
169
|
+
* an integer of 1 or more.
|
|
167
170
|
*/
|
|
168
171
|
export function sanitizeObject(obj, config) {
|
|
169
172
|
const maxDepth = config?.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
173
|
+
assertUsableMaxDepth(config?.maxDepth);
|
|
170
174
|
return sanitizeValue(obj, config, new WeakSet(), 0, maxDepth);
|
|
171
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Rejects a `maxDepth` that cannot describe a depth the caller wants kept.
|
|
178
|
+
*
|
|
179
|
+
* The guard runs before the object is entered, so `maxDepth: 0` discarded the
|
|
180
|
+
* argument itself and returned `undefined` under a non-optional `T` — every
|
|
181
|
+
* field read off the result then threw at a call site TypeScript had told was
|
|
182
|
+
* safe. `Number(process.env.MAX_DEPTH)` with the variable unset is the same
|
|
183
|
+
* shape of mistake as the body limit's `NaN`.
|
|
184
|
+
*
|
|
185
|
+
* @throws {ConfigurationError} when `maxDepth` is present but is not an
|
|
186
|
+
* integer of 1 or more.
|
|
187
|
+
*/
|
|
188
|
+
function assertUsableMaxDepth(maxDepth) {
|
|
189
|
+
if (maxDepth === undefined)
|
|
190
|
+
return;
|
|
191
|
+
if (!Number.isInteger(maxDepth) || maxDepth < 1) {
|
|
192
|
+
throw new ConfigurationError("Input sanitization maxDepth must be an integer of 1 or more, got: " +
|
|
193
|
+
String(maxDepth));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
172
196
|
/**
|
|
173
197
|
* Validates that a string contains only safe characters.
|
|
174
198
|
*
|
|
@@ -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
|
|
@@ -36,10 +36,18 @@ export function extractClientIp(headers, options) {
|
|
|
36
36
|
// Walk in from the right: index 0 from the end is the address our own
|
|
37
37
|
// outermost proxy observed, and each additional trusted hop steps left.
|
|
38
38
|
const index = chain.length - trustProxy;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
// A chain shorter than the configured hop count did not pass through all
|
|
40
|
+
// of the proxies whose entries make it trustworthy — a request entering
|
|
41
|
+
// at an inner hop, or one a client shortened on purpose. Clamping the
|
|
42
|
+
// index to 0 landed on the entry the client wrote, handing them their own
|
|
43
|
+
// rate-limit bucket; there is nothing trustworthy to read here, so the
|
|
44
|
+
// header is skipped entirely.
|
|
45
|
+
if (index >= 0) {
|
|
46
|
+
const candidate = chain[index];
|
|
47
|
+
const ip = candidate === undefined ? undefined : parseClientIp(candidate);
|
|
48
|
+
if (ip !== undefined)
|
|
49
|
+
return ip;
|
|
50
|
+
}
|
|
43
51
|
}
|
|
44
52
|
const realIp = lookupHeader(headers, "x-real-ip");
|
|
45
53
|
const ip = realIp === undefined ? undefined : parseClientIp(realIp);
|
package/dist/url/url.core.d.ts
CHANGED
|
@@ -27,6 +27,12 @@ export declare function fullyDecodeUri(value: string): {
|
|
|
27
27
|
* Operates on the fully decoded form, and treats a backslash as a separator
|
|
28
28
|
* because Windows and some proxies do.
|
|
29
29
|
*
|
|
30
|
+
* RFC 3986 lets a path segment carry parameters after a `;`, and Tomcat,
|
|
31
|
+
* Jetty and several reverse-proxy pairings strip them before resolving the
|
|
32
|
+
* path — so `/a/..;/b` names the same resource as `/a/../b`. Each segment is
|
|
33
|
+
* therefore also tested with its parameters removed. The raw segment is still
|
|
34
|
+
* tested first, so nothing that was a traversal stops being one.
|
|
35
|
+
*
|
|
30
36
|
* @param path - The path to inspect.
|
|
31
37
|
* @returns True when a `..` segment is present.
|
|
32
38
|
*/
|
package/dist/url/url.core.js
CHANGED
|
@@ -97,6 +97,12 @@ function decodeOnce(value) {
|
|
|
97
97
|
* Operates on the fully decoded form, and treats a backslash as a separator
|
|
98
98
|
* because Windows and some proxies do.
|
|
99
99
|
*
|
|
100
|
+
* RFC 3986 lets a path segment carry parameters after a `;`, and Tomcat,
|
|
101
|
+
* Jetty and several reverse-proxy pairings strip them before resolving the
|
|
102
|
+
* path — so `/a/..;/b` names the same resource as `/a/../b`. Each segment is
|
|
103
|
+
* therefore also tested with its parameters removed. The raw segment is still
|
|
104
|
+
* tested first, so nothing that was a traversal stops being one.
|
|
105
|
+
*
|
|
100
106
|
* @param path - The path to inspect.
|
|
101
107
|
* @returns True when a `..` segment is present.
|
|
102
108
|
*/
|
|
@@ -104,9 +110,17 @@ export function containsTraversal(path) {
|
|
|
104
110
|
const { decoded, truncated } = fullyDecodeUri(path);
|
|
105
111
|
if (truncated)
|
|
106
112
|
return true;
|
|
107
|
-
return decoded
|
|
108
|
-
|
|
109
|
-
|
|
113
|
+
return decoded.split(/[/\\]/).some((segment) => {
|
|
114
|
+
if (isTraversalSegment(segment))
|
|
115
|
+
return true;
|
|
116
|
+
const parameterStart = segment.indexOf(";");
|
|
117
|
+
return (parameterStart !== -1 &&
|
|
118
|
+
isTraversalSegment(segment.slice(0, parameterStart)));
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/** True for the segments a path resolver walks upward on. */
|
|
122
|
+
function isTraversalSegment(segment) {
|
|
123
|
+
return segment === ".." || segment === "...";
|
|
110
124
|
}
|
|
111
125
|
/**
|
|
112
126
|
* Validates a URL against security configuration.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/security",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
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
|
},
|