@webjsdev/server 0.8.8 → 0.8.10
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/index.js +3 -0
- package/package.json +1 -1
- package/src/actions.js +37 -5
- package/src/api.js +16 -1
- package/src/auth.js +18 -3
- package/src/body-limit.js +291 -0
- package/src/check.js +41 -350
- package/src/context.js +66 -16
- package/src/cors.js +245 -0
- package/src/csp.js +218 -0
- package/src/dev.js +215 -10
- package/src/env-schema.js +250 -0
- package/src/headers.js +213 -0
- package/src/json.js +11 -2
- package/src/node-version.js +102 -0
- package/src/page-action.js +225 -0
- package/src/ssr.js +41 -23
package/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { startServer, createRequestHandler } from './src/dev.js';
|
|
2
|
+
export { assertNodeVersion, checkNodeVersion, requiredNodeMajor, parseMajor, parseRequiredMajor } from './src/node-version.js';
|
|
3
|
+
export { validateEnv, formatEnvErrors, loadEnvSchema, applyEnvValidation } from './src/env-schema.js';
|
|
2
4
|
export { buildRouteTable, matchPage, matchApi } from './src/router.js';
|
|
3
5
|
export { ssrPage, ssrNotFound } from './src/ssr.js';
|
|
4
6
|
export { handleApi } from './src/api.js';
|
|
@@ -35,6 +37,7 @@ export { scanComponents, primeComponentRegistry, extractComponents, findOrphanCo
|
|
|
35
37
|
export { headers, cookies, getRequest, withRequest, cspNonce } from './src/context.js';
|
|
36
38
|
export { defaultLogger } from './src/logger.js';
|
|
37
39
|
export { rateLimit, parseWindow, clientIp, stampRemoteIp } from './src/rate-limit.js';
|
|
40
|
+
export { cors, resolveOrigin, applyCorsHeaders } from './src/cors.js';
|
|
38
41
|
export { memoryStore, redisStore, getStore, setStore } from './src/cache.js';
|
|
39
42
|
export { cache } from './src/cache-fn.js';
|
|
40
43
|
export { Session, session, cookieSessionStorage, storeSessionStorage, cookieSession, storeSession, getSession } from './src/session.js';
|
package/package.json
CHANGED
package/src/actions.js
CHANGED
|
@@ -6,6 +6,20 @@ import { getExposed } from '@webjsdev/core';
|
|
|
6
6
|
import { walk } from './fs-walk.js';
|
|
7
7
|
import { verify as verifyCsrf, CSRF_COOKIE, CSRF_HEADER } from './csrf.js';
|
|
8
8
|
import { getSerializer } from './serializer.js';
|
|
9
|
+
import { resolveOrigin } from './cors.js';
|
|
10
|
+
import { readTextBounded, payloadTooLarge, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
|
|
11
|
+
import { getBodyLimits } from './context.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The JSON / RPC body cap in effect for the current request: the per-request
|
|
15
|
+
* limit the handler stamped, or the secure default outside a request scope (a
|
|
16
|
+
* direct unit-test invocation). `0` disables the cap.
|
|
17
|
+
* @returns {number}
|
|
18
|
+
*/
|
|
19
|
+
function jsonBodyLimit() {
|
|
20
|
+
const limits = getBodyLimits();
|
|
21
|
+
return limits ? limits.json : DEFAULT_MAX_BODY_BYTES;
|
|
22
|
+
}
|
|
9
23
|
|
|
10
24
|
/**
|
|
11
25
|
* Internal RPC wire-format content type. Distinguishes webjs action
|
|
@@ -294,9 +308,13 @@ export async function invokeAction(idx, hash, fnName, req) {
|
|
|
294
308
|
}
|
|
295
309
|
const file = idx.hashToFile.get(hash);
|
|
296
310
|
if (!file) return rpcResponse({ error: 'Unknown action' }, { status: 404 });
|
|
311
|
+
// Bounded read (issue #237): reject an over-limit RPC body with 413 without
|
|
312
|
+
// buffering it whole (Content-Length fast-reject, plus a streaming cap for a
|
|
313
|
+
// chunked body with no declared length).
|
|
314
|
+
const { tooLarge, text: body } = await readTextBounded(req, jsonBodyLimit());
|
|
315
|
+
if (tooLarge) return payloadTooLarge();
|
|
297
316
|
let args = [];
|
|
298
317
|
try {
|
|
299
|
-
const body = await req.text();
|
|
300
318
|
args = body ? getSerializer().deserialize(body) : [];
|
|
301
319
|
if (!Array.isArray(args)) args = [args];
|
|
302
320
|
} catch {
|
|
@@ -388,11 +406,23 @@ export function withCors(resp, route, req) {
|
|
|
388
406
|
return new Response(resp.body, { status: resp.status, statusText: resp.statusText, headers: newHeaders });
|
|
389
407
|
}
|
|
390
408
|
|
|
391
|
-
/**
|
|
409
|
+
/**
|
|
410
|
+
* Match a route's configured origin policy against the request origin,
|
|
411
|
+
* preserving the expose() path's historical contract: `*` returns `true`
|
|
412
|
+
* (literal wildcard), an allowed concrete origin echoes back, and a
|
|
413
|
+
* mismatch returns `null`. Delegates the per-rule decision to the shared
|
|
414
|
+
* cors.js resolver (so RegExp + function policies work here too) but keeps
|
|
415
|
+
* the wildcard-as-`true` and echo-vs-null shape this caller expects.
|
|
416
|
+
*
|
|
417
|
+
* @param {string|string[]|RegExp|((origin: string) => boolean)} configured
|
|
418
|
+
* @param {string} origin
|
|
419
|
+
* @returns {true | string | null}
|
|
420
|
+
*/
|
|
392
421
|
function matchOrigin(configured, origin) {
|
|
393
422
|
if (configured === '*') return true;
|
|
394
|
-
|
|
395
|
-
|
|
423
|
+
const resolved = resolveOrigin(configured, origin, false);
|
|
424
|
+
if (!resolved) return null;
|
|
425
|
+
return resolved.allowOrigin === '*' ? true : resolved.allowOrigin;
|
|
396
426
|
}
|
|
397
427
|
|
|
398
428
|
/**
|
|
@@ -408,7 +438,9 @@ export async function invokeExposedAction(idx, route, params, req) {
|
|
|
408
438
|
const query = Object.fromEntries(url.searchParams.entries());
|
|
409
439
|
let body = {};
|
|
410
440
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
411
|
-
|
|
441
|
+
// Bounded read (issue #237): an over-limit body is a 413 before any parse.
|
|
442
|
+
const { tooLarge, text } = await readTextBounded(req, jsonBodyLimit());
|
|
443
|
+
if (tooLarge) return payloadTooLarge();
|
|
412
444
|
if (text) {
|
|
413
445
|
try {
|
|
414
446
|
const parsed = JSON.parse(text);
|
package/src/api.js
CHANGED
|
@@ -25,7 +25,22 @@ export async function handleApi(route, params, webRequest, dev) {
|
|
|
25
25
|
});
|
|
26
26
|
}
|
|
27
27
|
/** @type any */ (webRequest).params = params;
|
|
28
|
-
|
|
28
|
+
let result;
|
|
29
|
+
try {
|
|
30
|
+
result = await handler(webRequest, { params });
|
|
31
|
+
} catch (e) {
|
|
32
|
+
// A route handler that read its body via `readBody` (json.js) over the
|
|
33
|
+
// size limit (issue #237) throws a BodyLimitError; surface it as 413 rather
|
|
34
|
+
// than a generic 500. Detected via a marker so a cross-module-copy
|
|
35
|
+
// instanceof miss never downgrades it.
|
|
36
|
+
if (e && /** @type any */ (e).webjsBodyLimit) {
|
|
37
|
+
return new Response('Payload Too Large', {
|
|
38
|
+
status: 413,
|
|
39
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
throw e;
|
|
43
|
+
}
|
|
29
44
|
if (result instanceof Response) return result;
|
|
30
45
|
// Convenience: allow returning plain objects as JSON.
|
|
31
46
|
return Response.json(result);
|
package/src/auth.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { getStore } from './cache.js';
|
|
11
|
-
import { getRequest } from './context.js';
|
|
11
|
+
import { getRequest, getBodyLimits } from './context.js';
|
|
12
|
+
import { readTextBounded, readFormDataBounded, payloadTooLarge, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
|
|
12
13
|
|
|
13
14
|
const enc = new TextEncoder();
|
|
14
15
|
const dec = new TextDecoder();
|
|
@@ -409,10 +410,24 @@ export function createAuth(config) {
|
|
|
409
410
|
const provider = providers.get(seg[1]);
|
|
410
411
|
if (!provider) return new Response('Unknown provider', { status: 404 });
|
|
411
412
|
if (provider.type === 'credentials') {
|
|
413
|
+
// Bounded body read (issue #237): the credentials sign-in endpoint is
|
|
414
|
+
// public and unauthenticated, so cap its body to defend against
|
|
415
|
+
// memory exhaustion. Credentials are small fixed-shape JSON / form
|
|
416
|
+
// data, so the JSON / RPC limit applies. An over-limit body is a 413
|
|
417
|
+
// before any parse, and is never buffered whole (see body-limit.js).
|
|
418
|
+
const limits = getBodyLimits();
|
|
419
|
+
const limit = limits ? limits.json : DEFAULT_MAX_BODY_BYTES;
|
|
412
420
|
let body = {};
|
|
413
421
|
const ct = req.headers.get('content-type') || '';
|
|
414
|
-
if (ct.includes('json'))
|
|
415
|
-
|
|
422
|
+
if (ct.includes('json')) {
|
|
423
|
+
const { tooLarge, text } = await readTextBounded(req, limit);
|
|
424
|
+
if (tooLarge) return payloadTooLarge();
|
|
425
|
+
body = text ? JSON.parse(text) : {};
|
|
426
|
+
} else if (ct.includes('form')) {
|
|
427
|
+
const { tooLarge, formData } = await readFormDataBounded(req, limit);
|
|
428
|
+
if (tooLarge) return payloadTooLarge();
|
|
429
|
+
for (const [k, v] of formData.entries()) body[k] = v;
|
|
430
|
+
}
|
|
416
431
|
return signInFn('credentials', body, { req });
|
|
417
432
|
}
|
|
418
433
|
if (provider.type === 'oauth') return oauthRedirect(provider, { req });
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request body-size limits (413) and node:http server timeouts (issue #237).
|
|
3
|
+
*
|
|
4
|
+
* webjs's prod server reads request bodies on three paths: the server-action
|
|
5
|
+
* RPC endpoint (actions.js), `route.{js,ts}` handlers via `readBody` (json.js),
|
|
6
|
+
* and the no-JS page-action form path (page-action.js). Without a cap, an
|
|
7
|
+
* uncapped body is a memory-exhaustion vector. This module is the SINGLE place
|
|
8
|
+
* that decides the limit and performs a bounded read, so every body-read site
|
|
9
|
+
* enforces it uniformly.
|
|
10
|
+
*
|
|
11
|
+
* Two ideas, both web-standard / node:http-native, no library:
|
|
12
|
+
*
|
|
13
|
+
* 1. A bounded read. `readTextBounded` / `readFormDataBounded` reject a body
|
|
14
|
+
* over the configured limit with a 413 WITHOUT buffering the whole thing:
|
|
15
|
+
* a `Content-Length` over the limit is a fast reject (the body is never
|
|
16
|
+
* read), and a chunked/streamed body without `Content-Length` is counted
|
|
17
|
+
* while it streams and abandoned the moment it crosses the limit (so the
|
|
18
|
+
* process never holds more than roughly one chunk past the limit).
|
|
19
|
+
*
|
|
20
|
+
* 2. Server timeouts. `computeServerTimeouts` derives the `requestTimeout`,
|
|
21
|
+
* `headersTimeout`, and `keepAliveTimeout` values applied to the node:http
|
|
22
|
+
* server in `startServer`, with secure production defaults and config / env
|
|
23
|
+
* overrides. node semantics: `headersTimeout` MUST be < `requestTimeout`
|
|
24
|
+
* (node measures the header-receipt deadline from the start of the request,
|
|
25
|
+
* so a headers deadline at or above the whole-request deadline can never
|
|
26
|
+
* fire), so the helper clamps it below `requestTimeout` when a config sets
|
|
27
|
+
* them inconsistently.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Default JSON / RPC body cap: 1 MiB. Generous for an action payload. */
|
|
31
|
+
export const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Default form / multipart body cap: 10 MiB. A form submission (the page-action
|
|
35
|
+
* path) may legitimately carry more than a JSON RPC call (a textarea, a small
|
|
36
|
+
* upload), so it gets a separate, higher, still-bounded limit. Large file
|
|
37
|
+
* uploads are a distinct concern (#247) with their own streaming story.
|
|
38
|
+
*/
|
|
39
|
+
export const DEFAULT_MAX_MULTIPART_BYTES = 10 * 1024 * 1024;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Thrown by `readBody` (json.js) when a route handler's body exceeds the limit.
|
|
43
|
+
* The RPC and page-action paths return a 413 Response inline, but `readBody`
|
|
44
|
+
* runs INSIDE a user route handler and returns parsed data, so it signals the
|
|
45
|
+
* over-limit case by throwing. The API dispatcher (`handleApi`) catches this and
|
|
46
|
+
* maps it to a 413, so a handler that just does `await readBody(req)` gets the
|
|
47
|
+
* correct status with no extra code.
|
|
48
|
+
*/
|
|
49
|
+
export class BodyLimitError extends Error {
|
|
50
|
+
constructor() {
|
|
51
|
+
super('Payload Too Large');
|
|
52
|
+
this.name = 'BodyLimitError';
|
|
53
|
+
/** Marker so `handleApi` can detect it without an instanceof across module copies. */
|
|
54
|
+
this.webjsBodyLimit = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** requestTimeout: time to receive the ENTIRE request (headers + body). 30s. */
|
|
59
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* headersTimeout: time to receive the request headers. Must be < requestTimeout
|
|
63
|
+
* (node measures both from the same request start, so an equal-or-greater value
|
|
64
|
+
* never fires). 20s is comfortably under the 30s whole-request deadline.
|
|
65
|
+
*/
|
|
66
|
+
export const DEFAULT_HEADERS_TIMEOUT_MS = 20_000;
|
|
67
|
+
|
|
68
|
+
/** keepAliveTimeout: idle time before closing a kept-alive socket. 5s. */
|
|
69
|
+
export const DEFAULT_KEEP_ALIVE_TIMEOUT_MS = 5_000;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Read a non-negative integer from an env var, or undefined when unset / blank /
|
|
73
|
+
* not a finite non-negative integer. A value of `0` is honored (it disables the
|
|
74
|
+
* limit / timeout), so the check is on parseability, not truthiness.
|
|
75
|
+
*
|
|
76
|
+
* @param {string | undefined} raw
|
|
77
|
+
* @returns {number | undefined}
|
|
78
|
+
*/
|
|
79
|
+
function envInt(raw) {
|
|
80
|
+
if (raw == null || raw === '') return undefined;
|
|
81
|
+
const n = Number(raw);
|
|
82
|
+
if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) return undefined;
|
|
83
|
+
return n;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Read a non-negative integer from a package.json `webjs.<key>` value, or
|
|
88
|
+
* undefined when absent / not a finite non-negative integer.
|
|
89
|
+
*
|
|
90
|
+
* @param {unknown} pkg parsed package.json (or any object)
|
|
91
|
+
* @param {string} key the `webjs.<key>` field name
|
|
92
|
+
* @returns {number | undefined}
|
|
93
|
+
*/
|
|
94
|
+
function pkgInt(pkg, key) {
|
|
95
|
+
const v =
|
|
96
|
+
pkg && typeof pkg === 'object' && /** @type {any} */ (pkg).webjs
|
|
97
|
+
? /** @type {any} */ (pkg).webjs[key]
|
|
98
|
+
: undefined;
|
|
99
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || !Number.isInteger(v)) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
return v;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resolve the body-size limits. Precedence: env override wins, then the
|
|
107
|
+
* package.json `webjs.maxBodyBytes` / `webjs.maxMultipartBytes` config, then the
|
|
108
|
+
* secure defaults. A value of `0` (from env or config) disables that limit, the
|
|
109
|
+
* deliberate opt-out (e.g. an app fronted by an edge that already caps bodies).
|
|
110
|
+
*
|
|
111
|
+
* WEBJS_MAX_BODY_BYTES -> json / rpc cap
|
|
112
|
+
* WEBJS_MAX_MULTIPART_BYTES -> form / multipart cap
|
|
113
|
+
*
|
|
114
|
+
* @param {unknown} pkg parsed package.json (or any object)
|
|
115
|
+
* @param {{ env?: NodeJS.ProcessEnv }} [opts] injectable env (tests)
|
|
116
|
+
* @returns {{ json: number, multipart: number }} resolved byte limits (0 = off)
|
|
117
|
+
*/
|
|
118
|
+
export function readBodyLimits(pkg, opts = {}) {
|
|
119
|
+
const env = opts.env || process.env;
|
|
120
|
+
const json =
|
|
121
|
+
envInt(env.WEBJS_MAX_BODY_BYTES) ??
|
|
122
|
+
pkgInt(pkg, 'maxBodyBytes') ??
|
|
123
|
+
DEFAULT_MAX_BODY_BYTES;
|
|
124
|
+
const multipart =
|
|
125
|
+
envInt(env.WEBJS_MAX_MULTIPART_BYTES) ??
|
|
126
|
+
pkgInt(pkg, 'maxMultipartBytes') ??
|
|
127
|
+
DEFAULT_MAX_MULTIPART_BYTES;
|
|
128
|
+
return { json, multipart };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolve the node:http server timeouts. Precedence mirrors `readBodyLimits`:
|
|
133
|
+
* env override, then package.json `webjs.requestTimeoutMs` /
|
|
134
|
+
* `webjs.headersTimeoutMs` / `webjs.keepAliveTimeoutMs`, then the defaults. A
|
|
135
|
+
* value of `0` disables that timeout (node's own "no limit" sentinel).
|
|
136
|
+
*
|
|
137
|
+
* WEBJS_REQUEST_TIMEOUT_MS
|
|
138
|
+
* WEBJS_HEADERS_TIMEOUT_MS
|
|
139
|
+
* WEBJS_KEEP_ALIVE_TIMEOUT_MS
|
|
140
|
+
*
|
|
141
|
+
* node semantics enforced here: `headersTimeout` MUST be strictly less than
|
|
142
|
+
* `requestTimeout` to fire (both deadlines run from the same request start). So
|
|
143
|
+
* when a non-zero `headersTimeout` is >= a non-zero `requestTimeout`, clamp it
|
|
144
|
+
* to just under `requestTimeout` rather than silently shipping a dead timeout.
|
|
145
|
+
*
|
|
146
|
+
* @param {unknown} pkg parsed package.json (or any object)
|
|
147
|
+
* @param {{ env?: NodeJS.ProcessEnv }} [opts] injectable env (tests)
|
|
148
|
+
* @returns {{ requestTimeout: number, headersTimeout: number, keepAliveTimeout: number }}
|
|
149
|
+
*/
|
|
150
|
+
export function computeServerTimeouts(pkg, opts = {}) {
|
|
151
|
+
const env = opts.env || process.env;
|
|
152
|
+
const requestTimeout =
|
|
153
|
+
envInt(env.WEBJS_REQUEST_TIMEOUT_MS) ??
|
|
154
|
+
pkgInt(pkg, 'requestTimeoutMs') ??
|
|
155
|
+
DEFAULT_REQUEST_TIMEOUT_MS;
|
|
156
|
+
let headersTimeout =
|
|
157
|
+
envInt(env.WEBJS_HEADERS_TIMEOUT_MS) ??
|
|
158
|
+
pkgInt(pkg, 'headersTimeoutMs') ??
|
|
159
|
+
DEFAULT_HEADERS_TIMEOUT_MS;
|
|
160
|
+
const keepAliveTimeout =
|
|
161
|
+
envInt(env.WEBJS_KEEP_ALIVE_TIMEOUT_MS) ??
|
|
162
|
+
pkgInt(pkg, 'keepAliveTimeoutMs') ??
|
|
163
|
+
DEFAULT_KEEP_ALIVE_TIMEOUT_MS;
|
|
164
|
+
// Keep headersTimeout strictly under requestTimeout so it can actually fire.
|
|
165
|
+
// Both are measured from the same request start; a headers deadline at or
|
|
166
|
+
// above the whole-request deadline is dead. Skip when either is 0 (disabled).
|
|
167
|
+
if (requestTimeout > 0 && headersTimeout >= requestTimeout) {
|
|
168
|
+
headersTimeout = Math.max(1, requestTimeout - 1000);
|
|
169
|
+
}
|
|
170
|
+
return { requestTimeout, headersTimeout, keepAliveTimeout };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A 413 Payload Too Large response, returned by every body-read site when the
|
|
175
|
+
* bounded read trips the limit. Tiny plain-text body so it stays content-type
|
|
176
|
+
* agnostic; the caller never needs to vary it.
|
|
177
|
+
*
|
|
178
|
+
* @returns {Response}
|
|
179
|
+
*/
|
|
180
|
+
export function payloadTooLarge() {
|
|
181
|
+
return new Response('Payload Too Large', {
|
|
182
|
+
status: 413,
|
|
183
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Read a request body as bytes, bounded by `limit`. The single funnel both text
|
|
189
|
+
* and FormData readers go through.
|
|
190
|
+
*
|
|
191
|
+
* - `limit <= 0` disables the cap (read the whole body).
|
|
192
|
+
* - A `Content-Length` header over the limit is a FAST REJECT: the body is
|
|
193
|
+
* never touched, so an attacker-declared huge upload costs nothing.
|
|
194
|
+
* - Otherwise the body stream is read chunk by chunk and the running total is
|
|
195
|
+
* checked AFTER each chunk. The moment it crosses the limit the read is
|
|
196
|
+
* abandoned (the stream reader is cancelled) and `tooLarge` is returned, so
|
|
197
|
+
* a chunked body with no `Content-Length` can never buffer more than the
|
|
198
|
+
* bytes already read (roughly limit + one chunk), not the full payload.
|
|
199
|
+
*
|
|
200
|
+
* @param {Request} req
|
|
201
|
+
* @param {number} limit max bytes (0 / negative = unlimited)
|
|
202
|
+
* @returns {Promise<{ tooLarge: boolean, bytes: Uint8Array | null }>}
|
|
203
|
+
*/
|
|
204
|
+
export async function readBytesBounded(req, limit) {
|
|
205
|
+
// Fast reject on a declared Content-Length over the limit: never read a byte.
|
|
206
|
+
if (limit > 0) {
|
|
207
|
+
const cl = req.headers.get('content-length');
|
|
208
|
+
if (cl != null) {
|
|
209
|
+
const declared = Number(cl);
|
|
210
|
+
if (Number.isFinite(declared) && declared > limit) {
|
|
211
|
+
return { tooLarge: true, bytes: null };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const body = req.body;
|
|
217
|
+
if (!body) return { tooLarge: false, bytes: new Uint8Array(0) };
|
|
218
|
+
|
|
219
|
+
const reader = body.getReader();
|
|
220
|
+
/** @type {Uint8Array[]} */
|
|
221
|
+
const chunks = [];
|
|
222
|
+
let total = 0;
|
|
223
|
+
try {
|
|
224
|
+
for (;;) {
|
|
225
|
+
const { done, value } = await reader.read();
|
|
226
|
+
if (done) break;
|
|
227
|
+
if (!value) continue;
|
|
228
|
+
total += value.byteLength;
|
|
229
|
+
// Enforce WHILE reading so a no-Content-Length stream can't buffer past
|
|
230
|
+
// the limit: bail the instant the running total crosses it.
|
|
231
|
+
if (limit > 0 && total > limit) {
|
|
232
|
+
// Stop pulling more bytes; release the upstream so the socket can close.
|
|
233
|
+
try { await reader.cancel(); } catch { /* already closed */ }
|
|
234
|
+
return { tooLarge: true, bytes: null };
|
|
235
|
+
}
|
|
236
|
+
chunks.push(value);
|
|
237
|
+
}
|
|
238
|
+
} finally {
|
|
239
|
+
try { reader.releaseLock(); } catch { /* reader already released */ }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Concatenate the collected chunks into one buffer.
|
|
243
|
+
const out = new Uint8Array(total);
|
|
244
|
+
let offset = 0;
|
|
245
|
+
for (const c of chunks) {
|
|
246
|
+
out.set(c, offset);
|
|
247
|
+
offset += c.byteLength;
|
|
248
|
+
}
|
|
249
|
+
return { tooLarge: false, bytes: out };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Read a request body as text, bounded by `limit`. Used by the RPC endpoint,
|
|
254
|
+
* `readBody`, and the exposed-action REST path, all of which then parse the
|
|
255
|
+
* text (webjs wire or JSON).
|
|
256
|
+
*
|
|
257
|
+
* @param {Request} req
|
|
258
|
+
* @param {number} limit max bytes (0 / negative = unlimited)
|
|
259
|
+
* @returns {Promise<{ tooLarge: boolean, text: string }>}
|
|
260
|
+
*/
|
|
261
|
+
export async function readTextBounded(req, limit) {
|
|
262
|
+
const { tooLarge, bytes } = await readBytesBounded(req, limit);
|
|
263
|
+
if (tooLarge) return { tooLarge: true, text: '' };
|
|
264
|
+
const text = bytes && bytes.byteLength ? new TextDecoder().decode(bytes) : '';
|
|
265
|
+
return { tooLarge: false, text };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Read a request body as `FormData`, bounded by `limit`. Used by the page-action
|
|
270
|
+
* form path. Reconstructs a bounded Request from the already-read bytes and
|
|
271
|
+
* defers to the platform `formData()` parser, so multipart and
|
|
272
|
+
* urlencoded bodies are decoded exactly as before, just size-checked first.
|
|
273
|
+
*
|
|
274
|
+
* @param {Request} req
|
|
275
|
+
* @param {number} limit max bytes (0 / negative = unlimited)
|
|
276
|
+
* @returns {Promise<{ tooLarge: boolean, formData: FormData | null }>}
|
|
277
|
+
*/
|
|
278
|
+
export async function readFormDataBounded(req, limit) {
|
|
279
|
+
const { tooLarge, bytes } = await readBytesBounded(req, limit);
|
|
280
|
+
if (tooLarge) return { tooLarge: true, formData: null };
|
|
281
|
+
const ct = req.headers.get('content-type') || '';
|
|
282
|
+
// Hand the bounded bytes back to a fresh Request so its standard formData()
|
|
283
|
+
// parser (multipart boundary handling, urlencoded decoding) runs unchanged.
|
|
284
|
+
const bounded = new Request(req.url, {
|
|
285
|
+
method: 'POST',
|
|
286
|
+
headers: ct ? { 'content-type': ct } : undefined,
|
|
287
|
+
body: bytes && bytes.byteLength ? bytes : undefined,
|
|
288
|
+
});
|
|
289
|
+
const formData = await bounded.formData();
|
|
290
|
+
return { tooLarge: false, formData };
|
|
291
|
+
}
|