@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.
Files changed (81) hide show
  1. package/README.md +254 -11
  2. package/dist/body/body.core.d.ts +50 -2
  3. package/dist/body/body.core.js +150 -19
  4. package/dist/body/index.d.ts +1 -1
  5. package/dist/body/index.js +1 -1
  6. package/dist/cookie/cookie.core.d.ts +12 -0
  7. package/dist/cookie/cookie.core.js +98 -8
  8. package/dist/cors/cors.core.d.ts +8 -1
  9. package/dist/cors/cors.core.js +66 -9
  10. package/dist/csrf/csrf.core.d.ts +150 -9
  11. package/dist/csrf/csrf.core.js +199 -41
  12. package/dist/csrf/index.d.ts +2 -1
  13. package/dist/csrf/index.js +1 -1
  14. package/dist/header/header.core.js +18 -5
  15. package/dist/headers/headers.core.js +36 -4
  16. package/dist/index.d.ts +14 -9
  17. package/dist/index.js +10 -8
  18. package/dist/input/index.d.ts +1 -1
  19. package/dist/input/index.js +1 -1
  20. package/dist/input/input.core.d.ts +27 -0
  21. package/dist/input/input.core.js +125 -37
  22. package/dist/rateLimit/index.d.ts +2 -1
  23. package/dist/rateLimit/index.js +1 -1
  24. package/dist/rateLimit/rateLimit.core.d.ts +56 -11
  25. package/dist/rateLimit/rateLimit.core.js +160 -47
  26. package/dist/rateLimit/rateLimit.namespace.d.ts +2 -1
  27. package/dist/rateLimit/rateLimit.namespace.js +2 -1
  28. package/dist/types/index.d.ts +1 -1
  29. package/dist/types/security.type.d.ts +36 -19
  30. package/dist/types/security.type.js +26 -11
  31. package/dist/url/index.d.ts +2 -1
  32. package/dist/url/index.js +1 -1
  33. package/dist/url/url.core.d.ts +58 -2
  34. package/dist/url/url.core.js +270 -43
  35. package/package.json +14 -7
  36. package/dist/body/body.core.d.ts.map +0 -1
  37. package/dist/body/body.core.js.map +0 -1
  38. package/dist/body/index.d.ts.map +0 -1
  39. package/dist/body/index.js.map +0 -1
  40. package/dist/cookie/cookie.core.d.ts.map +0 -1
  41. package/dist/cookie/cookie.core.js.map +0 -1
  42. package/dist/cookie/index.d.ts.map +0 -1
  43. package/dist/cookie/index.js.map +0 -1
  44. package/dist/cors/cors.core.d.ts.map +0 -1
  45. package/dist/cors/cors.core.js.map +0 -1
  46. package/dist/cors/cors.namespace.d.ts.map +0 -1
  47. package/dist/cors/cors.namespace.js.map +0 -1
  48. package/dist/cors/index.d.ts.map +0 -1
  49. package/dist/cors/index.js.map +0 -1
  50. package/dist/csrf/csrf.core.d.ts.map +0 -1
  51. package/dist/csrf/csrf.core.js.map +0 -1
  52. package/dist/csrf/index.d.ts.map +0 -1
  53. package/dist/csrf/index.js.map +0 -1
  54. package/dist/header/header.core.d.ts.map +0 -1
  55. package/dist/header/header.core.js.map +0 -1
  56. package/dist/header/index.d.ts.map +0 -1
  57. package/dist/header/index.js.map +0 -1
  58. package/dist/headers/headers.core.d.ts.map +0 -1
  59. package/dist/headers/headers.core.js.map +0 -1
  60. package/dist/headers/index.d.ts.map +0 -1
  61. package/dist/headers/index.js.map +0 -1
  62. package/dist/index.d.ts.map +0 -1
  63. package/dist/index.js.map +0 -1
  64. package/dist/input/index.d.ts.map +0 -1
  65. package/dist/input/index.js.map +0 -1
  66. package/dist/input/input.core.d.ts.map +0 -1
  67. package/dist/input/input.core.js.map +0 -1
  68. package/dist/rateLimit/index.d.ts.map +0 -1
  69. package/dist/rateLimit/index.js.map +0 -1
  70. package/dist/rateLimit/rateLimit.core.d.ts.map +0 -1
  71. package/dist/rateLimit/rateLimit.core.js.map +0 -1
  72. package/dist/rateLimit/rateLimit.namespace.d.ts.map +0 -1
  73. package/dist/rateLimit/rateLimit.namespace.js.map +0 -1
  74. package/dist/types/index.d.ts.map +0 -1
  75. package/dist/types/index.js.map +0 -1
  76. package/dist/types/security.type.d.ts.map +0 -1
  77. package/dist/types/security.type.js.map +0 -1
  78. package/dist/url/index.d.ts.map +0 -1
  79. package/dist/url/index.js.map +0 -1
  80. package/dist/url/url.core.d.ts.map +0 -1
  81. 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 { createRateLimiter, cors, securityHeaders } from "@zudojs/security";
106
+ import {
107
+ generatePreflightHeaders,
108
+ generateSimpleHeaders,
109
+ } from "@zudojs/security";
15
110
 
16
- const limiter = createRateLimiter({ windowMs: 60000, max: 100 });
111
+ const config = { origin: ["https://app.example.com"], credentials: true };
17
112
 
18
- app.use(limiter);
19
- app.use(cors({ origin: "https://example.com" }));
20
- app.use(securityHeaders());
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
- - Input validation and sanitization
26
- - CORS configuration
27
- - CSRF token generation and validation
28
- - Rate limiting with sliding window
29
- - Security headers (CSP, HSTS, X-Frame-Options, etc.)
30
- - URL normalization and SSRF protection
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
 
@@ -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>): number;
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
  *
@@ -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
- const parsed = parseInt(contentLength, 10);
30
- if (isNaN(parsed)) {
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
- if (parsed < 0) {
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 (!contentType) {
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 type = contentType.toLowerCase();
70
- if (type.includes("json")) {
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 (type.includes("x-www-form-urlencoded") || type.includes("multipart")) {
74
- // Check if it's likely an auth form
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.includes("octet-stream")) {
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.includes("xml")) {
84
- return limits.webhook;
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
  *
@@ -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
@@ -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.