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