@daloyjs/core 1.0.0-beta.7 → 1.0.0-rc.1
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 +7 -5
- package/dist/adapters/node.js +216 -11
- package/dist/app.d.ts +32 -8
- package/dist/app.js +418 -67
- package/dist/http-signatures.js +44 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/ip-reputation.d.ts +8 -2
- package/dist/ip-reputation.js +7 -1
- package/dist/jwk.js +6 -1
- package/dist/mcp.d.ts +80 -9
- package/dist/mcp.js +206 -4
- package/dist/middleware.d.ts +38 -0
- package/dist/middleware.js +45 -4
- package/dist/mtls.js +6 -1
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/package.json +1 -1
package/dist/app.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { Router } from "./router.js";
|
|
2
2
|
import { WebSocketRegistry, normalizeWebSocketOptions, } from "./websocket.js";
|
|
3
3
|
import { BadRequestError, ForbiddenError, HttpError, InternalError, MethodNotAllowedError, NotFoundError, PayloadTooLargeError, RequestTimeoutError, TooManyRequestsError, UnsupportedMediaTypeError, ValidationError, } from "./errors.js";
|
|
4
|
-
import { validate } from "./schema.js";
|
|
5
4
|
import { readBodyLimited, safeJsonParse, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey, } from "./security.js";
|
|
6
5
|
import { createLogger, noopLogger } from "./logger.js";
|
|
7
6
|
import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
|
|
8
7
|
import { asyncapiHtml, docsContentSecurityPolicy, redocHtml, scalarHtml, swaggerUiHtml, } from "./docs.js";
|
|
9
8
|
import { generateAsyncAPI, asyncapiToYAML, } from "./asyncapi.js";
|
|
10
|
-
import { secureHeaders as secureHeadersMiddleware, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, CSRF_HOOK_MARKER, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, SECURE_HEADERS_MARKER, } from "./middleware.js";
|
|
9
|
+
import { secureHeaders as secureHeadersMiddleware, AUTH_HOOK_MARKER, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, CSRF_HOOK_MARKER, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, SECURE_HEADERS_MARKER, } from "./middleware.js";
|
|
11
10
|
import { COMPRESSION_HOOK_MARKER } from "./compression.js";
|
|
12
11
|
import { SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER } from "./session.js";
|
|
13
12
|
import { loadShedding as loadSheddingMiddleware, } from "./load-shedding.js";
|
|
@@ -119,6 +118,13 @@ const CANONICAL_HTTP_METHODS = new Set([
|
|
|
119
118
|
"HEAD",
|
|
120
119
|
"OPTIONS",
|
|
121
120
|
]);
|
|
121
|
+
/**
|
|
122
|
+
* Global-registry symbol stamped by `mcpRoutes()` on the route definitions it
|
|
123
|
+
* produces (unless the caller opts out with `public: true`). Read here — rather
|
|
124
|
+
* than importing from `mcp.ts` — so the MCP module is never pulled into the core
|
|
125
|
+
* `App` bundle. Must match the string used in `mcpRoutes`.
|
|
126
|
+
*/
|
|
127
|
+
const MCP_ROUTE_MARKER = Symbol.for("daloyjs.mcp.route");
|
|
122
128
|
/**
|
|
123
129
|
* Apply a topology-aware security preset on top of caller-supplied
|
|
124
130
|
* options. Returns a new options object where preset defaults fill in
|
|
@@ -194,6 +200,99 @@ export const DALOY_REQUEST_RAW_BODY = Symbol.for("daloyjs.request.rawBody");
|
|
|
194
200
|
* opt in; userland code should not depend on it.
|
|
195
201
|
*/
|
|
196
202
|
export const DALOY_RAW_STREAM = Symbol.for("daloyjs.response.rawStream");
|
|
203
|
+
/**
|
|
204
|
+
* Internal Symbol an adapter sets (once, on its request shim's prototype) to
|
|
205
|
+
* declare: "the object that ultimately consumes this request's `Response`
|
|
206
|
+
* only reads `status` / `headers` / {@link DALOY_RAW_BODY} — it never needs a
|
|
207
|
+
* branded WHATWG `Response`". When present on the incoming request,
|
|
208
|
+
* {@link serializeResult} may return a {@link LightResponse} and skip the
|
|
209
|
+
* ~2µs undici `Response` construction per request. Requests without the
|
|
210
|
+
* marker (Bun / Deno / Workers adapters, tests, direct `app.fetch()` callers)
|
|
211
|
+
* always get a real `Response`, so the public contract is unchanged.
|
|
212
|
+
*/
|
|
213
|
+
export const DALOY_LIGHT_RESPONSE_OK = Symbol.for("daloyjs.response.lightOk");
|
|
214
|
+
/**
|
|
215
|
+
* Minimal `Response` stand-in returned on the Node-adapter hot path (gated by
|
|
216
|
+
* {@link DALOY_LIGHT_RESPONSE_OK}). Carries `status` + a real `Headers`
|
|
217
|
+
* instance + the raw body bytes via {@link DALOY_RAW_BODY}; every other
|
|
218
|
+
* WHATWG surface (body streams, `json()`, `clone()`, …) delegates to a
|
|
219
|
+
* lazily-materialized real `Response`, so hook code that inspects the
|
|
220
|
+
* response body still behaves exactly as before — it just pays the
|
|
221
|
+
* construction cost only when it actually does so. `instanceof Response`
|
|
222
|
+
* holds via prototype re-rooting; all overridden accessors below shadow
|
|
223
|
+
* undici's brand-checked ones.
|
|
224
|
+
*/
|
|
225
|
+
class LightResponse {
|
|
226
|
+
#status;
|
|
227
|
+
#headers;
|
|
228
|
+
#rawBody;
|
|
229
|
+
#real;
|
|
230
|
+
constructor(status, headers, rawBody) {
|
|
231
|
+
this.#status = status;
|
|
232
|
+
this.#headers = headers;
|
|
233
|
+
this.#rawBody = rawBody;
|
|
234
|
+
this[DALOY_RAW_BODY] = rawBody;
|
|
235
|
+
}
|
|
236
|
+
/** Build (once) and return an equivalent real `Response` for rare surfaces. */
|
|
237
|
+
#materialize() {
|
|
238
|
+
return (this.#real ??= new Response(this.#rawBody, {
|
|
239
|
+
status: this.#status,
|
|
240
|
+
headers: this.#headers,
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
get status() {
|
|
244
|
+
return this.#status;
|
|
245
|
+
}
|
|
246
|
+
get headers() {
|
|
247
|
+
return this.#headers;
|
|
248
|
+
}
|
|
249
|
+
get ok() {
|
|
250
|
+
return this.#status >= 200 && this.#status <= 299;
|
|
251
|
+
}
|
|
252
|
+
// Spec constants for a synthesized (non-network) Response.
|
|
253
|
+
get statusText() {
|
|
254
|
+
return "";
|
|
255
|
+
}
|
|
256
|
+
get type() {
|
|
257
|
+
return "default";
|
|
258
|
+
}
|
|
259
|
+
get url() {
|
|
260
|
+
return "";
|
|
261
|
+
}
|
|
262
|
+
get redirected() {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
get body() {
|
|
266
|
+
return this.#materialize().body;
|
|
267
|
+
}
|
|
268
|
+
get bodyUsed() {
|
|
269
|
+
return this.#real !== undefined ? this.#real.bodyUsed : false;
|
|
270
|
+
}
|
|
271
|
+
arrayBuffer() {
|
|
272
|
+
return this.#materialize().arrayBuffer();
|
|
273
|
+
}
|
|
274
|
+
blob() {
|
|
275
|
+
return this.#materialize().blob();
|
|
276
|
+
}
|
|
277
|
+
bytes() {
|
|
278
|
+
return this.#materialize().bytes();
|
|
279
|
+
}
|
|
280
|
+
formData() {
|
|
281
|
+
return this.#materialize().formData();
|
|
282
|
+
}
|
|
283
|
+
json() {
|
|
284
|
+
return this.#materialize().json();
|
|
285
|
+
}
|
|
286
|
+
text() {
|
|
287
|
+
return this.#materialize().text();
|
|
288
|
+
}
|
|
289
|
+
clone() {
|
|
290
|
+
return this.#materialize().clone();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// `instanceof Response` must hold for hook code and adapter checks. Every
|
|
294
|
+
// own getter/method above shadows undici's brand-checked accessors.
|
|
295
|
+
Object.setPrototypeOf(LightResponse.prototype, Response.prototype);
|
|
197
296
|
/**
|
|
198
297
|
* The DaloyJS application: a contract-first router plus a web-standard
|
|
199
298
|
* `fetch(Request): Promise<Response>` handler that runs unchanged on Node,
|
|
@@ -614,6 +713,26 @@ export class App {
|
|
|
614
713
|
const origin = request.headers.get("origin");
|
|
615
714
|
if (!origin || origin === "null")
|
|
616
715
|
return;
|
|
716
|
+
// Fast path: when both the Origin header and the request URL are in the
|
|
717
|
+
// trivially-normalized shape (lowercase ASCII scheme://host[:port] with
|
|
718
|
+
// no userinfo / percent-escapes / IPv6 brackets), their origins can be
|
|
719
|
+
// compared as plain strings without two `new URL()` constructions per
|
|
720
|
+
// request. Anything unusual returns `undefined` and falls back to the
|
|
721
|
+
// exact WHATWG comparison below — the guard's accept/reject semantics
|
|
722
|
+
// are identical on both paths.
|
|
723
|
+
const fastHeaderOrigin = getOriginFast(origin);
|
|
724
|
+
if (fastHeaderOrigin !== undefined) {
|
|
725
|
+
const fastReqOrigin = typeof requestUrl === "string" ? getOriginFast(requestUrl) : requestUrl.origin;
|
|
726
|
+
if (fastReqOrigin !== undefined) {
|
|
727
|
+
if (fastHeaderOrigin === fastReqOrigin)
|
|
728
|
+
return;
|
|
729
|
+
if (corsOriginAllows.some((allows) => allows(origin)))
|
|
730
|
+
return;
|
|
731
|
+
throw new ForbiddenError(`Cross-origin ${method} from "${fastHeaderOrigin}" rejected: no registered cors() policy allows that origin. ` +
|
|
732
|
+
`Register cors({ origin: [...] }) via app.use(...) to allow it, or pass ` +
|
|
733
|
+
`app({ corsCrossOriginGuard: false }) / app({ secureDefaults: false }) to disable this guard.`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
617
736
|
let originUrl;
|
|
618
737
|
try {
|
|
619
738
|
originUrl = new URL(origin);
|
|
@@ -794,13 +913,24 @@ export class App {
|
|
|
794
913
|
/**
|
|
795
914
|
* First-request boot guard. Verifies that the assembled hook
|
|
796
915
|
* chain + route table is internally consistent before any user handler
|
|
797
|
-
* runs. Currently checks
|
|
798
|
-
*
|
|
799
|
-
* a `
|
|
800
|
-
*
|
|
801
|
-
* hook
|
|
802
|
-
*
|
|
803
|
-
*
|
|
916
|
+
* runs. Currently checks (production + `secureDefaults` only):
|
|
917
|
+
*
|
|
918
|
+
* 1. **Shadow auth** — a route that declares an `auth:` requirement (so it is
|
|
919
|
+
* advertised as protected in the OpenAPI `security` list) must have an
|
|
920
|
+
* authentication hook ({@link AUTH_HOOK_MARKER}) in its effective chain.
|
|
921
|
+
* Otherwise it accepts unauthenticated requests while claiming protection.
|
|
922
|
+
* 2. **Unauthenticated MCP** — a route from {@link mcpRoutes} must have an
|
|
923
|
+
* auth hook unless it opted out with `mcpRoutes(path, handler, { public: true })`.
|
|
924
|
+
* MCP tools are model-controlled and side-effecting, so a public one is a
|
|
925
|
+
* high-impact default.
|
|
926
|
+
* 3. **Missing CSRF** — when `session()` is installed and any route accepts a
|
|
927
|
+
* state-changing method (`POST`/`PUT`/`PATCH`/`DELETE`), a `csrf()` hook
|
|
928
|
+
* (or third-party equivalent stamped with {@link CSRF_HOOK_MARKER}) must
|
|
929
|
+
* also be present. Skipped when `app({ csrf: "off" })`.
|
|
930
|
+
*
|
|
931
|
+
* Opt out of all guards with `app({ secureDefaults: false })`. Runs once per
|
|
932
|
+
* App between registration changes; the result is cached so the fast path is a
|
|
933
|
+
* single boolean check.
|
|
804
934
|
*/
|
|
805
935
|
assertBootGuards() {
|
|
806
936
|
if (this.bootGuard.checked) {
|
|
@@ -811,13 +941,40 @@ export class App {
|
|
|
811
941
|
this.bootGuard.checked = true;
|
|
812
942
|
if (this.options.secureDefaults === false)
|
|
813
943
|
return;
|
|
814
|
-
if (this.options.csrf === "off")
|
|
815
|
-
return;
|
|
816
944
|
// Per the risk register: boot guards only fire in production so CI /
|
|
817
945
|
// staging surfaces that ship sample secrets / no CSRF token while
|
|
818
946
|
// iterating do not pay the refuse-to-boot cost.
|
|
819
947
|
if (!this.isProduction())
|
|
820
948
|
return;
|
|
949
|
+
// Guard 1: shadow auth — declared `auth:` with nothing enforcing it.
|
|
950
|
+
const shadowAuth = this.routeSecurityMarkers.find((r) => r.declaresAuth && !r.hasAuth);
|
|
951
|
+
if (shadowAuth) {
|
|
952
|
+
const err = new Error(`Route ${shadowAuth.method} ${shadowAuth.path} declares an auth requirement (auth: ...) ` +
|
|
953
|
+
`but no authentication hook is installed in its effective hook chain, so it is advertised ` +
|
|
954
|
+
`as protected while accepting unauthenticated requests. ` +
|
|
955
|
+
`Install an auth middleware (bearerAuth/basicAuth/jwk/httpSignatureAuth/clientCertAuth), ` +
|
|
956
|
+
`wrap a custom auth hook with markAuthHook(...), remove the route's auth: declaration, ` +
|
|
957
|
+
`or pass app({ secureDefaults: false }) to disable this guard. ` +
|
|
958
|
+
`See https://daloyjs.dev/docs/security/boot-guards.`);
|
|
959
|
+
this.bootGuard.error = err;
|
|
960
|
+
throw err;
|
|
961
|
+
}
|
|
962
|
+
// Guard 2: unauthenticated MCP tool endpoint.
|
|
963
|
+
const mcpNoAuth = this.routeSecurityMarkers.find((r) => r.isMcp && !r.hasAuth);
|
|
964
|
+
if (mcpNoAuth) {
|
|
965
|
+
const err = new Error(`MCP route ${mcpNoAuth.method} ${mcpNoAuth.path} (from mcpRoutes()) has no authentication ` +
|
|
966
|
+
`hook in its effective hook chain. MCP tools are model-controlled and can trigger side ` +
|
|
967
|
+
`effects, so an unauthenticated endpoint is a high-impact default. ` +
|
|
968
|
+
`Install an auth middleware covering the MCP route (e.g. app.use(bearerAuth({ ... }))), ` +
|
|
969
|
+
`wrap a custom auth hook with markAuthHook(...), pass mcpRoutes(path, handler, { public: true }) ` +
|
|
970
|
+
`to intentionally expose it, or pass app({ secureDefaults: false }) to disable this guard. ` +
|
|
971
|
+
`See https://daloyjs.dev/docs/security/boot-guards.`);
|
|
972
|
+
this.bootGuard.error = err;
|
|
973
|
+
throw err;
|
|
974
|
+
}
|
|
975
|
+
// Guard 3: session() + state-changing route without csrf().
|
|
976
|
+
if (this.options.csrf === "off")
|
|
977
|
+
return;
|
|
821
978
|
const stateChanging = this.routeSecurityMarkers.find((r) => isStateChangingMethod(r.method) && r.hasSession && !r.hasCsrf);
|
|
822
979
|
if (!stateChanging)
|
|
823
980
|
return;
|
|
@@ -834,7 +991,9 @@ export class App {
|
|
|
834
991
|
/**
|
|
835
992
|
* Per-request guard for spoofed proxy headers. When the App was
|
|
836
993
|
* constructed without an explicit {@link AppOptions.trustProxy} value
|
|
837
|
-
* and a request arrives carrying
|
|
994
|
+
* and a request arrives carrying a spoofable forwarded / client-IP header
|
|
995
|
+
* (`X-Forwarded-*`, `X-Real-IP`, or a vendor header like `CF-Connecting-IP`,
|
|
996
|
+
* `Fly-Client-IP`, `True-Client-IP`), refuse to
|
|
838
997
|
* dispatch it: the rate limiter, audit log, and request-id propagation
|
|
839
998
|
* would otherwise honour the attacker-supplied IP. Returns a structured
|
|
840
999
|
* `500 problem+json` so the failure is loud at the network boundary.
|
|
@@ -864,6 +1023,13 @@ export class App {
|
|
|
864
1023
|
"x-forwarded-proto",
|
|
865
1024
|
"x-forwarded-port",
|
|
866
1025
|
"x-real-ip",
|
|
1026
|
+
// Platform-specific client-IP headers are just as spoofable as
|
|
1027
|
+
// X-Forwarded-* when the app is not actually behind that platform's
|
|
1028
|
+
// proxy. Refuse them too so a client cannot forge its source IP via a
|
|
1029
|
+
// vendor header the operator never configured trust for.
|
|
1030
|
+
"cf-connecting-ip",
|
|
1031
|
+
"fly-client-ip",
|
|
1032
|
+
"true-client-ip",
|
|
867
1033
|
]) {
|
|
868
1034
|
if (headers.has(name)) {
|
|
869
1035
|
found = name;
|
|
@@ -1276,6 +1442,8 @@ export class App {
|
|
|
1276
1442
|
method: merged.method,
|
|
1277
1443
|
path: merged.path,
|
|
1278
1444
|
...securityMarkers,
|
|
1445
|
+
declaresAuth: merged.auth !== undefined && merged.auth !== null,
|
|
1446
|
+
isMcp: merged[MCP_ROUTE_MARKER] === true,
|
|
1279
1447
|
});
|
|
1280
1448
|
this.resetBootGuardCache();
|
|
1281
1449
|
return this;
|
|
@@ -2287,7 +2455,11 @@ export class App {
|
|
|
2287
2455
|
if (isPromiseLike(routeOnRequestResult))
|
|
2288
2456
|
await routeOnRequestResult;
|
|
2289
2457
|
}
|
|
2290
|
-
|
|
2458
|
+
// buildContext is sync unless a schema validator or body read actually
|
|
2459
|
+
// suspends — branch on the promise so the fully-sync case never
|
|
2460
|
+
// schedules a microtask.
|
|
2461
|
+
const builtCtx = buildContext(request, getUrl, match.params, def, this.options);
|
|
2462
|
+
ctx = isPromiseLike(builtCtx) ? await builtCtx : builtCtx;
|
|
2291
2463
|
// Stable two-field write keeps `ctx.state`'s hidden class consistent across
|
|
2292
2464
|
// requests for the common no-decorator case. The decorations spread only
|
|
2293
2465
|
// fires when `app.decorate()` was actually called.
|
|
@@ -2358,7 +2530,11 @@ export class App {
|
|
|
2358
2530
|
}
|
|
2359
2531
|
return finalizedRaw;
|
|
2360
2532
|
}
|
|
2361
|
-
const serializeResultRes = serializeResult(result, def, this.options.validateResponses ?? true
|
|
2533
|
+
const serializeResultRes = serializeResult(result, def, this.options.validateResponses ?? true,
|
|
2534
|
+
// Adapter shims set this marker on their request prototype to declare
|
|
2535
|
+
// that the response consumer only reads status/headers/raw-body — see
|
|
2536
|
+
// DALOY_LIGHT_RESPONSE_OK. Everyone else gets a real Response.
|
|
2537
|
+
request[DALOY_LIGHT_RESPONSE_OK] === true);
|
|
2362
2538
|
let response = isPromiseLike(serializeResultRes)
|
|
2363
2539
|
? await serializeResultRes
|
|
2364
2540
|
: serializeResultRes;
|
|
@@ -2767,14 +2943,17 @@ export function topoSortExtensions(exts) {
|
|
|
2767
2943
|
function securityMarkersFromHooks(layers) {
|
|
2768
2944
|
let hasSession = false;
|
|
2769
2945
|
let hasCsrf = false;
|
|
2946
|
+
let hasAuth = false;
|
|
2770
2947
|
for (const hooks of layers) {
|
|
2771
2948
|
const record = hooks;
|
|
2772
2949
|
if (record[SESSION_HOOK_MARKER] === true)
|
|
2773
2950
|
hasSession = true;
|
|
2774
2951
|
if (record[CSRF_HOOK_MARKER] === true)
|
|
2775
2952
|
hasCsrf = true;
|
|
2953
|
+
if (record[AUTH_HOOK_MARKER] === true)
|
|
2954
|
+
hasAuth = true;
|
|
2776
2955
|
}
|
|
2777
|
-
return { hasSession, hasCsrf };
|
|
2956
|
+
return { hasSession, hasCsrf, hasAuth };
|
|
2778
2957
|
}
|
|
2779
2958
|
function isStateChangingMethod(method) {
|
|
2780
2959
|
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
|
|
@@ -2807,6 +2986,85 @@ function getPathnameFast(url) {
|
|
|
2807
2986
|
end = h;
|
|
2808
2987
|
return url.slice(pathStart, end);
|
|
2809
2988
|
}
|
|
2989
|
+
/**
|
|
2990
|
+
* Extract the WHATWG origin (`scheme://host[:port]`) from an absolute
|
|
2991
|
+
* `http`/`https` URL or Origin-header value without constructing a `URL`.
|
|
2992
|
+
* Companion to {@link getPathnameFast}, used by the cross-origin guard on
|
|
2993
|
+
* state-changing requests.
|
|
2994
|
+
*
|
|
2995
|
+
* Returns `undefined` — signalling "fall back to `new URL(...).origin`" —
|
|
2996
|
+
* whenever the input is not in the trivially-normalized shape a `URL` parse
|
|
2997
|
+
* would return unchanged: non-`http(s)` schemes, uppercase characters
|
|
2998
|
+
* (scheme/host case-folding), userinfo (`user@host`, which `URL.origin`
|
|
2999
|
+
* strips), percent-escapes, non-ASCII hosts (IDNA/punycode), IPv6 literals
|
|
3000
|
+
* (zero-compression normalization), empty hosts, backslashes (URL treats
|
|
3001
|
+
* `\` as `/`), and explicit default ports (`:80` / `:443`, which
|
|
3002
|
+
* `URL.origin` elides). The fast path therefore never *disagrees* with the
|
|
3003
|
+
* WHATWG origin — it only answers when the answer is unambiguous.
|
|
3004
|
+
*/
|
|
3005
|
+
function getOriginFast(url) {
|
|
3006
|
+
let hostStart;
|
|
3007
|
+
if (url.startsWith("http://"))
|
|
3008
|
+
hostStart = 7;
|
|
3009
|
+
else if (url.startsWith("https://"))
|
|
3010
|
+
hostStart = 8;
|
|
3011
|
+
else
|
|
3012
|
+
return undefined;
|
|
3013
|
+
// Find the end of the authority: first "/", "?", or "#" after the scheme.
|
|
3014
|
+
let end = url.length;
|
|
3015
|
+
for (let i = hostStart; i < url.length; i++) {
|
|
3016
|
+
const c = url.charCodeAt(i);
|
|
3017
|
+
if (c === 47 /* / */ || c === 63 /* ? */ || c === 35 /* # */) {
|
|
3018
|
+
end = i;
|
|
3019
|
+
break;
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
if (end === hostStart)
|
|
3023
|
+
return undefined; // empty host
|
|
3024
|
+
for (let i = hostStart; i < end; i++) {
|
|
3025
|
+
const c = url.charCodeAt(i);
|
|
3026
|
+
// Reject anything that could normalize differently under a real URL
|
|
3027
|
+
// parse: uppercase A-Z, userinfo "@", percent "%", IPv6 "[", backslash
|
|
3028
|
+
// "\", raw whitespace/controls, and all non-ASCII.
|
|
3029
|
+
if ((c >= 65 && c <= 90) /* A-Z */ ||
|
|
3030
|
+
c === 64 /* @ */ ||
|
|
3031
|
+
c === 37 /* % */ ||
|
|
3032
|
+
c === 91 /* [ */ ||
|
|
3033
|
+
c === 92 /* \ */ ||
|
|
3034
|
+
c <= 32 /* controls + space */ ||
|
|
3035
|
+
c >= 127 /* DEL + non-ASCII */) {
|
|
3036
|
+
return undefined;
|
|
3037
|
+
}
|
|
3038
|
+
}
|
|
3039
|
+
const authority = url.slice(hostStart, end);
|
|
3040
|
+
const colon = authority.indexOf(":");
|
|
3041
|
+
if (colon !== -1) {
|
|
3042
|
+
const port = authority.slice(colon + 1);
|
|
3043
|
+
// Trailing ":" alone, an empty port, or a default port all normalize to
|
|
3044
|
+
// no port under URL — fall back rather than replicate that here. A
|
|
3045
|
+
// second ":" (malformed / IPv6-ish) also falls back.
|
|
3046
|
+
if (port.length === 0 || port.indexOf(":") !== -1)
|
|
3047
|
+
return undefined;
|
|
3048
|
+
if ((hostStart === 7 && port === "80") /* http default */ ||
|
|
3049
|
+
(hostStart === 8 && port === "443") /* https default */) {
|
|
3050
|
+
return undefined;
|
|
3051
|
+
}
|
|
3052
|
+
// Port must be all digits; anything else is not trivially normalized.
|
|
3053
|
+
for (let i = 0; i < port.length; i++) {
|
|
3054
|
+
const c = port.charCodeAt(i);
|
|
3055
|
+
if (c < 48 || c > 57)
|
|
3056
|
+
return undefined;
|
|
3057
|
+
}
|
|
3058
|
+
// Leading zeros normalize away under URL ("0080" -> "80"), and ports
|
|
3059
|
+
// above 65535 make the URL constructor *throw* (the guard's malformed-
|
|
3060
|
+
// origin rejection) — both must take the exact WHATWG path.
|
|
3061
|
+
if (port.length > 1 && port.charCodeAt(0) === 48 /* 0 */)
|
|
3062
|
+
return undefined;
|
|
3063
|
+
if (port.length > 5 || (port.length === 5 && Number(port) > 65535))
|
|
3064
|
+
return undefined;
|
|
3065
|
+
}
|
|
3066
|
+
return url.slice(0, end);
|
|
3067
|
+
}
|
|
2810
3068
|
function mergeHooks(layers) {
|
|
2811
3069
|
const pick = (key) => layers.map((h) => h[key]).filter((f) => typeof f === "function");
|
|
2812
3070
|
const requiredScopes = requiredScopesFromHooks(layers);
|
|
@@ -3183,44 +3441,77 @@ function buildContext(request, getUrl, rawParams, def, opts) {
|
|
|
3183
3441
|
if (!hasSchema) {
|
|
3184
3442
|
return finishContext();
|
|
3185
3443
|
}
|
|
3186
|
-
|
|
3187
|
-
if (
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3444
|
+
const applyChecked = (r, part) => {
|
|
3445
|
+
if (r.issues)
|
|
3446
|
+
throw new ValidationError(part, toIssues(r.issues));
|
|
3447
|
+
return r.value;
|
|
3448
|
+
};
|
|
3449
|
+
const validateBodyAndFinish = (raw) => {
|
|
3450
|
+
const r = def.request.body["~standard"].validate(raw);
|
|
3451
|
+
if (isPromiseLike(r)) {
|
|
3452
|
+
return r.then((resolved) => {
|
|
3453
|
+
body = applyChecked(resolved, "body");
|
|
3454
|
+
return finishContext();
|
|
3455
|
+
});
|
|
3192
3456
|
}
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3457
|
+
body = applyChecked(r, "body");
|
|
3458
|
+
return finishContext();
|
|
3459
|
+
};
|
|
3460
|
+
const stepBody = () => {
|
|
3461
|
+
if (!def.request?.body)
|
|
3462
|
+
return finishContext();
|
|
3463
|
+
const ct = (request.headers.get("content-type") ?? "").toLowerCase();
|
|
3464
|
+
const allowed = def.accepts ??
|
|
3465
|
+
opts.allowedContentTypes ?? [
|
|
3466
|
+
"application/json",
|
|
3467
|
+
"application/x-www-form-urlencoded",
|
|
3468
|
+
"multipart/form-data",
|
|
3469
|
+
];
|
|
3470
|
+
if (!allowed.some((a) => ct.includes(a))) {
|
|
3471
|
+
throw new UnsupportedMediaTypeError(ct || "(none)", allowed);
|
|
3198
3472
|
}
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
if (
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
"
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
if (!allowed.some((a) => ct.includes(a))) {
|
|
3214
|
-
throw new UnsupportedMediaTypeError(ct || "(none)", allowed);
|
|
3215
|
-
}
|
|
3216
|
-
const raw = await readBody(request, ct, opts.bodyLimitBytes, opts.multipart);
|
|
3217
|
-
const r = await validate(def.request.body, raw);
|
|
3218
|
-
if (r.issues)
|
|
3219
|
-
throw new ValidationError("body", toIssues(r.issues));
|
|
3220
|
-
body = r.value;
|
|
3473
|
+
const raw = readBody(request, ct, opts.bodyLimitBytes, opts.multipart);
|
|
3474
|
+
if (isPromiseLike(raw))
|
|
3475
|
+
return raw.then(validateBodyAndFinish);
|
|
3476
|
+
return validateBodyAndFinish(raw);
|
|
3477
|
+
};
|
|
3478
|
+
const stepHeaders = () => {
|
|
3479
|
+
if (!hasHeadersSchema)
|
|
3480
|
+
return stepBody();
|
|
3481
|
+
const r = def.request.headers["~standard"].validate(buildHeaders());
|
|
3482
|
+
if (isPromiseLike(r)) {
|
|
3483
|
+
return r.then((resolved) => {
|
|
3484
|
+
headers = applyChecked(resolved, "headers");
|
|
3485
|
+
return stepBody();
|
|
3486
|
+
});
|
|
3221
3487
|
}
|
|
3222
|
-
|
|
3223
|
-
|
|
3488
|
+
headers = applyChecked(r, "headers");
|
|
3489
|
+
return stepBody();
|
|
3490
|
+
};
|
|
3491
|
+
const stepQuery = () => {
|
|
3492
|
+
if (!hasQuerySchema)
|
|
3493
|
+
return stepHeaders();
|
|
3494
|
+
const r = def.request.query["~standard"].validate(buildQuery());
|
|
3495
|
+
if (isPromiseLike(r)) {
|
|
3496
|
+
return r.then((resolved) => {
|
|
3497
|
+
query = applyChecked(resolved, "query");
|
|
3498
|
+
return stepHeaders();
|
|
3499
|
+
});
|
|
3500
|
+
}
|
|
3501
|
+
query = applyChecked(r, "query");
|
|
3502
|
+
return stepHeaders();
|
|
3503
|
+
};
|
|
3504
|
+
if (def.request?.params) {
|
|
3505
|
+
const r = def.request.params["~standard"].validate(rawParams);
|
|
3506
|
+
if (isPromiseLike(r)) {
|
|
3507
|
+
return r.then((resolved) => {
|
|
3508
|
+
params = applyChecked(resolved, "params");
|
|
3509
|
+
return stepQuery();
|
|
3510
|
+
});
|
|
3511
|
+
}
|
|
3512
|
+
params = applyChecked(r, "params");
|
|
3513
|
+
}
|
|
3514
|
+
return stepQuery();
|
|
3224
3515
|
}
|
|
3225
3516
|
function headersToObject(h) {
|
|
3226
3517
|
const o = {};
|
|
@@ -3251,26 +3542,79 @@ function toIssues(issues) {
|
|
|
3251
3542
|
.join("."),
|
|
3252
3543
|
}));
|
|
3253
3544
|
}
|
|
3254
|
-
|
|
3545
|
+
/** Shared decoder for request-body text. Allocating one per request is wasted work. */
|
|
3546
|
+
const TEXT_DECODER = new TextDecoder();
|
|
3547
|
+
/**
|
|
3548
|
+
* Synchronous fast path for {@link readBodyLimited}: returns the adapter's
|
|
3549
|
+
* pre-buffered body bytes when they are available on the request via
|
|
3550
|
+
* {@link DALOY_REQUEST_RAW_BODY}, or `undefined` when the caller must fall
|
|
3551
|
+
* back to the async streaming read. Runs the exact same Content-Length
|
|
3552
|
+
* validation and size-limit checks (in the same order, throwing the same
|
|
3553
|
+
* errors) as `readBodyLimited`, so the security posture is identical — the
|
|
3554
|
+
* only difference is that a symbol-cache hit never touches the microtask
|
|
3555
|
+
* queue.
|
|
3556
|
+
*
|
|
3557
|
+
* @throws {BadRequestError} When `Content-Length` is present but invalid.
|
|
3558
|
+
* @throws {PayloadTooLargeError} When the declared or actual size exceeds `limit`.
|
|
3559
|
+
*/
|
|
3560
|
+
function readBodyBytesFast(req, limit) {
|
|
3561
|
+
const cl = req.headers.get("content-length");
|
|
3562
|
+
if (cl) {
|
|
3563
|
+
const n = Number(cl);
|
|
3564
|
+
if (!Number.isFinite(n) || n < 0)
|
|
3565
|
+
throw new BadRequestError("Invalid Content-Length");
|
|
3566
|
+
if (n > limit)
|
|
3567
|
+
throw new PayloadTooLargeError(limit);
|
|
3568
|
+
}
|
|
3569
|
+
const cached = req[DALOY_REQUEST_RAW_BODY];
|
|
3570
|
+
if (cached instanceof Uint8Array) {
|
|
3571
|
+
if (cached.byteLength > limit)
|
|
3572
|
+
throw new PayloadTooLargeError(limit);
|
|
3573
|
+
return cached;
|
|
3574
|
+
}
|
|
3575
|
+
return undefined;
|
|
3576
|
+
}
|
|
3577
|
+
function parseJsonBodyBytes(bytes) {
|
|
3578
|
+
if (bytes.byteLength === 0)
|
|
3579
|
+
return undefined;
|
|
3580
|
+
return safeJsonParse(TEXT_DECODER.decode(bytes));
|
|
3581
|
+
}
|
|
3582
|
+
function parseUrlencodedBodyBytes(bytes) {
|
|
3583
|
+
const params = new URLSearchParams(TEXT_DECODER.decode(bytes));
|
|
3584
|
+
// Same Spring4Shell-class defense as queryToObject: Object.fromEntries
|
|
3585
|
+
// would set __proto__ / constructor / prototype as own properties.
|
|
3586
|
+
const out = {};
|
|
3587
|
+
for (const [k, v] of params) {
|
|
3588
|
+
if (isForbiddenObjectKey(k))
|
|
3589
|
+
continue;
|
|
3590
|
+
out[k] = v;
|
|
3591
|
+
}
|
|
3592
|
+
return out;
|
|
3593
|
+
}
|
|
3594
|
+
/**
|
|
3595
|
+
* Read and parse a request body according to its content type. Plain
|
|
3596
|
+
* (non-`async`) on purpose: when the adapter pre-buffered the body bytes
|
|
3597
|
+
* (the common JSON POST case on Node), the parse completes synchronously and
|
|
3598
|
+
* the caller stays on the sync dispatch fast path. Falls back to the
|
|
3599
|
+
* streaming `readBodyLimited` promise otherwise. All parsing keeps the
|
|
3600
|
+
* prototype-pollution-safe semantics of the previous implementation.
|
|
3601
|
+
*/
|
|
3602
|
+
function readBody(req, ct, limit, multipart) {
|
|
3255
3603
|
if (ct.includes("application/json")) {
|
|
3256
|
-
const
|
|
3257
|
-
if (
|
|
3258
|
-
return
|
|
3259
|
-
return
|
|
3604
|
+
const fast = readBodyBytesFast(req, limit);
|
|
3605
|
+
if (fast !== undefined)
|
|
3606
|
+
return parseJsonBodyBytes(fast);
|
|
3607
|
+
return readBodyLimited(req, limit).then(parseJsonBodyBytes);
|
|
3260
3608
|
}
|
|
3261
3609
|
if (ct.includes("application/x-www-form-urlencoded")) {
|
|
3262
|
-
const
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
const out = {};
|
|
3267
|
-
for (const [k, v] of params) {
|
|
3268
|
-
if (isForbiddenObjectKey(k))
|
|
3269
|
-
continue;
|
|
3270
|
-
out[k] = v;
|
|
3271
|
-
}
|
|
3272
|
-
return out;
|
|
3610
|
+
const fast = readBodyBytesFast(req, limit);
|
|
3611
|
+
if (fast !== undefined)
|
|
3612
|
+
return parseUrlencodedBodyBytes(fast);
|
|
3613
|
+
return readBodyLimited(req, limit).then(parseUrlencodedBodyBytes);
|
|
3273
3614
|
}
|
|
3615
|
+
return readBodySlow(req, ct, limit, multipart);
|
|
3616
|
+
}
|
|
3617
|
+
async function readBodySlow(req, ct, limit, multipart) {
|
|
3274
3618
|
if (ct.includes("multipart/form-data")) {
|
|
3275
3619
|
// Fast-fail on an honestly-declared oversize body.
|
|
3276
3620
|
const cl = req.headers.get("content-length");
|
|
@@ -3362,7 +3706,7 @@ function normalizeSunset(value, method, path) {
|
|
|
3362
3706
|
}
|
|
3363
3707
|
return date.toUTCString();
|
|
3364
3708
|
}
|
|
3365
|
-
function serializeResult(result, def, validateResponses) {
|
|
3709
|
+
function serializeResult(result, def, validateResponses, lightOk = false) {
|
|
3366
3710
|
const spec = def.responses[result.status];
|
|
3367
3711
|
if (!spec) {
|
|
3368
3712
|
throw new InternalError(`Handler returned status ${result.status} which is not declared in responses for ${def.method} ${def.path}`);
|
|
@@ -3424,6 +3768,13 @@ function serializeResult(result, def, validateResponses) {
|
|
|
3424
3768
|
body = bytes;
|
|
3425
3769
|
rawBody = bytes;
|
|
3426
3770
|
}
|
|
3771
|
+
// Node-adapter hot path (opt-in via DALOY_LIGHT_RESPONSE_OK on the
|
|
3772
|
+
// incoming request): skip the ~2µs undici Response construction. Only
|
|
3773
|
+
// buffer-backed bodies qualify — streams keep the real Response so the
|
|
3774
|
+
// adapter's stream plumbing is untouched.
|
|
3775
|
+
if (lightOk && !isStream) {
|
|
3776
|
+
return new LightResponse(result.status, headers, rawBody);
|
|
3777
|
+
}
|
|
3427
3778
|
const response = new Response(body, { status: result.status, headers });
|
|
3428
3779
|
if (!isStream) {
|
|
3429
3780
|
response[DALOY_RAW_BODY] = rawBody;
|