@daloyjs/core 1.0.0-rc.4 → 1.0.0-rc.5
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 +14 -12
- package/dist/adapters/bun.d.ts +20 -2
- package/dist/adapters/bun.js +41 -5
- package/dist/adapters/deno.js +24 -7
- package/dist/adapters/lambda.d.ts +59 -2
- package/dist/adapters/lambda.js +136 -20
- package/dist/adapters/node.d.ts +8 -1
- package/dist/adapters/node.js +104 -19
- package/dist/app.d.ts +25 -3
- package/dist/app.js +113 -39
- package/dist/bot-guard.js +30 -3
- package/dist/client.d.ts +36 -7
- package/dist/client.js +7 -0
- package/dist/compression.d.ts +9 -0
- package/dist/compression.js +72 -1
- package/dist/conn-info.d.ts +5 -2
- package/dist/conn-info.js +5 -2
- package/dist/errors.d.ts +12 -3
- package/dist/errors.js +12 -3
- package/dist/fetch-guard.d.ts +27 -19
- package/dist/fetch-guard.js +50 -8
- package/dist/http-signatures.d.ts +4 -1
- package/dist/http-signatures.js +13 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/logger.d.ts +45 -0
- package/dist/logger.js +137 -0
- package/dist/mcp.js +10 -9
- package/dist/middleware.js +33 -3
- package/dist/mtls.js +6 -1
- package/dist/router.d.ts +2 -2
- package/dist/router.js +24 -9
- package/dist/safe-redirect.d.ts +5 -1
- package/dist/safe-redirect.js +25 -3
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/security.d.ts +41 -0
- package/dist/security.js +131 -15
- package/dist/session.d.ts +13 -2
- package/dist/session.js +111 -17
- package/dist/time-claims.js +3 -1
- package/dist/waf.js +86 -26
- package/package.json +5 -4
package/dist/logger.js
CHANGED
|
@@ -34,6 +34,17 @@ export const DEFAULT_REDACT_KEYS = Object.freeze([
|
|
|
34
34
|
"refresh_token",
|
|
35
35
|
"id_token",
|
|
36
36
|
"client_secret",
|
|
37
|
+
// A structured field literally named `private_key` is always a secret and
|
|
38
|
+
// has a negligible false-positive rate as a log-field name (mirrors the
|
|
39
|
+
// existing `client_secret`). The broader OAuth/session query-string names
|
|
40
|
+
// (`code`, `state`, `id`, `key`, `sid`, `session`, `signature`, `sig`,
|
|
41
|
+
// `auth`, …) are DELIBERATELY NOT here: those are extremely common,
|
|
42
|
+
// non-secret structured field names (record ids, sort keys, UI state) and
|
|
43
|
+
// redacting them at every depth would corrupt normal operational logs.
|
|
44
|
+
// Secrets that ride in a *URL query string* are handled instead by
|
|
45
|
+
// {@link sanitizeUrlForLog} / {@link SENSITIVE_URL_QUERY_KEYS}, which only
|
|
46
|
+
// applies to the `url` field where the query context makes them sensitive.
|
|
47
|
+
"private_key",
|
|
37
48
|
// AI / LLM provider credential headers and body fields. Added in response
|
|
38
49
|
// to the LiteLLM 2026 "AI blast radius" incident class (Snyk 2026,
|
|
39
50
|
// CVE-2026-42208 + CVE-2026-33634) — an AI gateway that brokers prompts
|
|
@@ -265,3 +276,129 @@ export const noopLogger = {
|
|
|
265
276
|
return noopLogger;
|
|
266
277
|
},
|
|
267
278
|
};
|
|
279
|
+
/**
|
|
280
|
+
* Query parameter names whose values are redacted when a request URL is
|
|
281
|
+
* bound into a log record. Case-insensitive. Covers OAuth redirect params,
|
|
282
|
+
* API keys in query strings, signed-URL tokens, session identifiers, and the
|
|
283
|
+
* exact-named parameters of AWS SigV4 / GCS V4 presigned URLs (the `x-amz-*`
|
|
284
|
+
* and `x-goog-*` families are additionally matched by prefix — see
|
|
285
|
+
* {@link SENSITIVE_URL_QUERY_KEY_PREFIXES}).
|
|
286
|
+
*
|
|
287
|
+
* @since 1.0.0
|
|
288
|
+
*/
|
|
289
|
+
export const SENSITIVE_URL_QUERY_KEYS = Object.freeze([
|
|
290
|
+
"authorization",
|
|
291
|
+
"access_token",
|
|
292
|
+
"refresh_token",
|
|
293
|
+
"id_token",
|
|
294
|
+
"token",
|
|
295
|
+
"api_key",
|
|
296
|
+
"apikey",
|
|
297
|
+
"api-key",
|
|
298
|
+
"key",
|
|
299
|
+
"password",
|
|
300
|
+
"passwd",
|
|
301
|
+
"secret",
|
|
302
|
+
"client_secret",
|
|
303
|
+
"code",
|
|
304
|
+
"state",
|
|
305
|
+
"session_state",
|
|
306
|
+
"session",
|
|
307
|
+
"sid",
|
|
308
|
+
"signature",
|
|
309
|
+
"sig",
|
|
310
|
+
"auth",
|
|
311
|
+
"private_key",
|
|
312
|
+
"x-api-key",
|
|
313
|
+
// AWS SigV4 presigned URL parameters. `X-Amz-Signature` is the secret; the
|
|
314
|
+
// credential (embeds the access-key id) and session token are equally
|
|
315
|
+
// sensitive. Also covered by the `x-amz-` prefix below.
|
|
316
|
+
"x-amz-signature",
|
|
317
|
+
"x-amz-credential",
|
|
318
|
+
"x-amz-security-token",
|
|
319
|
+
// Google Cloud Storage V4 signed URL parameters. Also covered by `x-goog-`.
|
|
320
|
+
"x-goog-signature",
|
|
321
|
+
"x-goog-credential",
|
|
322
|
+
"googleaccessid",
|
|
323
|
+
]);
|
|
324
|
+
/**
|
|
325
|
+
* Case-insensitive query-key prefixes whose values are always redacted in a
|
|
326
|
+
* logged URL. Covers the full AWS SigV4 (`X-Amz-*`) and GCS V4 (`X-Goog-*`)
|
|
327
|
+
* presigned-URL parameter families so a signature never leaks even if a
|
|
328
|
+
* provider adds a new signed parameter name. Redacting the non-secret members
|
|
329
|
+
* of the bundle (`X-Amz-Date`, `X-Amz-Expires`, …) is harmless in a log line.
|
|
330
|
+
*
|
|
331
|
+
* @since 1.0.0
|
|
332
|
+
*/
|
|
333
|
+
export const SENSITIVE_URL_QUERY_KEY_PREFIXES = Object.freeze([
|
|
334
|
+
"x-amz-",
|
|
335
|
+
"x-goog-",
|
|
336
|
+
]);
|
|
337
|
+
const SENSITIVE_URL_QUERY_KEY_SET = new Set(SENSITIVE_URL_QUERY_KEYS.map((k) => k.toLowerCase()));
|
|
338
|
+
/**
|
|
339
|
+
* Whether a URL query-parameter name is treated as secret-bearing when a
|
|
340
|
+
* request URL is bound into a log record. True when the lower-cased name is in
|
|
341
|
+
* {@link SENSITIVE_URL_QUERY_KEYS} or starts with a
|
|
342
|
+
* {@link SENSITIVE_URL_QUERY_KEY_PREFIXES} entry.
|
|
343
|
+
*
|
|
344
|
+
* @param lowerKey - Already-lower-cased query-parameter name.
|
|
345
|
+
* @returns `true` if the value should be redacted.
|
|
346
|
+
*/
|
|
347
|
+
function isSensitiveUrlQueryKey(lowerKey) {
|
|
348
|
+
if (SENSITIVE_URL_QUERY_KEY_SET.has(lowerKey))
|
|
349
|
+
return true;
|
|
350
|
+
for (const prefix of SENSITIVE_URL_QUERY_KEY_PREFIXES) {
|
|
351
|
+
if (lowerKey.startsWith(prefix))
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Produce a log-safe form of a request URL.
|
|
358
|
+
*
|
|
359
|
+
* Keeps scheme, host, and path for operability. Redacts values of
|
|
360
|
+
* {@link SENSITIVE_URL_QUERY_KEYS} / {@link SENSITIVE_URL_QUERY_KEY_PREFIXES}
|
|
361
|
+
* (and JWT-like / credential-like query values) so OAuth `?code=`,
|
|
362
|
+
* `?access_token=`, and presigned-URL signatures (`?X-Amz-Signature=`,
|
|
363
|
+
* `?X-Goog-Signature=`) never land in durable error logs under the field name
|
|
364
|
+
* `url` (which the structured redactor does not rename-match).
|
|
365
|
+
*
|
|
366
|
+
* Malformed URLs fall back to the path-only prefix before `?` / `#`.
|
|
367
|
+
*
|
|
368
|
+
* This runs once per request on the logging path, so it fast-paths the common
|
|
369
|
+
* case: a URL with no query, no fragment, and no userinfo (`@`) delimiter is
|
|
370
|
+
* already log-safe and is returned verbatim without the WHATWG URL parse (about
|
|
371
|
+
* an order of magnitude cheaper). The `@` guard preserves userinfo stripping
|
|
372
|
+
* for the rare inputs that carry credentials in the authority — `request.url`
|
|
373
|
+
* itself never does, but this is a public utility.
|
|
374
|
+
*
|
|
375
|
+
* @param url - Absolute or relative request URL (typically `request.url`).
|
|
376
|
+
* @returns A string safe to attach as a logger binding.
|
|
377
|
+
* @since 1.0.0
|
|
378
|
+
*/
|
|
379
|
+
export function sanitizeUrlForLog(url) {
|
|
380
|
+
if (url.indexOf("?") === -1 &&
|
|
381
|
+
url.indexOf("#") === -1 &&
|
|
382
|
+
url.indexOf("@") === -1) {
|
|
383
|
+
return url;
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
const parsed = new URL(url);
|
|
387
|
+
if (parsed.search === "" && parsed.hash === "") {
|
|
388
|
+
return `${parsed.origin}${parsed.pathname}`;
|
|
389
|
+
}
|
|
390
|
+
const safe = new URL(parsed.origin + parsed.pathname);
|
|
391
|
+
for (const [key, value] of parsed.searchParams) {
|
|
392
|
+
const lower = key.toLowerCase();
|
|
393
|
+
const sensitiveKey = isSensitiveUrlQueryKey(lower);
|
|
394
|
+
const sensitiveValue = JWT_LIKE_RE.test(value) || CREDENTIAL_LIKE_RE.test(value);
|
|
395
|
+
CREDENTIAL_LIKE_RE.lastIndex = 0;
|
|
396
|
+
safe.searchParams.append(key, sensitiveKey || sensitiveValue ? "[REDACTED]" : value);
|
|
397
|
+
}
|
|
398
|
+
return safe.toString();
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
const cut = url.search(/[?#]/);
|
|
402
|
+
return cut === -1 ? url : url.slice(0, cut);
|
|
403
|
+
}
|
|
404
|
+
}
|
package/dist/mcp.js
CHANGED
|
@@ -355,10 +355,16 @@ function compileUriTemplate(template) {
|
|
|
355
355
|
const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
356
356
|
/**
|
|
357
357
|
* Streamable HTTP DNS-rebinding defense: decide whether a browser `Origin`
|
|
358
|
-
* may talk to this MCP endpoint.
|
|
359
|
-
*
|
|
358
|
+
* may talk to this MCP endpoint.
|
|
359
|
+
*
|
|
360
|
+
* Loopback origins (`localhost` / `127.0.0.1` / `[::1]` / `*.localhost`) are
|
|
361
|
+
* allowed for local development. Every non-loopback origin must appear in
|
|
362
|
+
* the configured allowlist. We deliberately do **not** treat
|
|
363
|
+
* `Origin.host === request Host` as sufficient: under DNS rebinding both
|
|
364
|
+
* can be the attacker hostname resolving to the target IP, which would
|
|
365
|
+
* silently bypass an implicit same-origin check.
|
|
360
366
|
*/
|
|
361
|
-
function isAllowedOrigin(origin,
|
|
367
|
+
function isAllowedOrigin(origin, _request, allowlist) {
|
|
362
368
|
const normalized = origin.toLowerCase();
|
|
363
369
|
if (allowlist.has(normalized))
|
|
364
370
|
return true;
|
|
@@ -374,12 +380,7 @@ function isAllowedOrigin(origin, request, allowlist) {
|
|
|
374
380
|
const hostname = parsed.hostname;
|
|
375
381
|
if (LOOPBACK_HOSTNAMES.has(hostname) || hostname.endsWith(".localhost"))
|
|
376
382
|
return true;
|
|
377
|
-
|
|
378
|
-
return parsed.host === new URL(request.url).host;
|
|
379
|
-
}
|
|
380
|
-
catch {
|
|
381
|
-
return false;
|
|
382
|
-
}
|
|
383
|
+
return false;
|
|
383
384
|
}
|
|
384
385
|
/**
|
|
385
386
|
* Create a dependency-free MCP Streamable HTTP endpoint handler.
|
package/dist/middleware.js
CHANGED
|
@@ -257,6 +257,8 @@ export function secureHeaders(opts = {}) {
|
|
|
257
257
|
}
|
|
258
258
|
}
|
|
259
259
|
const headerEntries = Object.entries(headers);
|
|
260
|
+
// Lowercased name set for the common-path fast apply below.
|
|
261
|
+
const headerKeySet = headerEntries.length > 0 ? new Set(headerEntries.map(([k]) => k)) : null;
|
|
260
262
|
const hooks = {};
|
|
261
263
|
if (cspIsDynamic) {
|
|
262
264
|
hooks.beforeHandle = (ctx) => {
|
|
@@ -276,12 +278,40 @@ export function secureHeaders(opts = {}) {
|
|
|
276
278
|
return undefined;
|
|
277
279
|
};
|
|
278
280
|
}
|
|
279
|
-
if (headerEntries.length > 0) {
|
|
281
|
+
if (headerEntries.length > 0 && headerKeySet !== null) {
|
|
282
|
+
// Apply baseline security headers without overwriting values the handler
|
|
283
|
+
// (or an earlier hook) already set. Two paths, same accept/reject
|
|
284
|
+
// semantics:
|
|
285
|
+
//
|
|
286
|
+
// 1. Fast path (common): response carries none of our keys (typical
|
|
287
|
+
// after serializeResult: content-type + content-length +
|
|
288
|
+
// x-request-id only). One cheap forEach over the small response
|
|
289
|
+
// header map, then unconditional set of each default — avoids N
|
|
290
|
+
// `has()` probes that miss on every request.
|
|
291
|
+
// 2. Careful path: at least one of our keys is already present; fall
|
|
292
|
+
// back to set-if-absent so user-supplied CSP / frame-options / etc.
|
|
293
|
+
// still win.
|
|
280
294
|
hooks.onResponse = (res) => {
|
|
281
|
-
|
|
282
|
-
|
|
295
|
+
let conflict = false;
|
|
296
|
+
// `for...of` over Headers.entries() is faster than the callback-based
|
|
297
|
+
// forEach and lets us break the instant we find a conflicting header.
|
|
298
|
+
// WHATWG Headers yields lowercased names, matching headerKeySet.
|
|
299
|
+
for (const [name] of res.headers) {
|
|
300
|
+
if (headerKeySet.has(name)) {
|
|
301
|
+
conflict = true;
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (!conflict) {
|
|
306
|
+
for (const [k, v] of headerEntries)
|
|
283
307
|
res.headers.set(k, v);
|
|
284
308
|
}
|
|
309
|
+
else {
|
|
310
|
+
for (const [k, v] of headerEntries) {
|
|
311
|
+
if (!res.headers.has(k))
|
|
312
|
+
res.headers.set(k, v);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
285
315
|
};
|
|
286
316
|
}
|
|
287
317
|
hooks[SECURE_HEADERS_MARKER] = true;
|
package/dist/mtls.js
CHANGED
|
@@ -415,7 +415,12 @@ function certFromHeaders(request, cfg) {
|
|
|
415
415
|
const sanRaw = readHeader(request, cfg.san);
|
|
416
416
|
const verifyRaw = cfg.verify ? readHeader(request, cfg.verify) : undefined;
|
|
417
417
|
const successValue = (cfg.verifySuccessValue ?? "SUCCESS").toLowerCase();
|
|
418
|
-
|
|
418
|
+
// Without a configured verification header there is no cryptographic proof
|
|
419
|
+
// the terminator validated the chain. Default to unverified so
|
|
420
|
+
// requireVerified (default true) rejects spoofed identity-only headers.
|
|
421
|
+
// Operators that intentionally trust a proxy which only forwards identity
|
|
422
|
+
// must set requireVerified: false (and keep a strict behindProxy posture).
|
|
423
|
+
const verified = cfg.verify === undefined ? false : (verifyRaw ?? "").toLowerCase() === successValue;
|
|
419
424
|
const sans = [];
|
|
420
425
|
if (sanRaw) {
|
|
421
426
|
for (const piece of sanRaw.split(",")) {
|
package/dist/router.d.ts
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Trie / radix-style router with a static-route fast path.
|
|
3
3
|
*
|
|
4
4
|
* Performance:
|
|
5
|
-
* -
|
|
5
|
+
* - Exact static (parameter-free) paths resolve via a Map lookup — O(1).
|
|
6
6
|
* - Dynamic paths walk a trie, O(path-segments) regardless of route count.
|
|
7
|
-
* - Path
|
|
7
|
+
* - Path normalization and splitting avoid regular expressions.
|
|
8
8
|
*
|
|
9
9
|
* Safety:
|
|
10
10
|
* - Path traversal (`..`) and empty segments are rejected at lookup time.
|
package/dist/router.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Trie / radix-style router with a static-route fast path.
|
|
3
3
|
*
|
|
4
4
|
* Performance:
|
|
5
|
-
* -
|
|
5
|
+
* - Exact static (parameter-free) paths resolve via a Map lookup — O(1).
|
|
6
6
|
* - Dynamic paths walk a trie, O(path-segments) regardless of route count.
|
|
7
|
-
* - Path
|
|
7
|
+
* - Path normalization and splitting avoid regular expressions.
|
|
8
8
|
*
|
|
9
9
|
* Safety:
|
|
10
10
|
* - Path traversal (`..`) and empty segments are rejected at lookup time.
|
|
@@ -102,9 +102,12 @@ export class Router {
|
|
|
102
102
|
if (path.includes("/../") || path.endsWith("/..") || path.includes("//")) {
|
|
103
103
|
return undefined;
|
|
104
104
|
}
|
|
105
|
-
// Static fast path.
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
// Static fast path. Avoid allocating a normalized string for the common
|
|
106
|
+
// exact-path case; only trim when a trailing slash is actually present.
|
|
107
|
+
let staticEntry = this.staticTable.get(path);
|
|
108
|
+
if (!staticEntry && path.endsWith("/")) {
|
|
109
|
+
staticEntry = this.staticTable.get(trimTrailingSlashes(path));
|
|
110
|
+
}
|
|
108
111
|
if (staticEntry && staticEntry[method]) {
|
|
109
112
|
return { handler: staticEntry[method], params: {} };
|
|
110
113
|
}
|
|
@@ -120,8 +123,10 @@ export class Router {
|
|
|
120
123
|
}
|
|
121
124
|
/** Returns the set of methods registered at this exact path (for 405 responses). */
|
|
122
125
|
allowedMethods(path) {
|
|
123
|
-
|
|
124
|
-
|
|
126
|
+
let fromStatic = this.staticTable.get(path);
|
|
127
|
+
if (!fromStatic && path.endsWith("/")) {
|
|
128
|
+
fromStatic = this.staticTable.get(trimTrailingSlashes(path));
|
|
129
|
+
}
|
|
125
130
|
if (fromStatic)
|
|
126
131
|
return Object.keys(fromStatic);
|
|
127
132
|
const segments = splitPath(path);
|
|
@@ -165,6 +170,8 @@ export class Router {
|
|
|
165
170
|
* than letting a `URIError` bubble up as a generic 500.
|
|
166
171
|
*/
|
|
167
172
|
function safeDecodeURIComponent(segment) {
|
|
173
|
+
if (!segment.includes("%"))
|
|
174
|
+
return segment;
|
|
168
175
|
try {
|
|
169
176
|
return decodeURIComponent(segment);
|
|
170
177
|
}
|
|
@@ -187,9 +194,17 @@ function decodeSegments(segs, index) {
|
|
|
187
194
|
}
|
|
188
195
|
return parts.join("/");
|
|
189
196
|
}
|
|
197
|
+
function trimTrailingSlashes(path) {
|
|
198
|
+
if (path.length === 0)
|
|
199
|
+
return "/";
|
|
200
|
+
let end = path.length;
|
|
201
|
+
while (end > 1 && path.charCodeAt(end - 1) === 47)
|
|
202
|
+
end--;
|
|
203
|
+
return end === path.length ? path : path.slice(0, end);
|
|
204
|
+
}
|
|
190
205
|
function splitPath(path) {
|
|
191
|
-
const clean = path
|
|
206
|
+
const clean = trimTrailingSlashes(path);
|
|
192
207
|
if (clean === "/")
|
|
193
208
|
return [];
|
|
194
|
-
return clean.
|
|
209
|
+
return (clean.charCodeAt(0) === 47 ? clean.slice(1) : clean).split("/");
|
|
195
210
|
}
|
package/dist/safe-redirect.d.ts
CHANGED
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
* Percent-encoded protocol-relative path prefixes such as `/%2f%2f`
|
|
20
20
|
* are also refused so downstream decoders cannot turn a same-origin
|
|
21
21
|
* `Location` into an origin-escaping redirect.
|
|
22
|
+
* - Same-origin paths carrying any code point above `U+00FF` are refused:
|
|
23
|
+
* they cannot be represented in the ISO-8859-1 `Location` header, and this
|
|
24
|
+
* also blocks the Unicode slash homographs (`U+2044` `⁄`, `U+2215` `∕`,
|
|
25
|
+
* `U+FF0F` `/`) that `NFKC` normalization can fold into `/`.
|
|
22
26
|
* - Absolute URLs are only allowed when their `origin` exactly matches
|
|
23
27
|
* one of the entries in `allowedOrigins`.
|
|
24
28
|
* - `javascript:`, `data:`, `vbscript:`, and `file:` schemes are always
|
|
@@ -47,7 +51,7 @@
|
|
|
47
51
|
* @since 0.35.0
|
|
48
52
|
*/
|
|
49
53
|
/** Reason an open-redirect candidate was refused. */
|
|
50
|
-
export type SafeRedirectBlockReason = "empty-target" | "invalid-control-characters" | "protocol-relative" | "backslash-path" | "path-not-allowed" | "origin-not-allowed" | "scheme-not-allowed" | "parse-failed";
|
|
54
|
+
export type SafeRedirectBlockReason = "empty-target" | "invalid-control-characters" | "non-latin1-target" | "protocol-relative" | "backslash-path" | "path-not-allowed" | "origin-not-allowed" | "scheme-not-allowed" | "parse-failed";
|
|
51
55
|
/** Thrown when {@link safeRedirect} refuses a candidate URL and no `fallback` is configured. */
|
|
52
56
|
export declare class OpenRedirectBlockedError extends Error {
|
|
53
57
|
/** Machine-readable {@link SafeRedirectBlockReason} explaining the refusal. */
|
package/dist/safe-redirect.js
CHANGED
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
* Percent-encoded protocol-relative path prefixes such as `/%2f%2f`
|
|
20
20
|
* are also refused so downstream decoders cannot turn a same-origin
|
|
21
21
|
* `Location` into an origin-escaping redirect.
|
|
22
|
+
* - Same-origin paths carrying any code point above `U+00FF` are refused:
|
|
23
|
+
* they cannot be represented in the ISO-8859-1 `Location` header, and this
|
|
24
|
+
* also blocks the Unicode slash homographs (`U+2044` `⁄`, `U+2215` `∕`,
|
|
25
|
+
* `U+FF0F` `/`) that `NFKC` normalization can fold into `/`.
|
|
22
26
|
* - Absolute URLs are only allowed when their `origin` exactly matches
|
|
23
27
|
* one of the entries in `allowedOrigins`.
|
|
24
28
|
* - `javascript:`, `data:`, `vbscript:`, and `file:` schemes are always
|
|
@@ -65,6 +69,12 @@ const ALLOWED_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
|
65
69
|
// response-splitting via the `Location` header.
|
|
66
70
|
// eslint-disable-next-line no-control-regex
|
|
67
71
|
const CONTROL_CHAR_RE = /[\u0000-\u001f\u007f-\u009f]/;
|
|
72
|
+
// Any code point above U+00FF (outside Latin-1). Such characters cannot be
|
|
73
|
+
// written to a `Location` header — which is serialized as an ISO-8859-1
|
|
74
|
+
// ByteString, so `Headers.set` throws a raw `TypeError` — and they cover the
|
|
75
|
+
// Unicode slash homographs (U+2044, U+2215, U+FF0F) used to smuggle a
|
|
76
|
+
// protocol-relative redirect past a same-origin path check.
|
|
77
|
+
const NON_LATIN1_RE = /[^\x00-\xff]/;
|
|
68
78
|
function buildResponse(location, status, headers) {
|
|
69
79
|
const merged = new Headers(headers);
|
|
70
80
|
merged.set("Location", location);
|
|
@@ -97,6 +107,14 @@ function classify(target, allowedPaths, allowedOrigins) {
|
|
|
97
107
|
// user agents and proxies — refuse them outright.
|
|
98
108
|
if (target.includes("\\"))
|
|
99
109
|
return { ok: false, reason: "backslash-path" };
|
|
110
|
+
// The path is written verbatim into the `Location` header (an ISO-8859-1
|
|
111
|
+
// ByteString). A code point above U+00FF cannot live there — `Headers.set`
|
|
112
|
+
// would throw a raw `TypeError`, escaping this helper's typed error
|
|
113
|
+
// contract and surfacing to callers as an uncaught 500 — so refuse it here
|
|
114
|
+
// with a proper `OpenRedirectBlockedError`. This also blocks Unicode slash
|
|
115
|
+
// homographs that normalization can fold into an origin-escaping `//`.
|
|
116
|
+
if (NON_LATIN1_RE.test(target))
|
|
117
|
+
return { ok: false, reason: "non-latin1-target" };
|
|
100
118
|
if (allowedPaths.length === 0) {
|
|
101
119
|
return { ok: false, reason: "path-not-allowed" };
|
|
102
120
|
}
|
|
@@ -191,10 +209,14 @@ export function safeRedirect(target, options = {}) {
|
|
|
191
209
|
if (result.ok)
|
|
192
210
|
return buildResponse(result.location, status, options.headers);
|
|
193
211
|
if (options.fallback !== undefined) {
|
|
194
|
-
|
|
195
|
-
|
|
212
|
+
// Fallback must pass the same path safety checks as a primary same-origin
|
|
213
|
+
// target (no protocol-relative, no backslash confusion, no controls).
|
|
214
|
+
// Do not widen Location emission beyond what classify() would accept.
|
|
215
|
+
const fallbackResult = classify(options.fallback, ["/*"], []);
|
|
216
|
+
if (!fallbackResult.ok || !options.fallback.startsWith("/") || options.fallback.startsWith("//")) {
|
|
217
|
+
throw new TypeError(`safeRedirect: fallback must be a safe same-origin path starting with "/"; got ${options.fallback}`);
|
|
196
218
|
}
|
|
197
|
-
return buildResponse(
|
|
219
|
+
return buildResponse(fallbackResult.location, status, options.headers);
|
|
198
220
|
}
|
|
199
221
|
throw new OpenRedirectBlockedError(result.reason, target);
|
|
200
222
|
}
|
package/dist/sbom.cdx.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"bomFormat": "CycloneDX",
|
|
3
3
|
"specVersion": "1.5",
|
|
4
|
-
"serialNumber": "urn:uuid:
|
|
4
|
+
"serialNumber": "urn:uuid:4d7ef2ca-7207-5a53-933a-75950561ff0c",
|
|
5
5
|
"version": 1,
|
|
6
6
|
"metadata": {
|
|
7
|
-
"timestamp": "2026-07-
|
|
7
|
+
"timestamp": "2026-07-20T09:04:35.490Z",
|
|
8
8
|
"tools": [
|
|
9
9
|
{
|
|
10
10
|
"vendor": "DaloyJS",
|
|
11
11
|
"name": "daloy-generate-sbom",
|
|
12
|
-
"version": "1.0.0-rc.
|
|
12
|
+
"version": "1.0.0-rc.5"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"authors": [
|
|
@@ -19,11 +19,11 @@
|
|
|
19
19
|
],
|
|
20
20
|
"component": {
|
|
21
21
|
"type": "library",
|
|
22
|
-
"bom-ref": "pkg:npm/@daloyjs/core@1.0.0-rc.
|
|
22
|
+
"bom-ref": "pkg:npm/@daloyjs/core@1.0.0-rc.5",
|
|
23
23
|
"name": "@daloyjs/core",
|
|
24
|
-
"version": "1.0.0-rc.
|
|
24
|
+
"version": "1.0.0-rc.5",
|
|
25
25
|
"description": "DaloyJS is a runtime-portable, contract-first TypeScript web framework with built-in OpenAPI (Hey API), typed client generation, large-scale maintainability, and security-first defaults. Hono-grade portability, Elysia-grade DX, FastAPI-grade docs, Fastify-grade ops — distributed via pnpm.",
|
|
26
|
-
"purl": "pkg:npm/@daloyjs/core@1.0.0-rc.
|
|
26
|
+
"purl": "pkg:npm/@daloyjs/core@1.0.0-rc.5",
|
|
27
27
|
"licenses": [
|
|
28
28
|
{
|
|
29
29
|
"license": {
|
|
@@ -46,9 +46,9 @@
|
|
|
46
46
|
}
|
|
47
47
|
],
|
|
48
48
|
"swid": {
|
|
49
|
-
"tagId": "swidtag--daloyjs-core-1.0.0-rc.
|
|
49
|
+
"tagId": "swidtag--daloyjs-core-1.0.0-rc.5",
|
|
50
50
|
"name": "@daloyjs/core",
|
|
51
|
-
"version": "1.0.0-rc.
|
|
51
|
+
"version": "1.0.0-rc.5",
|
|
52
52
|
"tagVersion": 0,
|
|
53
53
|
"patch": false
|
|
54
54
|
}
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"components": [],
|
|
58
58
|
"dependencies": [
|
|
59
59
|
{
|
|
60
|
-
"ref": "pkg:npm/@daloyjs/core@1.0.0-rc.
|
|
60
|
+
"ref": "pkg:npm/@daloyjs/core@1.0.0-rc.5",
|
|
61
61
|
"dependsOn": []
|
|
62
62
|
}
|
|
63
63
|
]
|
package/dist/sbom.spdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"spdxVersion": "SPDX-2.3",
|
|
3
3
|
"dataLicense": "CC0-1.0",
|
|
4
4
|
"SPDXID": "SPDXRef-DOCUMENT",
|
|
5
|
-
"name": "@daloyjs/core-1.0.0-rc.
|
|
6
|
-
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-rc.
|
|
5
|
+
"name": "@daloyjs/core-1.0.0-rc.5",
|
|
6
|
+
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.0.0-rc.5-4d7ef2ca-7207-5a53-933a-75950561ff0c",
|
|
7
7
|
"creationInfo": {
|
|
8
|
-
"created": "2026-07-
|
|
8
|
+
"created": "2026-07-20T09:04:35.490Z",
|
|
9
9
|
"creators": [
|
|
10
10
|
"Tool: daloy-generate-sbom",
|
|
11
11
|
"Organization: DaloyJS"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{
|
|
17
17
|
"SPDXID": "SPDXRef-Package--daloyjs-core",
|
|
18
18
|
"name": "@daloyjs/core",
|
|
19
|
-
"versionInfo": "1.0.0-rc.
|
|
19
|
+
"versionInfo": "1.0.0-rc.5",
|
|
20
20
|
"downloadLocation": "https://github.com/daloyjs/daloy",
|
|
21
21
|
"filesAnalyzed": false,
|
|
22
22
|
"licenseConcluded": "MIT",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
{
|
|
28
28
|
"referenceCategory": "PACKAGE-MANAGER",
|
|
29
29
|
"referenceType": "purl",
|
|
30
|
-
"referenceLocator": "pkg:npm/@daloyjs/core@1.0.0-rc.
|
|
30
|
+
"referenceLocator": "pkg:npm/@daloyjs/core@1.0.0-rc.5"
|
|
31
31
|
}
|
|
32
32
|
]
|
|
33
33
|
}
|
package/dist/security.d.ts
CHANGED
|
@@ -141,6 +141,11 @@ export declare const SMUGGLING_SINGLETON_HEADERS: readonly string[];
|
|
|
141
141
|
* Throws {@link BadRequestError} so the framework returns a structured
|
|
142
142
|
* `400 problem+json` instead of forwarding a smuggling-class request.
|
|
143
143
|
*
|
|
144
|
+
* The framework's dispatch path runs this check, the reserved-prefix check,
|
|
145
|
+
* and the header-count cap in a single shared walk internally (see
|
|
146
|
+
* {@link assertInboundHeaderGuards}); calling this helper directly is only
|
|
147
|
+
* needed for custom pipelines.
|
|
148
|
+
*
|
|
144
149
|
* @param headers - Normalized request headers to inspect.
|
|
145
150
|
* @since 0.15.0
|
|
146
151
|
*/
|
|
@@ -179,6 +184,10 @@ export declare const RESERVED_INBOUND_HEADER_PREFIXES: readonly string[];
|
|
|
179
184
|
* `400 problem+json` instead of routing a request that may be probing
|
|
180
185
|
* for an internal-dispatch bypass.
|
|
181
186
|
*
|
|
187
|
+
* The framework's dispatch path runs this check and the header-count cap
|
|
188
|
+
* in a single shared walk internally (see {@link assertInboundHeaderGuards});
|
|
189
|
+
* calling this helper directly is only needed for custom pipelines.
|
|
190
|
+
*
|
|
182
191
|
* @param headers - Normalized request headers to inspect (names arrive lowercased).
|
|
183
192
|
* @since 0.36.0
|
|
184
193
|
*/
|
|
@@ -212,11 +221,43 @@ export declare const DEFAULT_MAX_HEADER_COUNT = 100;
|
|
|
212
221
|
* framework returns a structured `problem+json` response instead of routing
|
|
213
222
|
* a flood.
|
|
214
223
|
*
|
|
224
|
+
* The framework's dispatch path runs this check and the reserved-prefix
|
|
225
|
+
* check in a single shared walk internally (see
|
|
226
|
+
* {@link assertInboundHeaderGuards}); calling this helper directly is only
|
|
227
|
+
* needed for custom pipelines.
|
|
228
|
+
*
|
|
215
229
|
* @param headers - The incoming request headers.
|
|
216
230
|
* @param limit - Maximum distinct header fields to allow. `0` disables.
|
|
217
231
|
* @since 0.38.0
|
|
218
232
|
*/
|
|
219
233
|
export declare function assertHeaderCountWithinLimit(headers: Headers, limit: number): void;
|
|
234
|
+
/**
|
|
235
|
+
* Combined inbound header guards for the dispatch hot path (internal).
|
|
236
|
+
*
|
|
237
|
+
* Runs the singleton-duplicate check ({@link assertNoDuplicateSingletonHeaders}),
|
|
238
|
+
* the reserved-internal-prefix check ({@link assertNoReservedInternalHeaders}),
|
|
239
|
+
* and the header-count cap ({@link assertHeaderCountWithinLimit}) in a
|
|
240
|
+
* **single** walk of the header map, with the same observable semantics as
|
|
241
|
+
* calling the helpers in sequence: any `400`-class violation (duplicate
|
|
242
|
+
* singleton header or reserved internal header) anywhere in the map is
|
|
243
|
+
* rejected with `400` even when the map also exceeds the count cap. To
|
|
244
|
+
* preserve that precedence, the `431` count-cap rejection is deferred until
|
|
245
|
+
* the scan has covered every header — the same full-walk cost the
|
|
246
|
+
* sequential helpers already paid.
|
|
247
|
+
*
|
|
248
|
+
* A non-positive / non-finite `limit` disables the count cap (same as
|
|
249
|
+
* {@link assertHeaderCountWithinLimit}) while still rejecting duplicate
|
|
250
|
+
* singleton headers and reserved internal prefixes.
|
|
251
|
+
*
|
|
252
|
+
* @param headers - Normalized request headers (names arrive lowercased).
|
|
253
|
+
* @param limit - Maximum distinct header fields to allow. `0` disables the count cap.
|
|
254
|
+
* @throws {BadRequestError} When a duplicate singleton header or a reserved
|
|
255
|
+
* internal header is present (takes precedence over the count cap).
|
|
256
|
+
* @throws {RequestHeaderFieldsTooLargeError} When no `400`-class violation is
|
|
257
|
+
* present and the distinct-header count exceeds `limit`.
|
|
258
|
+
* @since 1.0.0
|
|
259
|
+
*/
|
|
260
|
+
export declare function assertInboundHeaderGuards(headers: Headers, limit: number): void;
|
|
220
261
|
/**
|
|
221
262
|
* Minimum acceptable secret length in bytes for HMAC / signing material in
|
|
222
263
|
* production (boot guard). Matches the OWASP "Secret Management"
|