@daloyjs/core 1.0.0-rc.0 → 1.0.0-rc.2
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 +11 -9
- package/bin/daloy.mjs +38 -10
- package/dist/app.d.ts +21 -8
- package/dist/app.js +71 -12
- package/dist/cli.d.ts +5 -2
- package/dist/cli.js +9 -5
- 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/dist/types.d.ts +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -211,7 +211,7 @@ That single command runs the two scripts:
|
|
|
211
211
|
```jsonc
|
|
212
212
|
// package.json
|
|
213
213
|
"scripts": {
|
|
214
|
-
"gen:openapi": "node
|
|
214
|
+
"gen:openapi": "node scripts/dump-openapi.ts",
|
|
215
215
|
"gen:client": "openapi-ts",
|
|
216
216
|
"gen": "pnpm gen:openapi && pnpm gen:client"
|
|
217
217
|
}
|
|
@@ -339,11 +339,11 @@ const app = createApp({ docs: true });
|
|
|
339
339
|
|
|
340
340
|
| Runtime | Spawned command |
|
|
341
341
|
| ------- | --------------------------------------------------------------- |
|
|
342
|
-
| Node | `node --
|
|
342
|
+
| Node | `node --watch <entry>` |
|
|
343
343
|
| Bun | `bun --hot <entry>` |
|
|
344
344
|
| Deno | `deno run --watch --allow-net --allow-env --allow-read <entry>` |
|
|
345
345
|
|
|
346
|
-
Entry defaults to `src/index.ts`, `src/main.ts`, `src/server.ts`, or `src/app.ts`.
|
|
346
|
+
Entry defaults to `src/index.ts`, `src/main.ts`, `src/server.ts`, or `src/app.ts`. Node.js (>= 22.18) runs TypeScript entries natively via built-in type stripping — no loader needed. Projects that rely on non-erasable syntax (enums, runtime namespaces, parameter properties) or extensionless relative imports can keep using a loader directly, e.g. `node --import tsx --watch <entry>` (and `daloy inspect` falls back to `tsx` automatically when the native load fails and `tsx` is installed).
|
|
347
347
|
|
|
348
348
|
Pass `--runtime <node|bun|deno>` to override runtime detection. This is required when running `daloy dev` from a `package.json` script on Bun or Deno, because the CLI binary's `#!/usr/bin/env node` shebang otherwise forces Node detection. The `bun-basic` template ships `"dev": "daloy dev --runtime bun"` for this reason.
|
|
349
349
|
|
|
@@ -509,7 +509,7 @@ The core only ever sees `Request → Response`. Adapters live at the edge.
|
|
|
509
509
|
|
|
510
510
|
## Status
|
|
511
511
|
|
|
512
|
-
DaloyJS is
|
|
512
|
+
DaloyJS is at **`1.0.0-rc.2`**, a security-hardening release candidate. Because the framework has no external users yet, this RC ships a few intentional breaking changes (see the [CHANGELOG](CHANGELOG.md)) to get the secure-by-default posture right before GA rather than deferring them; the generated OpenAPI contract is unchanged. From `1.0.0` GA onward, breaking changes follow SemVer with deprecations getting at least one minor cycle. The framework is already in use for production trials.
|
|
513
513
|
|
|
514
514
|
**Release quality bar.** Every release ships with **≥90% line + function coverage and ≥90% branch coverage**, strict TypeScript, OpenSSF Scorecard, CodeQL + Opengrep dual SAST, zizmor workflow linting, and npm provenance. Coverage was relaxed from a former 100% gate so complex security work isn't blocked chasing throwaway tests for unreachable defensive branches or tsx source-map phantoms; see [AGENTS.md](AGENTS.md) for the policy.
|
|
515
515
|
|
|
@@ -520,14 +520,14 @@ DaloyJS is now at its **first `1.0.0` release candidate** (`1.0.0-rc.0`). The pu
|
|
|
520
520
|
- Zero-config OpenAPI `info` autofill from `package.json` (Node / Bun) or `deno.json` / `deno.jsonc` (Deno); explicit `openapi.info` values always win.
|
|
521
521
|
- RFC 7231 + RFC 5789 HTTP-method allowlist enforced inside `app.route()` (WebDAV, `TRACE`, `CONNECT` rejected at the framework boundary).
|
|
522
522
|
- AI-friendly route metadata via optional `meta: { examples, extensions, summary, description, tags }`; examples are validated against your schemas at build time, surfaced as OpenAPI `examples` + `x-daloy-*` extensions, and dumped as `routes.json` / `routes.yaml` via `daloy inspect --ai`.
|
|
523
|
-
- Dependency-free MCP Streamable HTTP server helpers at `@daloyjs/core/mcp`: `createMcpHandler()` exposes tools (with `outputSchema`, `annotations`, and icons), resources, RFC 6570 resource templates, and prompts (with required-argument enforcement) over JSON-RPC 2.0 and validates `Origin` against DNS rebinding (with an `allowedOrigins` allowlist), while `mcpRoutes("/mcp", handler)` mounts the POST / GET / OPTIONS Daloy routes — with the JSON-RPC envelope schema surfaced in OpenAPI — for a dedicated MCP service with the same auth, rate-limit, body-limit, and timeout middleware as any other app.
|
|
523
|
+
- Dependency-free MCP Streamable HTTP server helpers at `@daloyjs/core/mcp`: `createMcpHandler()` exposes tools (with `outputSchema`, `annotations`, and icons), resources, RFC 6570 resource templates, and prompts (with required-argument enforcement) over JSON-RPC 2.0 and validates `Origin` against DNS rebinding (with an `allowedOrigins` allowlist), while `mcpRoutes("/mcp", handler)` mounts the POST / GET / OPTIONS Daloy routes — with the JSON-RPC envelope schema surfaced in OpenAPI — for a dedicated MCP service with the same auth, rate-limit, body-limit, and timeout middleware as any other app. Every `tools/call` argument is validated server-side against the tool's `inputSchema` (a dependency-free JSON-Schema subset — `type`/`required`/`properties`/`additionalProperties`/`enum`/`const`/bounds; exposed as `validateMcpInput()`) before the handler runs, rejecting a mismatch with JSON-RPC `-32602`; the JSON-RPC body is parsed with prototype-pollution-safe `safeJsonParse`; and an unauthenticated `mcpRoutes()` endpoint refuses to boot in production unless opted out with `mcpRoutes(path, handler, { public: true })`.
|
|
524
524
|
- API lifecycle and breaking-change detection: mark routes `deprecated` or give them a `sunset` date to emit RFC 8594 `Deprecation` / `Sunset` headers and an `x-sunset` OpenAPI extension, then gate CI with `diffOpenAPI()` / the `daloy diff` command, which fail on a breaking change versus the last published spec.
|
|
525
525
|
- In-process test client (`app.request()`), contract-test runner (gated in CI via `daloy inspect --check` and shipped as a default test in every `create-daloy` template), in-process typed client, and Hey API codegen via `pnpm gen`.
|
|
526
526
|
|
|
527
527
|
### Runtimes and deployment
|
|
528
528
|
|
|
529
529
|
- Adapters for Node (Heroku, Railway, Render, Fly.io), Bun, Deno, Cloudflare Workers, Vercel Node / Edge / Next.js / Netlify Edge, Fastly Compute, and AWS Lambda / Netlify Functions / Lambda Function URLs.
|
|
530
|
-
- `daloy dev` watch loop delegates to the host runtime's native watcher (`node --
|
|
530
|
+
- `daloy dev` watch loop delegates to the host runtime's native watcher (`node --watch`, `bun --hot`, or `deno run --watch`) with a `--runtime` override for cross-runtime `package.json` scripts.
|
|
531
531
|
- `pnpm create daloy` scaffolder with Node, Bun, Deno, Cloudflare Worker, and Vercel templates, plus optional `--with-ci` GitHub Actions / Dependabot / CODEOWNERS / SECURITY.md hardening. The completion summary surfaces official install links (nodejs.org, pnpm.io, bun.sh) for any runtime or package manager your selections need but that is missing from `PATH`, and skips a doomed dependency install when the chosen package manager is absent.
|
|
532
532
|
- Container-first templates: `HEALTHCHECK` to `/readyz`, `STOPSIGNAL SIGTERM`, non-root user, `tini` as PID 1.
|
|
533
533
|
- Generated `deploy.yml` for container templates signs every pushed GHCR image with **Sigstore Cosign** (keyless OIDC) and attaches an **SPDX SBOM attestation** so consumers can `cosign verify` and `cosign verify-attestation --type spdxjson` instead of trusting the registry alone.
|
|
@@ -549,7 +549,9 @@ DaloyJS is now at its **first `1.0.0` release candidate** (`1.0.0-rc.0`). The pu
|
|
|
549
549
|
|
|
550
550
|
The framework refuses to start (or to construct) when configuration is unsafe:
|
|
551
551
|
|
|
552
|
-
- Weak session secrets, `cors({ origin: "*" })` with credentials, `session()` + state-changing route without `csrf()`, and unconfigured `X-Forwarded
|
|
552
|
+
- Weak session secrets, `cors({ origin: "*" })` with credentials, `session()` + state-changing route without `csrf()`, and unconfigured forwarded / client-IP headers (`X-Forwarded-*`, `X-Real-IP`, and vendor headers `CF-Connecting-IP` / `Fly-Client-IP` / `True-Client-IP`) in production.
|
|
553
|
+
- **Shadow auth**: a route that declares an `auth:` requirement (advertised as protected in the OpenAPI `security` list) but installs no authentication hook to enforce it. Built-in auth middlewares (`bearerAuth` / `basicAuth` / `jwk` / `httpSignatureAuth` / `clientCertAuth`) satisfy the guard automatically; mark a custom auth hook (or upstream-gateway-enforced auth) with `markAuthHook()`.
|
|
554
|
+
- **Unauthenticated MCP**: an `mcpRoutes()` endpoint with no auth hook — MCP tools are model-controlled and side-effecting. Opt out for a genuinely public server with `mcpRoutes(path, handler, { public: true })`.
|
|
553
555
|
- `secureDefaults: false` in production unless `acknowledgeInsecureDefaults: true` is set, plus a once-per-process `error` log naming every disabled default.
|
|
554
556
|
- `preset: "internal-service"` topology preset for service-to-service deployments behind a mesh / sidecar / private network: turns OFF the browser-only guards (auto `secureHeaders`, `corsCrossOriginGuard`, `csrf` boot guard, unconfigured `X-Forwarded-*` guard) while keeping every input, parser, credential, SSRF, weak-secret, and refuse-to-boot guard ON. Per-knob options still win, the choice is logged at boot under `event: "security.preset.applied"`, and the live posture is auditable via `app.getSecurityPosture()`.
|
|
555
557
|
- `createJwtSigner()` / `createJwtVerifier()` refuse `alg: "none"`, accept only an explicit allowlist, refuse HS + JWK combinations, refuse to sign without `exp`, and refuse HS-shaped secrets under 32 bytes (RFC 7518 §3.2).
|
|
@@ -587,13 +589,13 @@ The framework refuses to start (or to construct) when configuration is unsafe:
|
|
|
587
589
|
- `clientCertAuth()` mTLS / client-certificate auth at `@daloyjs/core/mtls`: authenticate zero-trust / service-to-service callers by their TLS client certificate from two sources — **native TLS** (the Node adapter lazily reads the peer cert off the socket; plain requests pay nothing) or a **TLS-terminating proxy** (Envoy `X-Forwarded-Client-Cert` and nginx/HAProxy-style structured headers). `requireVerified` by default, exact `allowSubjectCNs` / `allowIssuerCNs`, **constant-time** `allowFingerprints`, `allowSANs` (SPIFFE/DNS/URI/IP, `TYPE:value` or bare), validity-window enforcement, and a custom async `verify()` hook. Missing cert → `401` problem+json with `Cache-Control: no-store`; any failed check → `403` (never echoes cert details). The accepted `ClientCertificate` is stamped on `ctx.state`. `parseForwardedClientCert()` / `normalizePeerCertificate()` exported standalone. Zero runtime dependencies.
|
|
588
590
|
- `autoBan()` adaptive auto-ban (fail2ban-style) at `@daloyjs/core/auto-ban`: temporarily ban abusive clients after repeated suspicious responses (default `401` / `403` / `429`, configurable `watchStatuses`) within a rolling `windowMs`. Bans **escalate** exponentially for repeat offenders (`banMs` → `2×` → `4×`, capped at `maxBanMs`) and **decay** once the client goes quiet. Observes the outgoing status via `onSend` (counts failures from any downstream middleware/handler), enforces in `beforeHandle`. Secure-by-default identity attribution — refuses to construct without `keyGenerator` or `trustProxyHeaders` so one offender can never ban everyone; unattributable requests are skipped. Pluggable `AutoBanStore` (mirrors the `rateLimit()` store; in-memory default, Redis-able for multi-instance), `groupId` sharing across route groups, `429`/`403` ban response with `Retry-After`, and `onBan` / `onStrike` hooks. Zero runtime dependencies.
|
|
589
591
|
- `botGuard()` bot / User-Agent management at `@daloyjs/core/bot-guard`: the in-app equivalent of Nginx/WAF bot rules. Blocks empty/missing `User-Agent` (default on) and known-abusive `User-Agent` strings / `RegExp`s, and **verifies declared crawlers** — a request claiming to be Googlebot/Bingbot is confirmed via reverse-DNS + forward-confirm (the method Google and Bing document), so a spoofed `User-Agent` can't impersonate a trusted crawler. Ships `GOOGLEBOT` / `BINGBOT` / `WELL_KNOWN_BOTS` presets and accepts custom `VerifiedBotRule`s. Allowlist-first (`allowUserAgents` bypasses every rule), secure-by-default (`verifiedBots` refuses to construct without an IP source; unverifiable crawlers blocked unless `blockUnverifiableBots: false`), subdomain-boundary-safe domain matching, per-IP verification cache to keep DNS off the hot path, `mode: "log"` monitor mode, `onBlock` callback, and a pluggable `BotResolver` (default lazy `node:dns/promises`). Zero runtime dependencies.
|
|
590
|
-
- `ipReputation()` IP reputation / dynamic denylist feed at `@daloyjs/core/ip-reputation`: wires pluggable, periodically-refreshed abuse feeds (Tor exit lists, Spamhaus DROP, cloud-abuse ranges, or your own threat intel) into the request path without a redeploy, reusing the same SSRF-grade CIDR matcher as `ipRestriction()`. Ships `urlFeed()` (fetches newline / Spamhaus-DROP-style lists, skips comment lines, keeps good rows from a partially-malformed feed) plus a custom `IpReputationFeed` interface. **Fail-open by design** — a feed that can't be loaded (initial or refresh) never blocks traffic; the last-known-good list is retained per feed. Periodic `unref`'d refresh, `mode: "log"` monitor mode, `onMatch` / `onError` callbacks, manual `refresh()` / `stop()` / `has()` / `size` controller, and pluggable IP resolution (`trustProxyHeaders` / `resolveIp`). Zero runtime dependencies.
|
|
592
|
+
- `ipReputation()` IP reputation / dynamic denylist feed at `@daloyjs/core/ip-reputation`: wires pluggable, periodically-refreshed abuse feeds (Tor exit lists, Spamhaus DROP, cloud-abuse ranges, or your own threat intel) into the request path without a redeploy, reusing the same SSRF-grade CIDR matcher as `ipRestriction()`. Ships `urlFeed()` (fetches newline / Spamhaus-DROP-style lists, skips comment lines, keeps good rows from a partially-malformed feed; **SSRF-hardened by default** — the outbound fetch runs through `fetchGuard()`, so a compromised feed host can't redirect it into cloud-metadata / internal space; override via `fetchImpl`) plus a custom `IpReputationFeed` interface. **Fail-open by design** — a feed that can't be loaded (initial or refresh) never blocks traffic; the last-known-good list is retained per feed. Periodic `unref`'d refresh, `mode: "log"` monitor mode, `onMatch` / `onError` callbacks, manual `refresh()` / `stop()` / `has()` / `size` controller, and pluggable IP resolution (`trustProxyHeaders` / `resolveIp`). Zero runtime dependencies.
|
|
591
593
|
- `geoBlock()` GeoIP / geo-blocking at `@daloyjs/core/geo-block`: country allow/deny middleware that maps the client IP to an ISO 3166-1 alpha-2 country and rejects (or logs) traffic from countries you don't serve. **No bundled GeoIP database and no runtime dependency** — supply either an operator-owned `lookupCountry(ip)` (a MaxMind / `ip2location` reader, or your own table, reusing the trusted-proxy `X-Forwarded-For` / `X-Real-IP` IP resolution) or a `resolveCountry(ctx)` that reads an edge-injected header (`CF-IPCountry`, `CloudFront-Viewer-Country`, `x-vercel-ip-country`). Deny wins over allow (least privilege); **allow-lists fail closed** on an unknown country while deny-only fails open (overridable via `allowUnknownCountry`). Country codes are validated at construction so typos throw instead of silently never matching. `mode: "log"` monitor mode with an `onBlock` decision hook (`denied_country` / `not_in_allowlist` / `unknown_country`), the resolved country stamped on `ctx.state.geo` for allowed requests, and a `403` problem+json rejection that never echoes the country/IP. Zero runtime dependencies.
|
|
592
594
|
- `concurrencyLimit()` per-route / per-client concurrency limits + queueing at `@daloyjs/core/concurrency-limit`: HAProxy `maxconn`/queue parity at the app layer. Bounds in-flight requests through a surface with a per-bucket semaphore (`maxConcurrent`), a bounded FIFO queue (`maxQueue`) with an optional `queueTimeoutMs`, and a fast `503` + `Retry-After` once the queue is full or the wait times out. Partition the budget with `scope`: `"global"` (default), `"route"` (per `method + path`), `"client"` (per identity, needs `trustProxyHeaders`/`keyGenerator`), or a custom function (`undefined` skips limiting, fail-open). Acquires in `beforeHandle` and releases in `onSend`, so slots are freed on success, error, and short-circuit paths alike — never leaked. `onReject` observability hook, configurable `retryAfterSeconds`/`message`. Complements the `maxConnections` socket cap and `loadShedding()`. Zero runtime dependencies. HAProxy `maxconn`/queue parity at the app layer. Bounds in-flight requests through a surface with a per-bucket semaphore (`maxConcurrent`), a bounded FIFO queue (`maxQueue`) with an optional `queueTimeoutMs`, and a fast `503` + `Retry-After` once the queue is full or the wait times out. Partition the budget with `scope`: `"global"` (default), `"route"` (per `method + path`), `"client"` (per identity, needs `trustProxyHeaders`/`keyGenerator`), or a custom function (`undefined` skips limiting, fail-open). Acquires in `beforeHandle` and releases in `onSend`, so slots are freed on success, error, and short-circuit paths alike — never leaked. `onReject` observability hook, configurable `retryAfterSeconds`/`message`. Complements the `maxConnections` socket cap and `loadShedding()`. Zero runtime dependencies.
|
|
593
595
|
- `requestDecompression()` inbound decompression-bomb guard at `@daloyjs/core/request-decompression`: core is safe by omission (it never decompresses request bodies), so this is the opt-in middleware for services that must accept compressed uploads. Inflates `gzip` / `deflate` bodies behind two caps enforced **during** inflation so a zip bomb is aborted before it is fully materialised: an absolute `maxDecompressedBytes` (required) and an expansion-ratio `maxRatio` (default `100`), both rejecting with `413`. The compressed upload itself is bounded by `maxCompressedBytes` (default 1 MiB) before a byte is inflated. Unknown, non-allowlisted, runtime-unsupported, or **layered** (`gzip, gzip`) encodings are refused `415`; malformed streams `400`; bodyless / uncompressed / `identity` / `GET` / `HEAD` traffic passes through untouched. Runs in `onRequest` and stashes the inflated bytes so schema-validated bodies and raw-body handlers both see the decompressed payload. `onBomb` observability hook, exported `decompressRequestBody()` for custom flows. Built on web-standard `DecompressionStream` (brotli excluded — not in the spec). Zero runtime dependencies.
|
|
594
596
|
- `waf()` opt-in WAF-lite signature/anomaly inbound-inspection middleware at `@daloyjs/core/waf`: a first-party defense-in-depth layer for teams without an edge WAF (it does **not** replace ModSecurity / a CDN WAF). Wires DaloyJS' high-confidence injection signatures — SQLi, XSS, NoSQL-operator injection (reusing `hasMongoOperatorKeys` for a structural body check), and command injection — into a single scored inbound-inspection pass over the decoded path, the raw + decoded query string, an opt-in header allowlist, and the validated body. Each rule that fires adds an anomaly `score`; reaching `blockThreshold` (default `5`) rejects with a generic `403` (block mode) or merely reports via `onMatch` (log mode) so operators can tune against real traffic first. Per-rule enable/disable + score overrides, inspection-surface toggles, control-character-stripped log samples, and bounded scanning (`maxValueLength` / `maxBodyNodes`) keep a hostile payload from becoming CPU-DoS. The `403` body never names the rule that fired. Zero runtime dependencies.
|
|
595
597
|
- Built-in docs UI Subresource Integrity (SRI): `DocsAssetOptions` lets `scalarHtml()` / `swaggerUiHtml()` / `redocHtml()` and the `docs: { assets }` auto-mount pin version-exact `*Integrity` hashes (`sha256`/`sha384`/`sha512`) plus a `crossOrigin` value (default `"anonymous"`) on the CDN-loaded Scalar / Swagger UI / Redoc `<script>` / `<link>` tags, so a poisoned jsDelivr asset can't execute. Malformed SRI values throw a `TypeError` at startup (browsers ignore unparseable `integrity`, so failing loud avoids a false sense of protection); self-hosting the assets via the same `assets` URLs stays supported. Zero runtime dependencies.
|
|
596
|
-
- HTTP Message Signatures (RFC 9421) at `@daloyjs/core/http-signatures`: first-party sign/verify for server-to-server request authentication via the standard `Signature` / `Signature-Input` headers — complements the inbound-only webhook HMAC and `clientCertAuth()` mTLS. `signMessage()` / `signRequest()` build an RFC 9421 signature base over derived components (`@method`, `@target-uri`, `@authority`, `@scheme`, `@request-target`, `@path`, `@query`, `@query-param`, `@status`) and HTTP fields with Structured-Fields header serialization; `verifyMessage()` / `verifyRequest()` and the `httpSignatureAuth()` middleware check them. Algorithms `hmac-sha256` / `ed25519` / `ecdsa-p256-sha256` / `ecdsa-p384-sha384` / `rsa-pss-sha512` / `rsa-v1_5-sha256` via WebCrypto (no `node:` imports). Secure-by-default verify: a **mandatory `algorithms` allowlist** (no implicit "any alg"), optional per-key alg pinning to defeat algorithm-confusion, a required `created` timestamp with a 300s freshness window, `created`-in-future / `expires` skew rejection, configurable `requiredComponents`, a 32-byte raw-HMAC floor, and `nonce` replay defense; the middleware answers a missing/invalid signature with `401` + `Cache-Control: no-store` and stamps the verified result on `ctx.state.httpSignature`. Ships RFC 9530 `contentDigest()` / `verifyContentDigest()` to bind the request body. Zero runtime dependencies.
|
|
598
|
+
- HTTP Message Signatures (RFC 9421) at `@daloyjs/core/http-signatures`: first-party sign/verify for server-to-server request authentication via the standard `Signature` / `Signature-Input` headers — complements the inbound-only webhook HMAC and `clientCertAuth()` mTLS. `signMessage()` / `signRequest()` build an RFC 9421 signature base over derived components (`@method`, `@target-uri`, `@authority`, `@scheme`, `@request-target`, `@path`, `@query`, `@query-param`, `@status`) and HTTP fields with Structured-Fields header serialization; `verifyMessage()` / `verifyRequest()` and the `httpSignatureAuth()` middleware check them. Algorithms `hmac-sha256` / `ed25519` / `ecdsa-p256-sha256` / `ecdsa-p384-sha384` / `rsa-pss-sha512` / `rsa-v1_5-sha256` via WebCrypto (no `node:` imports). Secure-by-default verify: a **mandatory `algorithms` allowlist** (no implicit "any alg"), optional per-key alg pinning to defeat algorithm-confusion, a required `created` timestamp with a 300s freshness window, `created`-in-future / `expires` skew rejection, configurable `requiredComponents`, a 32-byte raw-HMAC floor, a 2048-bit RSA modulus floor (NIST SP 800-131A, parity with the JWT verifier), and `nonce` replay defense; the middleware answers a missing/invalid signature with `401` + `Cache-Control: no-store` and stamps the verified result on `ctx.state.httpSignature`. Ships RFC 9530 `contentDigest()` / `verifyContentDigest()` to bind the request body. Zero runtime dependencies.
|
|
597
599
|
- `compression()` built on web-standard `CompressionStream` (prefers `br` > `gzip` > `deflate`), with BREACH-aware always-on guards (skips `Set-Cookie`, `Authorization`, session / CSRF cookies, already-compressed content types), `minimumSize: 1024`, negative-compression-ratio post-check, no configurable `compressLevel` knob (CPU-DoS defense — `level: 9` is refused at construction), always-on `Vary: Accept-Encoding`, and strong → weak ETag downgrade per RFC 9110 §8.8.3.
|
|
598
600
|
- `etag()` helper auto-skips on `Set-Cookie` and private / no-store / no-cache `Cache-Control` (cross-tenant fingerprinting defense).
|
|
599
601
|
- `timing` / `timingSafeEqual` helpers.
|
package/bin/daloy.mjs
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* `daloy` CLI shim. Real logic lives in `dist/cli.js` (`src/cli.ts`).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* TypeScript entry files are imported directly — Node.js >= 22.18 strips
|
|
6
|
+
* erasable TypeScript syntax natively. If the native load fails (older
|
|
7
|
+
* Node, non-erasable syntax such as enums, or extensionless relative
|
|
8
|
+
* imports) we fall back to registering `tsx` when the consumer project has
|
|
9
|
+
* it installed; otherwise we surface a friendly error.
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
12
|
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
@@ -20,17 +22,33 @@ const PKG = JSON.parse(
|
|
|
20
22
|
|
|
21
23
|
const TS_EXT = /\.(ts|tsx|mts|cts)$/i;
|
|
22
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Error codes that mean the host Node.js could not load a TypeScript file
|
|
27
|
+
* natively — the cases where falling back to a transpiling loader (tsx)
|
|
28
|
+
* can still succeed.
|
|
29
|
+
*/
|
|
30
|
+
const NATIVE_TS_ERROR_CODES = new Set([
|
|
31
|
+
// Type stripping disabled (--no-strip-types) or Node too old.
|
|
32
|
+
"ERR_UNKNOWN_FILE_EXTENSION",
|
|
33
|
+
// Non-erasable syntax: enums, runtime namespaces, parameter properties.
|
|
34
|
+
"ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX",
|
|
35
|
+
// Extensionless relative imports that native stripping refuses to resolve.
|
|
36
|
+
"ERR_MODULE_NOT_FOUND",
|
|
37
|
+
]);
|
|
38
|
+
|
|
23
39
|
let tsxRegistered = false;
|
|
24
|
-
async function
|
|
25
|
-
if (!TS_EXT.test(specifier) || tsxRegistered) return;
|
|
40
|
+
async function registerTsxFallback(specifier, cause) {
|
|
26
41
|
try {
|
|
27
42
|
const api = await import("tsx/esm/api");
|
|
28
43
|
api.register();
|
|
29
44
|
tsxRegistered = true;
|
|
30
45
|
} catch {
|
|
31
46
|
throw new Error(
|
|
32
|
-
`Loading TypeScript entry "${specifier}"
|
|
33
|
-
|
|
47
|
+
`Loading TypeScript entry "${specifier}" failed: ${cause?.message ?? cause}\n` +
|
|
48
|
+
"Node.js runs erasable-only TypeScript natively (>= 22.18). If the entry uses " +
|
|
49
|
+
"non-erasable syntax (enums, runtime namespaces, parameter properties) or " +
|
|
50
|
+
"extensionless relative imports, install tsx (`pnpm add -D tsx`) and re-run.",
|
|
51
|
+
{ cause }
|
|
34
52
|
);
|
|
35
53
|
}
|
|
36
54
|
}
|
|
@@ -40,8 +58,18 @@ async function importEntry(specifier) {
|
|
|
40
58
|
if (!existsSync(abs)) {
|
|
41
59
|
throw new Error(`Entry file not found: ${abs}`);
|
|
42
60
|
}
|
|
43
|
-
|
|
44
|
-
|
|
61
|
+
const href = pathToFileURL(abs).href;
|
|
62
|
+
if (!TS_EXT.test(abs) || tsxRegistered) return import(href);
|
|
63
|
+
try {
|
|
64
|
+
return await import(href);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (!NATIVE_TS_ERROR_CODES.has(err?.code)) throw err;
|
|
67
|
+
await registerTsxFallback(abs, err);
|
|
68
|
+
// Cache-bust so the retried load resolves through tsx's hooks instead of
|
|
69
|
+
// the failed native module job. The first attempt failed before
|
|
70
|
+
// evaluation, so no side effects ran twice.
|
|
71
|
+
return import(`${href}?daloy-tsx-fallback=1`);
|
|
72
|
+
}
|
|
45
73
|
}
|
|
46
74
|
|
|
47
75
|
function spawnDev(command, args) {
|
|
@@ -62,7 +90,7 @@ function spawnDev(command, args) {
|
|
|
62
90
|
new Error(
|
|
63
91
|
`\`${command}\` was not found on PATH. ` +
|
|
64
92
|
(command === "node"
|
|
65
|
-
? "
|
|
93
|
+
? "Ensure Node.js is on PATH."
|
|
66
94
|
: `Install ${command} or run daloy dev from the runtime that hosts it.`)
|
|
67
95
|
)
|
|
68
96
|
);
|
package/dist/app.d.ts
CHANGED
|
@@ -1107,19 +1107,32 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
|
|
|
1107
1107
|
/**
|
|
1108
1108
|
* First-request boot guard. Verifies that the assembled hook
|
|
1109
1109
|
* chain + route table is internally consistent before any user handler
|
|
1110
|
-
* runs. Currently checks
|
|
1111
|
-
*
|
|
1112
|
-
* a `
|
|
1113
|
-
*
|
|
1114
|
-
* hook
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
1110
|
+
* runs. Currently checks (production + `secureDefaults` only):
|
|
1111
|
+
*
|
|
1112
|
+
* 1. **Shadow auth** — a route that declares an `auth:` requirement (so it is
|
|
1113
|
+
* advertised as protected in the OpenAPI `security` list) must have an
|
|
1114
|
+
* authentication hook ({@link AUTH_HOOK_MARKER}) in its effective chain.
|
|
1115
|
+
* Otherwise it accepts unauthenticated requests while claiming protection.
|
|
1116
|
+
* 2. **Unauthenticated MCP** — a route from {@link mcpRoutes} must have an
|
|
1117
|
+
* auth hook unless it opted out with `mcpRoutes(path, handler, { public: true })`.
|
|
1118
|
+
* MCP tools are model-controlled and side-effecting, so a public one is a
|
|
1119
|
+
* high-impact default.
|
|
1120
|
+
* 3. **Missing CSRF** — when `session()` is installed and any route accepts a
|
|
1121
|
+
* state-changing method (`POST`/`PUT`/`PATCH`/`DELETE`), a `csrf()` hook
|
|
1122
|
+
* (or third-party equivalent stamped with {@link CSRF_HOOK_MARKER}) must
|
|
1123
|
+
* also be present. Skipped when `app({ csrf: "off" })`.
|
|
1124
|
+
*
|
|
1125
|
+
* Opt out of all guards with `app({ secureDefaults: false })`. Runs once per
|
|
1126
|
+
* App between registration changes; the result is cached so the fast path is a
|
|
1127
|
+
* single boolean check.
|
|
1117
1128
|
*/
|
|
1118
1129
|
private assertBootGuards;
|
|
1119
1130
|
/**
|
|
1120
1131
|
* Per-request guard for spoofed proxy headers. When the App was
|
|
1121
1132
|
* constructed without an explicit {@link AppOptions.trustProxy} value
|
|
1122
|
-
* and a request arrives carrying
|
|
1133
|
+
* and a request arrives carrying a spoofable forwarded / client-IP header
|
|
1134
|
+
* (`X-Forwarded-*`, `X-Real-IP`, or a vendor header like `CF-Connecting-IP`,
|
|
1135
|
+
* `Fly-Client-IP`, `True-Client-IP`), refuse to
|
|
1123
1136
|
* dispatch it: the rate limiter, audit log, and request-id propagation
|
|
1124
1137
|
* would otherwise honour the attacker-supplied IP. Returns a structured
|
|
1125
1138
|
* `500 problem+json` so the failure is loud at the network boundary.
|
package/dist/app.js
CHANGED
|
@@ -6,7 +6,7 @@ import { createLogger, noopLogger } from "./logger.js";
|
|
|
6
6
|
import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
|
|
7
7
|
import { asyncapiHtml, docsContentSecurityPolicy, redocHtml, scalarHtml, swaggerUiHtml, } from "./docs.js";
|
|
8
8
|
import { generateAsyncAPI, asyncapiToYAML, } from "./asyncapi.js";
|
|
9
|
-
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";
|
|
10
10
|
import { COMPRESSION_HOOK_MARKER } from "./compression.js";
|
|
11
11
|
import { SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER } from "./session.js";
|
|
12
12
|
import { loadShedding as loadSheddingMiddleware, } from "./load-shedding.js";
|
|
@@ -118,6 +118,13 @@ const CANONICAL_HTTP_METHODS = new Set([
|
|
|
118
118
|
"HEAD",
|
|
119
119
|
"OPTIONS",
|
|
120
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");
|
|
121
128
|
/**
|
|
122
129
|
* Apply a topology-aware security preset on top of caller-supplied
|
|
123
130
|
* options. Returns a new options object where preset defaults fill in
|
|
@@ -906,13 +913,24 @@ export class App {
|
|
|
906
913
|
/**
|
|
907
914
|
* First-request boot guard. Verifies that the assembled hook
|
|
908
915
|
* chain + route table is internally consistent before any user handler
|
|
909
|
-
* runs. Currently checks
|
|
910
|
-
*
|
|
911
|
-
* a `
|
|
912
|
-
*
|
|
913
|
-
* hook
|
|
914
|
-
*
|
|
915
|
-
*
|
|
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.
|
|
916
934
|
*/
|
|
917
935
|
assertBootGuards() {
|
|
918
936
|
if (this.bootGuard.checked) {
|
|
@@ -923,13 +941,40 @@ export class App {
|
|
|
923
941
|
this.bootGuard.checked = true;
|
|
924
942
|
if (this.options.secureDefaults === false)
|
|
925
943
|
return;
|
|
926
|
-
if (this.options.csrf === "off")
|
|
927
|
-
return;
|
|
928
944
|
// Per the risk register: boot guards only fire in production so CI /
|
|
929
945
|
// staging surfaces that ship sample secrets / no CSRF token while
|
|
930
946
|
// iterating do not pay the refuse-to-boot cost.
|
|
931
947
|
if (!this.isProduction())
|
|
932
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;
|
|
933
978
|
const stateChanging = this.routeSecurityMarkers.find((r) => isStateChangingMethod(r.method) && r.hasSession && !r.hasCsrf);
|
|
934
979
|
if (!stateChanging)
|
|
935
980
|
return;
|
|
@@ -946,7 +991,9 @@ export class App {
|
|
|
946
991
|
/**
|
|
947
992
|
* Per-request guard for spoofed proxy headers. When the App was
|
|
948
993
|
* constructed without an explicit {@link AppOptions.trustProxy} value
|
|
949
|
-
* 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
|
|
950
997
|
* dispatch it: the rate limiter, audit log, and request-id propagation
|
|
951
998
|
* would otherwise honour the attacker-supplied IP. Returns a structured
|
|
952
999
|
* `500 problem+json` so the failure is loud at the network boundary.
|
|
@@ -976,6 +1023,13 @@ export class App {
|
|
|
976
1023
|
"x-forwarded-proto",
|
|
977
1024
|
"x-forwarded-port",
|
|
978
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",
|
|
979
1033
|
]) {
|
|
980
1034
|
if (headers.has(name)) {
|
|
981
1035
|
found = name;
|
|
@@ -1388,6 +1442,8 @@ export class App {
|
|
|
1388
1442
|
method: merged.method,
|
|
1389
1443
|
path: merged.path,
|
|
1390
1444
|
...securityMarkers,
|
|
1445
|
+
declaresAuth: merged.auth !== undefined && merged.auth !== null,
|
|
1446
|
+
isMcp: merged[MCP_ROUTE_MARKER] === true,
|
|
1391
1447
|
});
|
|
1392
1448
|
this.resetBootGuardCache();
|
|
1393
1449
|
return this;
|
|
@@ -2887,14 +2943,17 @@ export function topoSortExtensions(exts) {
|
|
|
2887
2943
|
function securityMarkersFromHooks(layers) {
|
|
2888
2944
|
let hasSession = false;
|
|
2889
2945
|
let hasCsrf = false;
|
|
2946
|
+
let hasAuth = false;
|
|
2890
2947
|
for (const hooks of layers) {
|
|
2891
2948
|
const record = hooks;
|
|
2892
2949
|
if (record[SESSION_HOOK_MARKER] === true)
|
|
2893
2950
|
hasSession = true;
|
|
2894
2951
|
if (record[CSRF_HOOK_MARKER] === true)
|
|
2895
2952
|
hasCsrf = true;
|
|
2953
|
+
if (record[AUTH_HOOK_MARKER] === true)
|
|
2954
|
+
hasAuth = true;
|
|
2896
2955
|
}
|
|
2897
|
-
return { hasSession, hasCsrf };
|
|
2956
|
+
return { hasSession, hasCsrf, hasAuth };
|
|
2898
2957
|
}
|
|
2899
2958
|
function isStateChangingMethod(method) {
|
|
2900
2959
|
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
|
package/dist/cli.d.ts
CHANGED
|
@@ -130,8 +130,11 @@ export declare function normalizeEntryArg(entry: string): string;
|
|
|
130
130
|
* runtime and entry file. Pure function so tests can assert exact argv
|
|
131
131
|
* without spawning a child process.
|
|
132
132
|
*
|
|
133
|
-
* - Node: `node --
|
|
134
|
-
*
|
|
133
|
+
* - Node: `node --watch <entry>` (Node >= 22.18 strips TypeScript types
|
|
134
|
+
* natively, so `.ts` entries with erasable-only syntax run without a
|
|
135
|
+
* loader; `.js` entries also work. Projects that need non-erasable
|
|
136
|
+
* syntax — enums, runtime namespaces, parameter properties — should
|
|
137
|
+
* transpile or add a loader such as tsx themselves).
|
|
135
138
|
* - Bun: `bun --hot <entry>` (Bun ships with TS support).
|
|
136
139
|
* - Deno: `deno run --watch --allow-net --allow-env --allow-read <entry>`
|
|
137
140
|
* (the three permissions cover serving HTTP, reading env vars, and
|
package/dist/cli.js
CHANGED
|
@@ -22,8 +22,9 @@ Usage:
|
|
|
22
22
|
Commands:
|
|
23
23
|
inspect [entry] Load an App and print its routes (default command).
|
|
24
24
|
dev [entry] Start the entry file with the host runtime's
|
|
25
|
-
native watch mode (
|
|
26
|
-
Bun, --watch on Deno).
|
|
25
|
+
native watch mode (node --watch on Node, --hot on
|
|
26
|
+
Bun, --watch on Deno). Node runs TypeScript
|
|
27
|
+
entries via its built-in type stripping.
|
|
27
28
|
doctor [entry] Audit a loaded App's secure-by-default posture.
|
|
28
29
|
Exits non-zero on any violation so the
|
|
29
30
|
command can guard container HEALTHCHECK and CI
|
|
@@ -186,8 +187,11 @@ export function normalizeEntryArg(entry) {
|
|
|
186
187
|
* runtime and entry file. Pure function so tests can assert exact argv
|
|
187
188
|
* without spawning a child process.
|
|
188
189
|
*
|
|
189
|
-
* - Node: `node --
|
|
190
|
-
*
|
|
190
|
+
* - Node: `node --watch <entry>` (Node >= 22.18 strips TypeScript types
|
|
191
|
+
* natively, so `.ts` entries with erasable-only syntax run without a
|
|
192
|
+
* loader; `.js` entries also work. Projects that need non-erasable
|
|
193
|
+
* syntax — enums, runtime namespaces, parameter properties — should
|
|
194
|
+
* transpile or add a loader such as tsx themselves).
|
|
191
195
|
* - Bun: `bun --hot <entry>` (Bun ships with TS support).
|
|
192
196
|
* - Deno: `deno run --watch --allow-net --allow-env --allow-read <entry>`
|
|
193
197
|
* (the three permissions cover serving HTTP, reading env vars, and
|
|
@@ -219,7 +223,7 @@ export function buildDevCommand(runtime, entry) {
|
|
|
219
223
|
};
|
|
220
224
|
case "node":
|
|
221
225
|
default:
|
|
222
|
-
return { command: "node", args: ["--
|
|
226
|
+
return { command: "node", args: ["--watch", safe] };
|
|
223
227
|
}
|
|
224
228
|
}
|
|
225
229
|
/**
|
package/dist/http-signatures.js
CHANGED
|
@@ -48,6 +48,14 @@ export const DEFAULT_SIGNATURE_CLOCK_SKEW_SECONDS = 60;
|
|
|
48
48
|
const MAX_HEADER_LENGTH = 8192;
|
|
49
49
|
/** Minimum byte length for a raw HMAC secret (RFC 7518 §3.2). */
|
|
50
50
|
const MIN_HMAC_KEY_BYTES = 32;
|
|
51
|
+
/**
|
|
52
|
+
* Minimum RSA modulus size accepted for `rsa-*` signature algorithms. NIST
|
|
53
|
+
* SP 800-131A has disallowed RSA keys shorter than 2048 bits since 2014; the
|
|
54
|
+
* JWT verifier ({@link file://./jwt.ts}) enforces the same floor, so the HTTP
|
|
55
|
+
* Message Signatures path holds the parity to keep undersized (crackable) RSA
|
|
56
|
+
* keys out of every signature-verification surface in the framework.
|
|
57
|
+
*/
|
|
58
|
+
const MIN_RSA_KEY_BITS = 2048;
|
|
51
59
|
const ENC = new TextEncoder();
|
|
52
60
|
// ---------------------------------------------------------------------------
|
|
53
61
|
// WebCrypto + encoding helpers
|
|
@@ -132,11 +140,37 @@ function algSpec(alg) {
|
|
|
132
140
|
};
|
|
133
141
|
}
|
|
134
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Refuse RSA keys whose modulus is shorter than {@link MIN_RSA_KEY_BITS}.
|
|
145
|
+
*
|
|
146
|
+
* Only applies to the `rsa-*` algorithms — non-RSA keys are ignored. Every RSA
|
|
147
|
+
* `CryptoKey` carries a numeric `algorithm.modulusLength`; when WebCrypto
|
|
148
|
+
* reports a length below the floor the key is refused for both signing and
|
|
149
|
+
* verification. Mirrors the JWT verifier's `assertRsaModulusFloor` so no
|
|
150
|
+
* signature surface in the framework accepts an undersized RSA key.
|
|
151
|
+
*
|
|
152
|
+
* @param alg - The HTTP signature algorithm the key will be used with.
|
|
153
|
+
* @param key - The imported (or caller-supplied) `CryptoKey`.
|
|
154
|
+
* @throws {TypeError} When `alg` is RSA and the modulus is under the floor.
|
|
155
|
+
*/
|
|
156
|
+
function assertRsaModulusFloor(alg, key) {
|
|
157
|
+
if (alg !== "rsa-pss-sha512" && alg !== "rsa-v1_5-sha256")
|
|
158
|
+
return;
|
|
159
|
+
const algorithm = key.algorithm;
|
|
160
|
+
const modulusLength = algorithm?.modulusLength;
|
|
161
|
+
if (typeof modulusLength !== "number" || !Number.isFinite(modulusLength))
|
|
162
|
+
return;
|
|
163
|
+
if (modulusLength < MIN_RSA_KEY_BITS) {
|
|
164
|
+
throw new TypeError(`http-signatures: ${alg} key modulus must be at least ${MIN_RSA_KEY_BITS} bits (NIST SP 800-131A); got ${modulusLength}.`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
135
167
|
async function importKey(alg, material, usage) {
|
|
136
168
|
const spec = algSpec(alg);
|
|
137
169
|
const c = getCrypto();
|
|
138
|
-
if (isCryptoKey(material))
|
|
170
|
+
if (isCryptoKey(material)) {
|
|
171
|
+
assertRsaModulusFloor(alg, material);
|
|
139
172
|
return material;
|
|
173
|
+
}
|
|
140
174
|
if (material instanceof Uint8Array) {
|
|
141
175
|
if (!spec.symmetric) {
|
|
142
176
|
throw new TypeError(`http-signatures: raw byte keys are only supported for hmac-sha256; got ${alg}.`);
|
|
@@ -147,9 +181,11 @@ async function importKey(alg, material, usage) {
|
|
|
147
181
|
return c.subtle.importKey("raw", material, spec.importParams, false, [usage]);
|
|
148
182
|
}
|
|
149
183
|
if (isJsonWebKey(material)) {
|
|
150
|
-
|
|
184
|
+
const key = await c.subtle.importKey("jwk", material, spec.importParams, false, [
|
|
151
185
|
usage,
|
|
152
186
|
]);
|
|
187
|
+
assertRsaModulusFloor(alg, key);
|
|
188
|
+
return key;
|
|
153
189
|
}
|
|
154
190
|
throw new TypeError("http-signatures: unsupported key material.");
|
|
155
191
|
}
|
|
@@ -731,7 +767,7 @@ export function verifyRequest(request, opts) {
|
|
|
731
767
|
export function httpSignatureAuth(opts) {
|
|
732
768
|
const stateKey = opts.stateKey ?? "httpSignature";
|
|
733
769
|
const message = opts.message ?? "Valid HTTP message signature required";
|
|
734
|
-
|
|
770
|
+
const authHooks = {
|
|
735
771
|
async beforeHandle(ctx) {
|
|
736
772
|
const headers = ctx.request.headers;
|
|
737
773
|
if (opts.optional && !headers.has("signature"))
|
|
@@ -744,6 +780,11 @@ export function httpSignatureAuth(opts) {
|
|
|
744
780
|
return undefined;
|
|
745
781
|
},
|
|
746
782
|
};
|
|
783
|
+
// Same global symbol as middleware's AUTH_HOOK_MARKER (stamped inline to keep
|
|
784
|
+
// the middleware module out of this bundle): lets the route-auth boot guard
|
|
785
|
+
// recognize that a route declaring `auth:` is actually enforced here.
|
|
786
|
+
authHooks[Symbol.for("daloyjs.auth.hook")] = true;
|
|
787
|
+
return authHooks;
|
|
747
788
|
}
|
|
748
789
|
const CONTENT_DIGEST_HASH = {
|
|
749
790
|
"sha-256": "SHA-256",
|
package/dist/index.d.ts
CHANGED
|
@@ -19,11 +19,11 @@ export type { StandardSchemaV1 } from "./schema.js";
|
|
|
19
19
|
export { validate, isStandardSchema } from "./schema.js";
|
|
20
20
|
export { diffOpenAPI, hasBreakingChanges } from "./openapi-diff.js";
|
|
21
21
|
export type { ChangeSeverity, OpenAPIChange, OpenAPIDiffResult } from "./openapi-diff.js";
|
|
22
|
-
export { MCP_DEFAULT_MAX_BODY_BYTES, MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSIONS, McpToolError, createMcpHandler, mcpRoutes, } from "./mcp.js";
|
|
23
|
-
export type { McpContent, McpEmbeddedResourceContent, McpHandler, McpHandlerOptions, McpIcon, McpImageContent, McpJsonObject, McpJsonRpcId, McpJsonSchema, McpJsonValue, McpPrompt, McpPromptArgument, McpPromptDefinition, McpPromptMessage, McpPromptResult, McpRequestContext, McpResource, McpResourceContents, McpResourceDefinition, McpResourceTemplate, McpResourceTemplateDefinition, McpServerInfo, McpTextContent, McpTool, McpToolAnnotations, McpToolHandler, McpToolResult, } from "./mcp.js";
|
|
22
|
+
export { MCP_DEFAULT_MAX_BODY_BYTES, MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSIONS, McpToolError, createMcpHandler, mcpRoutes, validateMcpInput, } from "./mcp.js";
|
|
23
|
+
export type { McpContent, McpEmbeddedResourceContent, McpHandler, McpHandlerOptions, McpIcon, McpImageContent, McpJsonObject, McpJsonRpcId, McpJsonSchema, McpJsonValue, McpPrompt, McpPromptArgument, McpPromptDefinition, McpPromptMessage, McpPromptResult, McpRequestContext, McpResource, McpResourceContents, McpResourceDefinition, McpResourceTemplate, McpResourceTemplateDefinition, McpRoutesOptions, McpServerInfo, McpTextContent, McpTool, McpToolAnnotations, McpToolHandler, McpToolResult, } from "./mcp.js";
|
|
24
24
|
export { readBodyLimited, safeJsonParse, isForbiddenObjectKey, sanitizeHeaderName, sanitizeHeaderValue, timingSafeEqual, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, RESERVED_INBOUND_HEADER_PREFIXES, SMUGGLING_SINGLETON_HEADERS, verifyWebhookSignature, signWebhookPayload, WEBHOOK_DEFAULT_TOLERANCE_SECONDS, assertStrongSecret, MIN_PROD_SECRET_BYTES, WEAK_SECRET_STRINGS, sanitizeFilename, assertSafeRelativePath, hasMongoOperatorKeys, assertNoMongoOperators, } from "./security.js";
|
|
25
25
|
export type { WebhookHmacAlgorithm } from "./security.js";
|
|
26
|
-
export { requestId, secureHeaders, SECURE_HEADERS_MARKER, cors, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, rateLimit, loginThrottle, timing, bearerAuth, basicAuth, csrf, CSRF_HOOK_MARKER, fetchMetadata, requireScopes, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, _resetSharedRateLimitStoresForTests, } from "./middleware.js";
|
|
26
|
+
export { requestId, secureHeaders, SECURE_HEADERS_MARKER, cors, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, rateLimit, loginThrottle, timing, bearerAuth, basicAuth, markAuthHook, AUTH_HOOK_MARKER, csrf, CSRF_HOOK_MARKER, fetchMetadata, requireScopes, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, _resetSharedRateLimitStoresForTests, } from "./middleware.js";
|
|
27
27
|
export { etag } from "./etag.js";
|
|
28
28
|
export type { ETagOptions } from "./etag.js";
|
|
29
29
|
export { compression, COMPRESSION_HOOK_MARKER, _resetCompressionRuntimeProbeForTests, } from "./compression.js";
|
package/dist/index.js
CHANGED
|
@@ -11,9 +11,9 @@ export { defineDependency, DEPENDENCY_MARKER } from "./dependency.js";
|
|
|
11
11
|
export { HttpError, BadRequestError, ValidationError, NotFoundError, ConflictError, UnauthorizedError, ForbiddenError, MethodNotAllowedError, PayloadTooLargeError, RequestHeaderFieldsTooLargeError, UnsupportedMediaTypeError, TooManyRequestsError, RequestTimeoutError, InternalError, MessageLeakError, httpError, SAFE_CUSTOM_ERROR_RESPONSE_HEADERS, checkCustomErrorResponseHeaders, } from "./errors.js";
|
|
12
12
|
export { validate, isStandardSchema } from "./schema.js";
|
|
13
13
|
export { diffOpenAPI, hasBreakingChanges } from "./openapi-diff.js";
|
|
14
|
-
export { MCP_DEFAULT_MAX_BODY_BYTES, MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSIONS, McpToolError, createMcpHandler, mcpRoutes, } from "./mcp.js";
|
|
14
|
+
export { MCP_DEFAULT_MAX_BODY_BYTES, MCP_PROTOCOL_VERSION, MCP_PROTOCOL_VERSIONS, McpToolError, createMcpHandler, mcpRoutes, validateMcpInput, } from "./mcp.js";
|
|
15
15
|
export { readBodyLimited, safeJsonParse, isForbiddenObjectKey, sanitizeHeaderName, sanitizeHeaderValue, timingSafeEqual, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, RESERVED_INBOUND_HEADER_PREFIXES, SMUGGLING_SINGLETON_HEADERS, verifyWebhookSignature, signWebhookPayload, WEBHOOK_DEFAULT_TOLERANCE_SECONDS, assertStrongSecret, MIN_PROD_SECRET_BYTES, WEAK_SECRET_STRINGS, sanitizeFilename, assertSafeRelativePath, hasMongoOperatorKeys, assertNoMongoOperators, } from "./security.js";
|
|
16
|
-
export { requestId, secureHeaders, SECURE_HEADERS_MARKER, cors, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, rateLimit, loginThrottle, timing, bearerAuth, basicAuth, csrf, CSRF_HOOK_MARKER, fetchMetadata, requireScopes, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, _resetSharedRateLimitStoresForTests, } from "./middleware.js";
|
|
16
|
+
export { requestId, secureHeaders, SECURE_HEADERS_MARKER, cors, CORS_HOOK_MARKER, CORS_ORIGIN_ALLOW_MARKER, CORS_WILDCARD_ORIGIN_MARKER, rateLimit, loginThrottle, timing, bearerAuth, basicAuth, markAuthHook, AUTH_HOOK_MARKER, csrf, CSRF_HOOK_MARKER, fetchMetadata, requireScopes, REQUIRE_SCOPES_AGGREGATE_KEY, REQUIRE_SCOPES_HOOK_MARKER, _resetSharedRateLimitStoresForTests, } from "./middleware.js";
|
|
17
17
|
export { etag } from "./etag.js";
|
|
18
18
|
export { compression, COMPRESSION_HOOK_MARKER, _resetCompressionRuntimeProbeForTests, } from "./compression.js";
|
|
19
19
|
export { createJwtSigner, createJwtVerifier, JwtError, DEFAULT_JWT_MAX_LIFETIME_SECONDS, } from "./jwt.js";
|
package/dist/ip-reputation.d.ts
CHANGED
|
@@ -160,8 +160,12 @@ export interface UrlFeedOptions {
|
|
|
160
160
|
/** Feed name. Defaults to the URL. */
|
|
161
161
|
name?: string;
|
|
162
162
|
/**
|
|
163
|
-
* Custom `fetch` implementation. Defaults to
|
|
164
|
-
*
|
|
163
|
+
* Custom `fetch` implementation. Defaults to an SSRF-hardened
|
|
164
|
+
* {@link fetchGuard} instance so a compromised or malicious feed host cannot
|
|
165
|
+
* redirect the request into internal/link-local space (cloud metadata, etc.).
|
|
166
|
+
* Override with your own client for a non-standard runtime, or with
|
|
167
|
+
* `fetchGuard({ allowPrivate: true })` for an intentionally internal feed
|
|
168
|
+
* mirror.
|
|
165
169
|
*/
|
|
166
170
|
fetchImpl?: typeof fetch;
|
|
167
171
|
/** Extra request headers (e.g. an API token for a commercial feed). */
|
|
@@ -174,6 +178,8 @@ export interface UrlFeedOptions {
|
|
|
174
178
|
* are skipped by {@link ipReputation}, so a partially-malformed feed still loads
|
|
175
179
|
* its good entries.
|
|
176
180
|
*
|
|
181
|
+
* The outbound fetch is SSRF-hardened by default (see {@link UrlFeedOptions.fetchImpl}).
|
|
182
|
+
*
|
|
177
183
|
* @param url - The feed URL.
|
|
178
184
|
* @param opts - Optional feed name, custom `fetch`, and request headers.
|
|
179
185
|
* @returns A feed ready to pass to {@link IpReputationOptions.feeds}.
|
package/dist/ip-reputation.js
CHANGED
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
* @since 0.37.0
|
|
46
46
|
*/
|
|
47
47
|
import { ForbiddenError } from "./errors.js";
|
|
48
|
+
import { fetchGuard } from "./fetch-guard.js";
|
|
48
49
|
import { compileCidrMatcher, matchesMatcher, parseIp, } from "./ip-restriction.js";
|
|
49
50
|
const DEFAULT_REFRESH_MS = 60 * 60_000;
|
|
50
51
|
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
|
@@ -74,6 +75,8 @@ function parseFeedLine(line) {
|
|
|
74
75
|
* are skipped by {@link ipReputation}, so a partially-malformed feed still loads
|
|
75
76
|
* its good entries.
|
|
76
77
|
*
|
|
78
|
+
* The outbound fetch is SSRF-hardened by default (see {@link UrlFeedOptions.fetchImpl}).
|
|
79
|
+
*
|
|
77
80
|
* @param url - The feed URL.
|
|
78
81
|
* @param opts - Optional feed name, custom `fetch`, and request headers.
|
|
79
82
|
* @returns A feed ready to pass to {@link IpReputationOptions.feeds}.
|
|
@@ -81,7 +84,10 @@ function parseFeedLine(line) {
|
|
|
81
84
|
*/
|
|
82
85
|
export function urlFeed(url, opts = {}) {
|
|
83
86
|
const name = opts.name ?? url;
|
|
84
|
-
|
|
87
|
+
// Secure-by-default: route the outbound feed fetch through fetchGuard() so
|
|
88
|
+
// redirects are re-validated per hop and internal/metadata targets are
|
|
89
|
+
// refused, matching createWebhookSender's posture. Callers can override.
|
|
90
|
+
const doFetch = opts.fetchImpl ?? fetchGuard();
|
|
85
91
|
return {
|
|
86
92
|
name,
|
|
87
93
|
async fetch(signal) {
|