@zudojs/security 0.0.1 → 1.0.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 +254 -11
- package/dist/body/body.core.d.ts +50 -2
- package/dist/body/body.core.js +150 -19
- package/dist/body/index.d.ts +1 -1
- package/dist/body/index.js +1 -1
- package/dist/cookie/cookie.core.d.ts +12 -0
- package/dist/cookie/cookie.core.js +98 -8
- package/dist/cors/cors.core.d.ts +8 -1
- package/dist/cors/cors.core.js +66 -9
- package/dist/csrf/csrf.core.d.ts +150 -9
- package/dist/csrf/csrf.core.js +199 -41
- package/dist/csrf/index.d.ts +2 -1
- package/dist/csrf/index.js +1 -1
- package/dist/header/header.core.js +18 -5
- package/dist/headers/headers.core.js +36 -4
- package/dist/index.d.ts +14 -9
- package/dist/index.js +10 -8
- package/dist/input/index.d.ts +1 -1
- package/dist/input/index.js +1 -1
- package/dist/input/input.core.d.ts +27 -0
- package/dist/input/input.core.js +125 -37
- package/dist/rateLimit/index.d.ts +2 -1
- package/dist/rateLimit/index.js +1 -1
- package/dist/rateLimit/rateLimit.core.d.ts +56 -11
- package/dist/rateLimit/rateLimit.core.js +160 -47
- package/dist/rateLimit/rateLimit.namespace.d.ts +2 -1
- package/dist/rateLimit/rateLimit.namespace.js +2 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/security.type.d.ts +36 -19
- package/dist/types/security.type.js +26 -11
- package/dist/url/index.d.ts +2 -1
- package/dist/url/index.js +1 -1
- package/dist/url/url.core.d.ts +58 -2
- package/dist/url/url.core.js +270 -43
- package/package.json +14 -7
- package/dist/body/body.core.d.ts.map +0 -1
- package/dist/body/body.core.js.map +0 -1
- package/dist/body/index.d.ts.map +0 -1
- package/dist/body/index.js.map +0 -1
- package/dist/cookie/cookie.core.d.ts.map +0 -1
- package/dist/cookie/cookie.core.js.map +0 -1
- package/dist/cookie/index.d.ts.map +0 -1
- package/dist/cookie/index.js.map +0 -1
- package/dist/cors/cors.core.d.ts.map +0 -1
- package/dist/cors/cors.core.js.map +0 -1
- package/dist/cors/cors.namespace.d.ts.map +0 -1
- package/dist/cors/cors.namespace.js.map +0 -1
- package/dist/cors/index.d.ts.map +0 -1
- package/dist/cors/index.js.map +0 -1
- package/dist/csrf/csrf.core.d.ts.map +0 -1
- package/dist/csrf/csrf.core.js.map +0 -1
- package/dist/csrf/index.d.ts.map +0 -1
- package/dist/csrf/index.js.map +0 -1
- package/dist/header/header.core.d.ts.map +0 -1
- package/dist/header/header.core.js.map +0 -1
- package/dist/header/index.d.ts.map +0 -1
- package/dist/header/index.js.map +0 -1
- package/dist/headers/headers.core.d.ts.map +0 -1
- package/dist/headers/headers.core.js.map +0 -1
- package/dist/headers/index.d.ts.map +0 -1
- package/dist/headers/index.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/input/index.d.ts.map +0 -1
- package/dist/input/index.js.map +0 -1
- package/dist/input/input.core.d.ts.map +0 -1
- package/dist/input/input.core.js.map +0 -1
- package/dist/rateLimit/index.d.ts.map +0 -1
- package/dist/rateLimit/index.js.map +0 -1
- package/dist/rateLimit/rateLimit.core.d.ts.map +0 -1
- package/dist/rateLimit/rateLimit.core.js.map +0 -1
- package/dist/rateLimit/rateLimit.namespace.d.ts.map +0 -1
- package/dist/rateLimit/rateLimit.namespace.js.map +0 -1
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/index.js.map +0 -1
- package/dist/types/security.type.d.ts.map +0 -1
- package/dist/types/security.type.js.map +0 -1
- package/dist/url/index.d.ts.map +0 -1
- package/dist/url/index.js.map +0 -1
- package/dist/url/url.core.d.ts.map +0 -1
- package/dist/url/url.core.js.map +0 -1
package/README.md
CHANGED
|
@@ -10,24 +10,267 @@ npm install @zudojs/security
|
|
|
10
10
|
|
|
11
11
|
## Quick Start
|
|
12
12
|
|
|
13
|
+
These are **functions, not middleware**. They compute answers and header values;
|
|
14
|
+
wiring them into a request pipeline is the caller's job, which keeps the package
|
|
15
|
+
framework-agnostic.
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import {
|
|
19
|
+
createRateLimiter,
|
|
20
|
+
extractClientIp,
|
|
21
|
+
retryAfterSeconds,
|
|
22
|
+
generateSimpleHeaders,
|
|
23
|
+
generateSecurityHeaders,
|
|
24
|
+
} from "@zudojs/security";
|
|
25
|
+
|
|
26
|
+
const limiter = createRateLimiter({ windowMs: 60_000, max: 100 });
|
|
27
|
+
|
|
28
|
+
function handle(request, response) {
|
|
29
|
+
// Forwarding headers are only trusted when you say how many proxies you run.
|
|
30
|
+
const ip = extractClientIp(request.headers, {
|
|
31
|
+
trustProxy: 1,
|
|
32
|
+
remoteAddress: request.socket.remoteAddress,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const limit = limiter.check({ ip });
|
|
36
|
+
if (!limit.allowed) {
|
|
37
|
+
// `resetAt` is a Date; `Retry-After` is a whole number of seconds.
|
|
38
|
+
return respond(429, { retryAfter: retryAfterSeconds(limit) });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const headers = {
|
|
42
|
+
...generateSecurityHeaders(),
|
|
43
|
+
...generateSimpleHeaders(request.headers.origin, {
|
|
44
|
+
origin: ["https://example.com"],
|
|
45
|
+
credentials: true,
|
|
46
|
+
}),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return respond(200, body, headers);
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Rate limiting
|
|
54
|
+
|
|
55
|
+
A genuine sliding window: each check prunes entries older than `windowMs` and
|
|
56
|
+
decides against what remains, so a client cannot spend a full allowance either
|
|
57
|
+
side of a fixed boundary. Only allowed requests consume an allowance slot, so a
|
|
58
|
+
client being limited cannot grow its own bucket.
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
const limiter = createRateLimiter({
|
|
62
|
+
windowMs: 60_000,
|
|
63
|
+
max: 100,
|
|
64
|
+
maxKeys: 100_000, // LRU-evicted ceiling on tracked keys
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const ip = "203.0.113.7";
|
|
68
|
+
|
|
69
|
+
limiter.check({ ip }); // { allowed, remaining, resetAt: Date, total }
|
|
70
|
+
limiter.getCount(ip);
|
|
71
|
+
limiter.reset(ip);
|
|
72
|
+
limiter.destroy(); // clears the cleanup timer
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`middleware` fills a response for you, and the default handler now derives
|
|
76
|
+
`Retry-After` from the decision rather than emitting a fixed `60`. It also
|
|
77
|
+
honours `message`:
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
const limiter = createRateLimiter({
|
|
81
|
+
windowMs: 3_600_000,
|
|
82
|
+
max: 10,
|
|
83
|
+
message: "Hourly quota exhausted.",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const response = { statusCode: 200, headers: {} as Record<string, string> };
|
|
87
|
+
const result = limiter.middleware({ ip }, response);
|
|
88
|
+
// response.headers["Retry-After"] → seconds until the window frees up
|
|
89
|
+
// response.headers["X-RateLimit-Limit"], ["X-RateLimit-Remaining"], ["X-RateLimit-Reset"]
|
|
90
|
+
// response.body → { error: { code, message: "Hourly quota exhausted." } }
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
A custom `handler` receives the same decision as a third argument, so it can do
|
|
94
|
+
the arithmetic too. Two-parameter handlers written against the previous
|
|
95
|
+
signature still work.
|
|
96
|
+
|
|
97
|
+
**`extractClientIp` does not trust `X-Forwarded-For` by default.** Any client can
|
|
98
|
+
send that header, so taking its leftmost entry hands the caller control of their
|
|
99
|
+
own rate-limit bucket. Set `trustProxy` to the number of proxies you actually
|
|
100
|
+
operate; entries are then read in from the right, and everything to the left of
|
|
101
|
+
your own hops is ignored.
|
|
102
|
+
|
|
103
|
+
## CORS
|
|
104
|
+
|
|
13
105
|
```typescript
|
|
14
|
-
import {
|
|
106
|
+
import {
|
|
107
|
+
generatePreflightHeaders,
|
|
108
|
+
generateSimpleHeaders,
|
|
109
|
+
} from "@zudojs/security";
|
|
15
110
|
|
|
16
|
-
const
|
|
111
|
+
const config = { origin: ["https://app.example.com"], credentials: true };
|
|
17
112
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
113
|
+
// `Access-Control-Request-Headers` is a comma-separated list; split it
|
|
114
|
+
// yourself — the package takes a `string[]`, not the raw header.
|
|
115
|
+
const requested = (request.headers["access-control-request-headers"] ?? "")
|
|
116
|
+
.split(",")
|
|
117
|
+
.map((h) => h.trim())
|
|
118
|
+
.filter((h) => h.length > 0);
|
|
119
|
+
|
|
120
|
+
// Preflight — optionally validating what the browser asked for.
|
|
121
|
+
generatePreflightHeaders(request.headers.origin, config, {
|
|
122
|
+
method: request.headers["access-control-request-method"],
|
|
123
|
+
headers: requested,
|
|
124
|
+
});
|
|
21
125
|
```
|
|
22
126
|
|
|
127
|
+
`Vary: Origin` is emitted whenever the allowed origin is reflected (array, regex
|
|
128
|
+
or predicate), including on rejection — without it a shared cache can serve one
|
|
129
|
+
origin's `Access-Control-Allow-Origin` to another. A wildcard origin combined
|
|
130
|
+
with `credentials` throws, since browsers reject that pairing outright.
|
|
131
|
+
|
|
132
|
+
## CSRF
|
|
133
|
+
|
|
134
|
+
Two patterns, both on the same HMAC-SHA256 token. Bind the token to a session
|
|
135
|
+
wherever you have one — an unbound token is valid for every user.
|
|
136
|
+
|
|
137
|
+
The shortest correct version binds your configuration once. `secret` must be at
|
|
138
|
+
least 32 characters — the signature is HMAC-SHA256, so a shorter one adds no
|
|
139
|
+
strength:
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
import { createCsrfProtection } from "@zudojs/security";
|
|
143
|
+
|
|
144
|
+
const csrf = createCsrfProtection({
|
|
145
|
+
secret: process.env.CSRF_SECRET, // >= 32 chars
|
|
146
|
+
cookieName: "app_csrf",
|
|
147
|
+
headerName: "x-app-csrf",
|
|
148
|
+
expiration: 3600,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Issue
|
|
152
|
+
const { token, setCookie } = csrf.issue({ sessionId });
|
|
153
|
+
setHeader("Set-Cookie", setCookie);
|
|
154
|
+
|
|
155
|
+
// Verify — safe to call on every request; safe methods return true.
|
|
156
|
+
if (!csrf.verify(
|
|
157
|
+
{ method: request.method, headers: request.headers, cookieHeader: request.headers.cookie },
|
|
158
|
+
{ sessionId },
|
|
159
|
+
)) {
|
|
160
|
+
return respond(403);
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The same thing with the primitives, if you would rather hold the pieces:
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
import {
|
|
168
|
+
generateCsrfToken,
|
|
169
|
+
generateCsrfCookie,
|
|
170
|
+
verifyDoubleSubmit,
|
|
171
|
+
extractCsrfTokenFromCookies,
|
|
172
|
+
extractCsrfTokenFromHeaders,
|
|
173
|
+
requiresCsrfProtection,
|
|
174
|
+
} from "@zudojs/security";
|
|
175
|
+
|
|
176
|
+
// Issue
|
|
177
|
+
const token = generateCsrfToken(secret, { sessionId, expiration: 3600 });
|
|
178
|
+
setHeader("Set-Cookie", generateCsrfCookie(token));
|
|
179
|
+
|
|
180
|
+
// Verify
|
|
181
|
+
if (requiresCsrfProtection(request.method)) {
|
|
182
|
+
const ok = verifyDoubleSubmit(
|
|
183
|
+
extractCsrfTokenFromCookies(request.headers.cookie ?? ""),
|
|
184
|
+
extractCsrfTokenFromHeaders(request.headers),
|
|
185
|
+
secret,
|
|
186
|
+
{ sessionId },
|
|
187
|
+
);
|
|
188
|
+
if (!ok) return respond(403);
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The cookie is `Secure` and `HttpOnly` by default, which suits the synchroniser
|
|
193
|
+
token pattern where the server renders the token into the page. For the
|
|
194
|
+
double-submit pattern — where client script reads the cookie back — pass
|
|
195
|
+
`httpOnly: false`, and accept that XSS on the origin can then read the token.
|
|
196
|
+
|
|
197
|
+
## URLs and SSRF
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
import {
|
|
201
|
+
isSafeUrl,
|
|
202
|
+
isPrivateHostname,
|
|
203
|
+
validateRequestTarget,
|
|
204
|
+
} from "@zudojs/security";
|
|
205
|
+
|
|
206
|
+
isSafeUrl("http://169.254.169.254/"); // false — cloud metadata
|
|
207
|
+
isSafeUrl("http://[::ffff:127.0.0.1]/"); // false — v4-mapped loopback
|
|
208
|
+
isSafeUrl("gopher://internal/"); // false — protocol not allowlisted
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
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`), and only `http:`
|
|
213
|
+
and `https:` are permitted unless you widen `allowedProtocols`.
|
|
214
|
+
|
|
215
|
+
**This cannot stop DNS rebinding.** A public hostname may resolve to a private
|
|
216
|
+
address, and may resolve differently between the check and the connection. For
|
|
217
|
+
outbound requests that must be safe, resolve the hostname yourself, run
|
|
218
|
+
`isPrivateHostname` against the resolved address, and connect to that address.
|
|
219
|
+
|
|
220
|
+
`validateRequestTarget` rejects CR and LF — literal or percent-encoded — and
|
|
221
|
+
detects traversal by decoding to a fixed point rather than pattern-matching
|
|
222
|
+
encoded forms. It also enforces `maxLength` and rejects invalid percent
|
|
223
|
+
encoding; a single trailing `%` used to abort decoding altogether, so
|
|
224
|
+
`/a/%2e%2e/etc/passwd%` and `/a%0d%0aX-Evil:1%` were both reported valid.
|
|
225
|
+
|
|
226
|
+
It takes a `RequestTargetConfig` rather than the full `UrlValidationConfig`: a
|
|
227
|
+
request target is origin-form and carries no scheme, so `allowedProtocols`
|
|
228
|
+
could never apply to it.
|
|
229
|
+
|
|
230
|
+
## Cookies
|
|
231
|
+
|
|
232
|
+
`serializeCookie` validates before it writes: the value is percent-encoded, and
|
|
233
|
+
an unsafe name, attribute, `Max-Age` or `Expires` throws. `SameSite=None` and
|
|
234
|
+
`Partitioned` require `Secure`.
|
|
235
|
+
|
|
236
|
+
`parseCookieHeader` validates on the way in too. A name that is not an RFC 6265
|
|
237
|
+
token, or a value carrying a control character, is reported in `errors` and
|
|
238
|
+
kept out of `cookies` — it used to check only length, so a malformed cookie
|
|
239
|
+
reached the caller with `errors: []`. Values are otherwise accepted leniently
|
|
240
|
+
(spaces, commas and quoted-string wrappers are common in the wild); use
|
|
241
|
+
`validateCookieValue` where you want the strict `cookie-octet` rule.
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
createSecureCookie("sid", value, { maxAge: 3600 });
|
|
245
|
+
// sid=…; Max-Age=3600; Secure; HttpOnly; SameSite=Lax
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Input sanitization
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
import { sanitizeObject, detectThreats, escapeHtml } from "@zudojs/security";
|
|
252
|
+
|
|
253
|
+
sanitizeObject(payload, { maxDepth: 32 }); // cycle-safe, arrays stay arrays
|
|
254
|
+
detectThreats(input); // ordered: SQL_INJECTION, XSS, NULL_BYTE, CONTROL_CHARACTERS
|
|
255
|
+
escapeHtml(text);
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`detectThreats` and `containsSqlInjection` are heuristics with a high
|
|
259
|
+
false-positive rate on ordinary prose — useful for logging and alerting, never a
|
|
260
|
+
substitute for parameterised queries or contextual output encoding.
|
|
261
|
+
`escapeHtml` covers element text and quoted attribute values; unquoted
|
|
262
|
+
attributes, `<script>` bodies and URL positions need their own encoding.
|
|
263
|
+
|
|
23
264
|
## Features
|
|
24
265
|
|
|
25
|
-
-
|
|
26
|
-
- CORS
|
|
27
|
-
- CSRF
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
266
|
+
- Sliding-window rate limiting with proxy-aware client IP extraction
|
|
267
|
+
- CORS with `Vary: Origin` and wildcard/credentials validation
|
|
268
|
+
- CSRF tokens: HMAC-SHA256, session-bound, constant-time double-submit
|
|
269
|
+
- URL validation, traversal detection and SSRF range checks
|
|
270
|
+
- Cookie parsing and injection-safe serialization
|
|
271
|
+
- Header validation, CRLF stripping and opt-in hop-by-hop rejection
|
|
272
|
+
- Body size limits, `Content-Length` validation and request-smuggling framing checks
|
|
273
|
+
- Security headers with a restrictive default CSP and HSTS
|
|
31
274
|
|
|
32
275
|
## Use Cases
|
|
33
276
|
|
package/dist/body/body.core.d.ts
CHANGED
|
@@ -12,7 +12,19 @@ export declare const DEFAULT_BODY_LIMITS: BodyLimitPresets;
|
|
|
12
12
|
* @param contentLength - The Content-Length header value.
|
|
13
13
|
* @returns An error message if invalid, or undefined.
|
|
14
14
|
*/
|
|
15
|
-
export declare function validateContentLength(contentLength: string | undefined): string | undefined;
|
|
15
|
+
export declare function validateContentLength(contentLength: string | undefined, maxSize?: number): string | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Validates the framing headers of a request.
|
|
18
|
+
*
|
|
19
|
+
* A message carrying both `Content-Length` and `Transfer-Encoding`, or more
|
|
20
|
+
* than one distinct `Content-Length`, is ambiguous: two servers in a chain can
|
|
21
|
+
* disagree about where the body ends. RFC 9112 requires rejecting it.
|
|
22
|
+
*
|
|
23
|
+
* @param headers - Request headers.
|
|
24
|
+
* @param maxSize - Optional maximum allowed Content-Length in bytes.
|
|
25
|
+
* @returns An error message if the framing is unsafe, or undefined.
|
|
26
|
+
*/
|
|
27
|
+
export declare function validateBodyFraming(headers: Record<string, string | string[] | undefined>, maxSize?: number): string | undefined;
|
|
16
28
|
/**
|
|
17
29
|
* Validates that a body size is within the allowed limit.
|
|
18
30
|
*
|
|
@@ -22,14 +34,33 @@ export declare function validateContentLength(contentLength: string | undefined)
|
|
|
22
34
|
* @returns An error message if too large, or undefined.
|
|
23
35
|
*/
|
|
24
36
|
export declare function validateBodySize(actualSize: number, maxSize?: number, contentType?: string): string | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Parses a Content-Type header into its bare media type.
|
|
39
|
+
*
|
|
40
|
+
* Strips parameters (`; charset=utf-8`, `; boundary=…`) and lowercases, so
|
|
41
|
+
* routing decisions see `application/json` rather than the raw header.
|
|
42
|
+
*
|
|
43
|
+
* @param contentType - The Content-Type header value.
|
|
44
|
+
* @returns The lowercased media type, or undefined when absent or malformed.
|
|
45
|
+
*/
|
|
46
|
+
export declare function parseMediaType(contentType: string | undefined): string | undefined;
|
|
25
47
|
/**
|
|
26
48
|
* Gets the appropriate body limit for a given content type.
|
|
27
49
|
*
|
|
50
|
+
* Routing is on the parsed media type, not on substrings of the raw header:
|
|
51
|
+
* a client that sends `application/x-notjson` does not get the JSON limit.
|
|
52
|
+
*
|
|
53
|
+
* Note that a form post cannot be recognised as an authentication request from
|
|
54
|
+
* its Content-Type — auth endpoints send exactly the same media type as any
|
|
55
|
+
* other form. Pass `purpose: "auth"` on those routes to select the tighter
|
|
56
|
+
* limit; otherwise a login form is bounded only by the upload limit.
|
|
57
|
+
*
|
|
28
58
|
* @param contentType - The Content-Type header value.
|
|
29
59
|
* @param presetLimits - Optional custom preset limits.
|
|
60
|
+
* @param purpose - Optional explicit route purpose, overriding type-based routing.
|
|
30
61
|
* @returns The maximum body size in bytes.
|
|
31
62
|
*/
|
|
32
|
-
export declare function getBodyLimitForContentType(contentType: string | undefined, presetLimits?: Partial<BodyLimitPresets
|
|
63
|
+
export declare function getBodyLimitForContentType(contentType: string | undefined, presetLimits?: Partial<BodyLimitPresets>, purpose?: keyof BodyLimitPresets): number;
|
|
33
64
|
/**
|
|
34
65
|
* Validates a body limit configuration.
|
|
35
66
|
*
|
|
@@ -37,6 +68,23 @@ export declare function getBodyLimitForContentType(contentType: string | undefin
|
|
|
37
68
|
* @returns An error message if invalid, or undefined.
|
|
38
69
|
*/
|
|
39
70
|
export declare function validateBodyLimitConfig(config: BodyLimitConfig): string | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* Resolves the body limit that applies to a content type from a rule list.
|
|
73
|
+
*
|
|
74
|
+
* This is the implementation of `BodyLimitConfig.contentTypes`, which was
|
|
75
|
+
* declared as *"Content types that use this limit (if empty, applies to all)"*
|
|
76
|
+
* and had no reader anywhere in the package — so the field could be filled in
|
|
77
|
+
* and would never change a single decision.
|
|
78
|
+
*
|
|
79
|
+
* A rule naming the content type wins over a catch-all, whatever the order in
|
|
80
|
+
* the array, so a general default can sit alongside specific overrides.
|
|
81
|
+
*
|
|
82
|
+
* @param contentType - The raw `Content-Type` header value.
|
|
83
|
+
* @param rules - Limit rules, each optionally scoped to content types.
|
|
84
|
+
* @param fallback - Limit to use when no rule applies.
|
|
85
|
+
* @returns The maximum body size in bytes.
|
|
86
|
+
*/
|
|
87
|
+
export declare function resolveBodyLimit(contentType: string | undefined, rules: readonly BodyLimitConfig[], fallback?: number): number;
|
|
40
88
|
/**
|
|
41
89
|
* Creates a body size checker function for use in middleware.
|
|
42
90
|
*
|
package/dist/body/body.core.js
CHANGED
|
@@ -22,20 +22,70 @@ const DEFAULT_MAX_BODY_SIZE = 1_048_576;
|
|
|
22
22
|
* @param contentLength - The Content-Length header value.
|
|
23
23
|
* @returns An error message if invalid, or undefined.
|
|
24
24
|
*/
|
|
25
|
-
export function validateContentLength(contentLength) {
|
|
25
|
+
export function validateContentLength(contentLength, maxSize) {
|
|
26
26
|
if (contentLength === undefined) {
|
|
27
27
|
return undefined; // No Content-Length is fine (chunked transfer)
|
|
28
28
|
}
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
// RFC 9110: Content-Length is 1*DIGIT and nothing else. `parseInt` would
|
|
30
|
+
// accept "100abc" as 100 and "1e10" as 1 — a length the origin and any
|
|
31
|
+
// intermediary could disagree about, which is how requests get smuggled.
|
|
32
|
+
if (!/^\d+$/.test(contentLength)) {
|
|
31
33
|
return `Content-Length is not a valid number: ${contentLength}`;
|
|
32
34
|
}
|
|
33
|
-
|
|
34
|
-
return `Content-Length cannot be negative: ${parsed}`;
|
|
35
|
-
}
|
|
35
|
+
const parsed = Number(contentLength);
|
|
36
36
|
if (!Number.isSafeInteger(parsed)) {
|
|
37
37
|
return `Content-Length is not a safe integer: ${contentLength}`;
|
|
38
38
|
}
|
|
39
|
+
if (maxSize !== undefined && parsed > maxSize) {
|
|
40
|
+
return `Content-Length ${parsed} exceeds maximum ${maxSize} bytes`;
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Validates the framing headers of a request.
|
|
46
|
+
*
|
|
47
|
+
* A message carrying both `Content-Length` and `Transfer-Encoding`, or more
|
|
48
|
+
* than one distinct `Content-Length`, is ambiguous: two servers in a chain can
|
|
49
|
+
* disagree about where the body ends. RFC 9112 requires rejecting it.
|
|
50
|
+
*
|
|
51
|
+
* @param headers - Request headers.
|
|
52
|
+
* @param maxSize - Optional maximum allowed Content-Length in bytes.
|
|
53
|
+
* @returns An error message if the framing is unsafe, or undefined.
|
|
54
|
+
*/
|
|
55
|
+
export function validateBodyFraming(headers, maxSize) {
|
|
56
|
+
const lookup = new Map(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
|
|
57
|
+
const contentLength = lookup.get("content-length");
|
|
58
|
+
const transferEncoding = lookup.get("transfer-encoding");
|
|
59
|
+
if (contentLength !== undefined && transferEncoding !== undefined) {
|
|
60
|
+
return "Request specifies both Content-Length and Transfer-Encoding (request smuggling risk)";
|
|
61
|
+
}
|
|
62
|
+
if (Array.isArray(contentLength)) {
|
|
63
|
+
const distinct = new Set(contentLength.map((v) => v.trim()));
|
|
64
|
+
if (distinct.size > 1) {
|
|
65
|
+
return `Request specifies conflicting Content-Length values: ${[...distinct].join(", ")}`;
|
|
66
|
+
}
|
|
67
|
+
return validateContentLength(contentLength[0], maxSize);
|
|
68
|
+
}
|
|
69
|
+
if (typeof contentLength === "string") {
|
|
70
|
+
// A single header field may still carry a comma-separated list.
|
|
71
|
+
if (contentLength.includes(",")) {
|
|
72
|
+
const distinct = new Set(contentLength.split(",").map((v) => v.trim()));
|
|
73
|
+
if (distinct.size > 1) {
|
|
74
|
+
return `Request specifies conflicting Content-Length values: ${[...distinct].join(", ")}`;
|
|
75
|
+
}
|
|
76
|
+
return validateContentLength([...distinct][0], maxSize);
|
|
77
|
+
}
|
|
78
|
+
return validateContentLength(contentLength, maxSize);
|
|
79
|
+
}
|
|
80
|
+
if (typeof transferEncoding === "string") {
|
|
81
|
+
const encodings = transferEncoding
|
|
82
|
+
.toLowerCase()
|
|
83
|
+
.split(",")
|
|
84
|
+
.map((e) => e.trim());
|
|
85
|
+
if (encodings.length > 0 && encodings[encodings.length - 1] !== "chunked") {
|
|
86
|
+
return `Transfer-Encoding must end with "chunked", got: ${transferEncoding}`;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
39
89
|
return undefined;
|
|
40
90
|
}
|
|
41
91
|
/**
|
|
@@ -54,34 +104,70 @@ export function validateBodySize(actualSize, maxSize, contentType) {
|
|
|
54
104
|
}
|
|
55
105
|
return undefined;
|
|
56
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Parses a Content-Type header into its bare media type.
|
|
109
|
+
*
|
|
110
|
+
* Strips parameters (`; charset=utf-8`, `; boundary=…`) and lowercases, so
|
|
111
|
+
* routing decisions see `application/json` rather than the raw header.
|
|
112
|
+
*
|
|
113
|
+
* @param contentType - The Content-Type header value.
|
|
114
|
+
* @returns The lowercased media type, or undefined when absent or malformed.
|
|
115
|
+
*/
|
|
116
|
+
export function parseMediaType(contentType) {
|
|
117
|
+
if (!contentType)
|
|
118
|
+
return undefined;
|
|
119
|
+
const bare = contentType.split(";")[0]?.trim().toLowerCase();
|
|
120
|
+
return bare && bare.includes("/") ? bare : undefined;
|
|
121
|
+
}
|
|
57
122
|
/**
|
|
58
123
|
* Gets the appropriate body limit for a given content type.
|
|
59
124
|
*
|
|
125
|
+
* Routing is on the parsed media type, not on substrings of the raw header:
|
|
126
|
+
* a client that sends `application/x-notjson` does not get the JSON limit.
|
|
127
|
+
*
|
|
128
|
+
* Note that a form post cannot be recognised as an authentication request from
|
|
129
|
+
* its Content-Type — auth endpoints send exactly the same media type as any
|
|
130
|
+
* other form. Pass `purpose: "auth"` on those routes to select the tighter
|
|
131
|
+
* limit; otherwise a login form is bounded only by the upload limit.
|
|
132
|
+
*
|
|
60
133
|
* @param contentType - The Content-Type header value.
|
|
61
134
|
* @param presetLimits - Optional custom preset limits.
|
|
135
|
+
* @param purpose - Optional explicit route purpose, overriding type-based routing.
|
|
62
136
|
* @returns The maximum body size in bytes.
|
|
63
137
|
*/
|
|
64
|
-
export function getBodyLimitForContentType(contentType, presetLimits) {
|
|
138
|
+
export function getBodyLimitForContentType(contentType, presetLimits, purpose) {
|
|
65
139
|
const limits = { ...DEFAULT_BODY_LIMITS, ...presetLimits };
|
|
66
|
-
if (
|
|
140
|
+
if (purpose !== undefined) {
|
|
141
|
+
return limits[purpose];
|
|
142
|
+
}
|
|
143
|
+
const type = parseMediaType(contentType);
|
|
144
|
+
if (!type) {
|
|
67
145
|
return limits.json;
|
|
68
146
|
}
|
|
69
|
-
const
|
|
70
|
-
|
|
147
|
+
const [group = "", subtype = ""] = type.split("/");
|
|
148
|
+
// Structured-syntax suffixes: application/vnd.api+json, image/svg+xml, …
|
|
149
|
+
const suffix = subtype.includes("+")
|
|
150
|
+
? subtype.slice(subtype.lastIndexOf("+") + 1)
|
|
151
|
+
: undefined;
|
|
152
|
+
if (subtype === "json" || suffix === "json") {
|
|
71
153
|
return limits.json;
|
|
72
154
|
}
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
if (type.includes("login") || type.includes("auth")) {
|
|
76
|
-
return limits.auth;
|
|
77
|
-
}
|
|
78
|
-
return limits.upload;
|
|
155
|
+
if (subtype === "xml" || suffix === "xml") {
|
|
156
|
+
return limits.webhook;
|
|
79
157
|
}
|
|
80
|
-
if (type
|
|
158
|
+
if (type === "application/x-www-form-urlencoded") {
|
|
159
|
+
// Urlencoded forms carry field data, not files — the JSON limit fits far
|
|
160
|
+
// better than the 100 MB upload limit a login form used to receive.
|
|
161
|
+
return limits.json;
|
|
162
|
+
}
|
|
163
|
+
if (group === "multipart") {
|
|
81
164
|
return limits.upload;
|
|
82
165
|
}
|
|
83
|
-
if (type
|
|
84
|
-
|
|
166
|
+
if (type === "application/octet-stream" ||
|
|
167
|
+
group === "image" ||
|
|
168
|
+
group === "video" ||
|
|
169
|
+
group === "audio") {
|
|
170
|
+
return limits.upload;
|
|
85
171
|
}
|
|
86
172
|
return limits.json;
|
|
87
173
|
}
|
|
@@ -99,8 +185,53 @@ export function validateBodyLimitConfig(config) {
|
|
|
99
185
|
// 1GB
|
|
100
186
|
return `Body limit maxSize ${config.maxSize} exceeds maximum allowed (1GB)`;
|
|
101
187
|
}
|
|
188
|
+
// `contentTypes` used to be declared and never looked at, here or anywhere.
|
|
189
|
+
// An entry that is not a media type would silently never match, so it is
|
|
190
|
+
// reported rather than ignored.
|
|
191
|
+
if (config.contentTypes) {
|
|
192
|
+
for (const entry of config.contentTypes) {
|
|
193
|
+
if (parseMediaType(entry) === undefined) {
|
|
194
|
+
return `Body limit contentTypes entry is not a media type: "${entry}" (expected e.g. "application/json")`;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
102
198
|
return undefined;
|
|
103
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Resolves the body limit that applies to a content type from a rule list.
|
|
202
|
+
*
|
|
203
|
+
* This is the implementation of `BodyLimitConfig.contentTypes`, which was
|
|
204
|
+
* declared as *"Content types that use this limit (if empty, applies to all)"*
|
|
205
|
+
* and had no reader anywhere in the package — so the field could be filled in
|
|
206
|
+
* and would never change a single decision.
|
|
207
|
+
*
|
|
208
|
+
* A rule naming the content type wins over a catch-all, whatever the order in
|
|
209
|
+
* the array, so a general default can sit alongside specific overrides.
|
|
210
|
+
*
|
|
211
|
+
* @param contentType - The raw `Content-Type` header value.
|
|
212
|
+
* @param rules - Limit rules, each optionally scoped to content types.
|
|
213
|
+
* @param fallback - Limit to use when no rule applies.
|
|
214
|
+
* @returns The maximum body size in bytes.
|
|
215
|
+
*/
|
|
216
|
+
export function resolveBodyLimit(contentType, rules, fallback = DEFAULT_MAX_BODY_SIZE) {
|
|
217
|
+
const type = parseMediaType(contentType);
|
|
218
|
+
if (type !== undefined) {
|
|
219
|
+
for (const rule of rules) {
|
|
220
|
+
const scoped = rule.contentTypes;
|
|
221
|
+
if (!scoped || scoped.length === 0)
|
|
222
|
+
continue;
|
|
223
|
+
if (scoped.some((entry) => parseMediaType(entry) === type)) {
|
|
224
|
+
return rule.maxSize;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
for (const rule of rules) {
|
|
229
|
+
if (!rule.contentTypes || rule.contentTypes.length === 0) {
|
|
230
|
+
return rule.maxSize;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return fallback;
|
|
234
|
+
}
|
|
104
235
|
/**
|
|
105
236
|
* Creates a body size checker function for use in middleware.
|
|
106
237
|
*
|
package/dist/body/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @zudojs/security — Body Validation Barrel
|
|
3
3
|
*/
|
|
4
|
-
export { DEFAULT_BODY_LIMITS, validateContentLength, validateBodySize, getBodyLimitForContentType, validateBodyLimitConfig, createBodySizeChecker, } from "./body.core.js";
|
|
4
|
+
export { DEFAULT_BODY_LIMITS, parseMediaType, validateBodyFraming, validateContentLength, validateBodySize, getBodyLimitForContentType, validateBodyLimitConfig, resolveBodyLimit, createBodySizeChecker, } from "./body.core.js";
|
|
5
5
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/body/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @zudojs/security — Body Validation Barrel
|
|
3
3
|
*/
|
|
4
|
-
export { DEFAULT_BODY_LIMITS, validateContentLength, validateBodySize, getBodyLimitForContentType, validateBodyLimitConfig, createBodySizeChecker, } from "./body.core.js";
|
|
4
|
+
export { DEFAULT_BODY_LIMITS, parseMediaType, validateBodyFraming, validateContentLength, validateBodySize, getBodyLimitForContentType, validateBodyLimitConfig, resolveBodyLimit, createBodySizeChecker, } from "./body.core.js";
|
|
5
5
|
//# sourceMappingURL=index.js.map
|
|
@@ -18,9 +18,16 @@ export declare function parseCookieHeader(cookieHeader: string, config?: CookieS
|
|
|
18
18
|
/**
|
|
19
19
|
* Serializes a parsed cookie into a Set-Cookie header value.
|
|
20
20
|
*
|
|
21
|
+
* The name, value, and every attribute are validated before being written: a
|
|
22
|
+
* value carrying a CRLF would split the response, and one carrying a `;` would
|
|
23
|
+
* append attributes the caller never asked for. Values are percent-encoded by
|
|
24
|
+
* default so ordinary text (spaces, commas, non-ASCII) round-trips safely
|
|
25
|
+
* through {@link parseCookieHeader}.
|
|
26
|
+
*
|
|
21
27
|
* @param cookie - The cookie to serialize.
|
|
22
28
|
* @param config - Optional security configuration for defaults.
|
|
23
29
|
* @returns The serialized Set-Cookie header value.
|
|
30
|
+
* @throws {Error} when the name, value, or an attribute is unsafe.
|
|
24
31
|
*/
|
|
25
32
|
export declare function serializeCookie(cookie: ParsedCookie, config?: CookieSecurityConfig): string;
|
|
26
33
|
/**
|
|
@@ -31,6 +38,7 @@ export declare function serializeCookie(cookie: ParsedCookie, config?: CookieSec
|
|
|
31
38
|
* @param options - Optional cookie attributes.
|
|
32
39
|
* @param config - Optional security configuration.
|
|
33
40
|
* @returns The serialized Set-Cookie header value.
|
|
41
|
+
* @throws {Error} when the name, value, or an attribute is unsafe.
|
|
34
42
|
*/
|
|
35
43
|
export declare function createSecureCookie(name: string, value: string, options?: Partial<Omit<ParsedCookie, "name" | "value">>, config?: CookieSecurityConfig): string;
|
|
36
44
|
/**
|
|
@@ -50,6 +58,10 @@ export declare function validateCookieValue(value: string): string | undefined;
|
|
|
50
58
|
/**
|
|
51
59
|
* Strips security-sensitive cookies from a cookie header.
|
|
52
60
|
*
|
|
61
|
+
* Matching is on the whole name and on `name`-prefixed variants (`session`
|
|
62
|
+
* also strips `session_id` and `session-token`), because the sensitive cookie
|
|
63
|
+
* in a real deployment is rarely named exactly `session`.
|
|
64
|
+
*
|
|
53
65
|
* @param cookieHeader - The raw Cookie header.
|
|
54
66
|
* @param sensitiveNames - Names of cookies to strip (case-insensitive).
|
|
55
67
|
* @returns The cleaned cookie header.
|