@daloyjs/core 0.36.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -2
- package/bin/daloy.mjs +2 -0
- package/dist/adapters/bun.js +16 -9
- package/dist/adapters/deno.js +7 -1
- package/dist/adapters/node.d.ts +11 -0
- package/dist/adapters/node.js +24 -0
- package/dist/app.d.ts +144 -1
- package/dist/app.js +208 -1
- package/dist/asyncapi.d.ts +98 -0
- package/dist/asyncapi.js +212 -0
- package/dist/auto-ban.d.ts +205 -0
- package/dist/auto-ban.js +222 -0
- package/dist/bot-guard.d.ts +209 -0
- package/dist/bot-guard.js +291 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +88 -4
- package/dist/concurrency-limit.d.ts +135 -0
- package/dist/concurrency-limit.js +254 -0
- package/dist/docs.d.ts +57 -6
- package/dist/docs.js +34 -3
- package/dist/errors.d.ts +20 -0
- package/dist/errors.js +27 -0
- package/dist/fetch-guard.js +4 -0
- package/dist/fetch-resilience.d.ts +295 -0
- package/dist/fetch-resilience.js +485 -0
- package/dist/geo-block.d.ts +184 -0
- package/dist/geo-block.js +153 -0
- package/dist/hashing.d.ts +2 -1
- package/dist/hashing.js +12 -1
- package/dist/http-signatures.d.ts +303 -0
- package/dist/http-signatures.js +782 -0
- package/dist/idempotency.d.ts +204 -0
- package/dist/idempotency.js +341 -0
- package/dist/index.d.ts +38 -4
- package/dist/index.js +18 -1
- package/dist/ip-reputation.d.ts +198 -0
- package/dist/ip-reputation.js +253 -0
- package/dist/jwk.d.ts +15 -0
- package/dist/jwk.js +24 -2
- package/dist/load-shedding.d.ts +5 -0
- package/dist/logger.js +6 -2
- package/dist/metrics.d.ts +208 -0
- package/dist/metrics.js +452 -0
- package/dist/middleware.js +0 -10
- package/dist/mtls.d.ts +266 -0
- package/dist/mtls.js +488 -0
- package/dist/multipart.js +1 -1
- package/dist/openapi-diff.d.ts +79 -0
- package/dist/openapi-diff.js +246 -0
- package/dist/openapi.js +4 -1
- package/dist/pagination.d.ts +210 -0
- package/dist/pagination.js +353 -0
- package/dist/rate-limit-redis.d.ts +8 -0
- package/dist/rate-limit-redis.js +8 -0
- package/dist/request-decompression.d.ts +200 -0
- package/dist/request-decompression.js +363 -0
- package/dist/response-cache.d.ts +205 -0
- package/dist/response-cache.js +374 -0
- package/dist/router.d.ts +22 -0
- package/dist/router.js +64 -7
- package/dist/safe-redirect.d.ts +2 -2
- package/dist/safe-redirect.js +3 -8
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/scheduler.d.ts +315 -0
- package/dist/scheduler.js +546 -0
- package/dist/security.d.ts +27 -7
- package/dist/security.js +27 -7
- package/dist/session.js +3 -3
- package/dist/types.d.ts +33 -0
- package/dist/waf.d.ts +213 -0
- package/dist/waf.js +334 -0
- package/dist/webhook-delivery.d.ts +263 -0
- package/dist/webhook-delivery.js +311 -0
- package/dist/websocket.d.ts +52 -0
- package/dist/websocket.js +13 -0
- package/package.json +76 -2
package/README.md
CHANGED
|
@@ -437,6 +437,7 @@ DaloyJS is in **public preview** (`0.x`). The public API may still change betwee
|
|
|
437
437
|
- Zero-config OpenAPI `info` autofill from `package.json` (Node / Bun) or `deno.json` / `deno.jsonc` (Deno); explicit `openapi.info` values always win.
|
|
438
438
|
- RFC 7231 + RFC 5789 HTTP-method allowlist enforced inside `app.route()` (WebDAV, `TRACE`, `CONNECT` rejected at the framework boundary).
|
|
439
439
|
- 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`.
|
|
440
|
+
- 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.
|
|
440
441
|
- In-process test client (`app.request()`), contract-test runner, in-process typed client, and Hey API codegen via `pnpm gen`.
|
|
441
442
|
|
|
442
443
|
### Runtimes and deployment
|
|
@@ -490,6 +491,23 @@ The framework refuses to start (or to construct) when configuration is unsafe:
|
|
|
490
491
|
- `jwk()` asymmetric-only JWKS middleware: refuses `HS*` at construction, cross-checks `kid` and JWT-vs-JWK `alg`, requires `https://` JWKS URLs with TTL caching + in-flight-promise dedup, normalizes `scope` / `scp` / `scopes` claims.
|
|
491
492
|
- `requireScopes()` with RFC-6750 `WWW-Authenticate: Bearer` challenge and per-request scope aggregation.
|
|
492
493
|
- `session()` with signed cookies and pluggable stores.
|
|
494
|
+
- `idempotency()` with `Idempotency-Key` fingerprinting + byte-for-byte response replay, in-flight `409`, `422` on key reuse with a different payload, and a pluggable `IdempotencyStore` (in-memory default) at `@daloyjs/core/idempotency`.
|
|
495
|
+
- `responseCache()` server-side body cache (cache-key + TTL with `s-maxage`/`max-age` orchestration, request `no-store`/`no-cache` directives, recursion-safe stale-while-revalidate, `Vary`-aware keying, `X-Cache` HIT/MISS/STALE marker, pluggable `ResponseCacheStore` in-memory default) at `@daloyjs/core/response-cache`. Never caches `Set-Cookie` or `private`/`no-store`/`no-cache` responses. Complements `etag()`/`compression()`, which do not cache bodies.
|
|
496
|
+
- `paginationQuery()` / `encodeCursor()` / `decodeCursor()` / `buildPageLinks()` / `buildLinkHeader()` cursor-pagination helpers at `@daloyjs/core/pagination`: opaque base64url cursors (length-capped, prototype-pollution-safe decode → `400` on tamper), RFC 8288 `Link` header emission with CRLF / header-injection guards, and a Standard Schema that validates `cursor`/`limit` and auto-wires both into the OpenAPI spec + typed client via `toJSONSchema()`.
|
|
497
|
+
- `app.metrics()` + `MetricsRegistry` / `httpMetrics()` Prometheus / OpenMetrics exposition at `@daloyjs/core/metrics`: dependency-free counters / gauges / histograms, RED instrumentation (`http_requests_total`, `http_request_duration_seconds`, `http_requests_in_flight`) plus process gauges, exposition-injection-safe name/label validation, a per-metric cardinality cap, and an opt-in `/metrics` route with the same hardened posture as `app.healthcheck()` (bearer token + `timingSafeEqual`, per-IP rate limit, refuse-to-boot unauthenticated in production).
|
|
498
|
+
- `resilientFetch()` + `CircuitBreaker` outbound resilience at `@daloyjs/core/fetch-resilience`: a dependency-free circuit breaker (`closed → open → half-open`), retry-with-backoff (exponential + full jitter, idempotent-method/transient-status scoped, honours `Retry-After`), and a per-call timeout (`AbortController` → `FetchTimeoutError`) designed to layer **on top of** `fetchGuard()` — an `SsrfBlockedError` is a terminal refusal that is never retried and never trips the breaker, so SSRF protection stays intact under the resilience layer.
|
|
499
|
+
- `createWebhookSender()` + `MemoryWebhookDeadLetterSink` outbound webhook delivery at `@daloyjs/core/webhook-delivery`: the outbound counterpart to `verifyWebhookSignature()` — timestamped HMAC-signed `POST`s (`webhook-id` / `webhook-timestamp` / `webhook-signature`, computed over `"<timestamp>.<body>"` and reused across retries for safe deduping), bounded retry-with-backoff (transient-status + network scoped, honours `Retry-After`), per-attempt timeout, and dead-letter semantics. Transport defaults to `fetchGuard()`, so a subscriber URL pointing at cloud metadata or a private range is refused with a terminal `SsrfBlockedError` (never retried, dead-lettered once). Zero runtime dependencies.
|
|
500
|
+
- `app.cron()` + standalone `Scheduler` in-process scheduled tasks at `@daloyjs/core/scheduler`: a queue-agnostic schedule primitive for periodic housekeeping (cache sweeps, token refresh, reconciliation). Fixed intervals or 5-field cron expressions (lists / ranges / steps / named months & days / `@hourly`–`@yearly` aliases / optional IANA `timeZone`), arithmetic cron parsing (no backtracking regex), fixed-rate **single-flight** (overlapping ticks are skipped, never run concurrently), per-run `timeoutMs` with `AbortSignal`, and graceful-shutdown integration (stop arming → await in-flight → abort after grace). Timers are `unref`'d. `parseCron()` / `nextCronRun()` exported standalone. Zero runtime dependencies.
|
|
501
|
+
- `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.
|
|
502
|
+
- `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.
|
|
503
|
+
- `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.
|
|
504
|
+
- `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.
|
|
505
|
+
- `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.
|
|
506
|
+
- `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.
|
|
507
|
+
- `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.
|
|
508
|
+
- `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.
|
|
509
|
+
- Built-in docs UI Subresource Integrity (SRI): `DocsAssetOptions` lets `scalarHtml()` / `swaggerUiHtml()` 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 `<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.
|
|
510
|
+
- 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.
|
|
493
511
|
- `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.
|
|
494
512
|
- `etag()` helper auto-skips on `Set-Cookie` and private / no-store / no-cache `Cache-Control` (cross-tenant fingerprinting defense).
|
|
495
513
|
- `timing` / `timingSafeEqual` helpers.
|
|
@@ -507,6 +525,7 @@ The framework refuses to start (or to construct) when configuration is unsafe:
|
|
|
507
525
|
- an Origin policy (`allowedOrigins: "same-origin"` / `string[]` / predicate) or `acknowledgeCrossOriginUpgrade: true`.
|
|
508
526
|
|
|
509
527
|
This closes the Cross-Site WebSocket Hijacking (CSWSH) class of bug — Storybook's [CVE-2026-27148](https://www.aikido.dev/blog/storybooks-websockets-attack) is the representative case: cookie auth alone does not stop a malicious site from opening an authenticated WS handshake from a victim's browser. The Origin check runs **before** `beforeUpgrade` in both adapters.
|
|
528
|
+
- Contract-first **AsyncAPI 3.0** generation for `app.ws()` surfaces via `@daloyjs/core/asyncapi` (`generateAsyncAPI()` / `asyncapiToYAML()`) and `daloy inspect --asyncapi`. Each route becomes a channel (address + path params) with a `receive` operation for inbound client messages and an optional `send` operation for outbound messages, described via an optional handler `meta` block (`summary` / `description` / `tags` / `send` / `receive` / `operationId`).
|
|
510
529
|
|
|
511
530
|
### Lifecycle and ops
|
|
512
531
|
|
|
@@ -536,9 +555,9 @@ A growing suite of static gates runs on every push and PR:
|
|
|
536
555
|
|
|
537
556
|
- Parity / governance / runtime-parity / routing-hardening audits: `verify:parity-audits`, `verify:governance-audits`, `verify:runtime-parity-audits`, `verify:routing-hardening-audits`.
|
|
538
557
|
- Source-tree gates: `verify:no-shrinkwrap`, `verify:no-bin-shadowing`, `verify:no-native-addons`, `verify:no-polyfill-cdns` (hijacked-CDN IOCs and typosquats), `verify:no-redos-patterns`, `verify:no-encoded-payloads`, `verify:no-invisible-unicode`, `verify:no-weak-random`, `verify:no-unsafe-buffer`, `verify:no-leaked-credentials`, `verify:no-vulnerable-sandboxes`.
|
|
539
|
-
- Agent-skill gates: `verify:no-leaky-agent-skills`, `verify:no-toxic-agent-skills`, `verify:no-toxic-skills`.
|
|
558
|
+
- Agent-skill gates: `verify:no-leaky-agent-skills`, `verify:no-toxic-agent-skills`, `verify:no-toxic-skills` — scanning every agent-instruction surface (`SKILL.md`, `AGENTS.md`, `copilot-instructions.md`, `.cursorrules`, `CLAUDE.md`, `*.instructions.md`, `*.prompt.md`); the `.cursorrules` / `CLAUDE.md` filenames cover the **TrapDoor** crypto-stealer's AI-agent-config prompt-injection persistence ([Socket, 2026-05-24](https://socket.dev/blog/trapdoor-crypto-stealer)).
|
|
540
559
|
- Dependency gates: `verify:no-runtime-deps`, `verify:dep-licenses`, `verify:known-dep-names`, `verify:lockfile-sources`, `verify:no-registry-exfiltration`, `verify:no-remote-exec`, `verify:no-lifecycle-scripts`, `verify:runtime-eol` (refuses to release on a Node line past its EOL date).
|
|
541
|
-
- IOC coverage in `verify:no-registry-exfiltration` and `verify:lockfile-sources` for active campaigns including Beamglea phishing-CDN, `naya-flore` / `nvlore-hsc` WhatsApp remote-kill-switch, the Toptal GitHub-org hijack, `xuxingfeng` and `xlsx-to-json-lh` destructive payloads, `react-login-page` keylogger, `@crypto-exploit` wallet drainers, Vietnam-Telegram-ban Fastlane typosquats, surveillance-malware packages, the Discord-webhook reconnaissance campaign, and npm-package-aliasing dependency-confusion patterns.
|
|
560
|
+
- IOC coverage in `verify:no-registry-exfiltration` and `verify:lockfile-sources` for active campaigns including Beamglea phishing-CDN, `naya-flore` / `nvlore-hsc` WhatsApp remote-kill-switch, the Toptal GitHub-org hijack, `xuxingfeng` and `xlsx-to-json-lh` destructive payloads, `react-login-page` keylogger, `@crypto-exploit` wallet drainers, Vietnam-Telegram-ban Fastlane typosquats, surveillance-malware packages, the Discord-webhook reconnaissance campaign, the `codexui-android` AI-coding-agent token theft (reads of `~/.codex/auth.json` / `~/.claude/`), and npm-package-aliasing dependency-confusion patterns.
|
|
542
561
|
- `SECURITY-CONTACTS.md` rotation file with a machine-readable ACTIVE block and `<!-- last-exercise: -->` marker; the release workflow refuses to publish when `github.actor` is not on the ACTIVE rotation.
|
|
543
562
|
- Governance floor reaffirmed by audit: top-level `permissions:` on every workflow, `persist-credentials: false` on every `actions/checkout`, 40-hex SHA pinning on every third-party `uses:`, `step-security/harden-runner` on every workflow using third-party actions, and `.github/CODEOWNERS` on privileged files.
|
|
544
563
|
- Mandatory hardware-backed 2FA for every contributor with publish access (documented in `SECURITY.md`).
|
package/bin/daloy.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
11
11
|
import { resolve, dirname } from "node:path";
|
|
12
12
|
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { readFile } from "node:fs/promises";
|
|
13
14
|
import { spawn } from "node:child_process";
|
|
14
15
|
import { runCli } from "../dist/cli.js";
|
|
15
16
|
|
|
@@ -83,6 +84,7 @@ const result = await runCli(process.argv.slice(2), {
|
|
|
83
84
|
importEntry,
|
|
84
85
|
version: PKG.version,
|
|
85
86
|
spawn: spawnDev,
|
|
87
|
+
readTextFile: (path) => readFile(resolve(process.cwd(), path), "utf8"),
|
|
86
88
|
});
|
|
87
89
|
|
|
88
90
|
process.exit(result.exitCode);
|
package/dist/adapters/bun.js
CHANGED
|
@@ -15,15 +15,22 @@ export function serve(app, opts = {}) {
|
|
|
15
15
|
return app.fetch(req);
|
|
16
16
|
}
|
|
17
17
|
: (req) => app.fetch(req),
|
|
18
|
-
error: (err) =>
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
18
|
+
error: (err) => {
|
|
19
|
+
// Last-resort handler reached only if app.fetch itself throws (it
|
|
20
|
+
// normally catches everything). Log the error server-side but never
|
|
21
|
+
// echo `err.message` to the client — that would leak internal details
|
|
22
|
+
// and bypass the framework's prod-mode error redaction. Mirrors the
|
|
23
|
+
// redacted problem+json body the Node adapter emits in writeAdapterError.
|
|
24
|
+
app.log.error({ err }, "Unhandled error in Bun fetch handler");
|
|
25
|
+
return new Response(JSON.stringify({
|
|
26
|
+
type: "https://daloyjs.dev/errors/internal",
|
|
27
|
+
title: "Internal Server Error",
|
|
28
|
+
status: 500,
|
|
29
|
+
}), {
|
|
30
|
+
status: 500,
|
|
31
|
+
headers: { "content-type": "application/problem+json" },
|
|
32
|
+
});
|
|
33
|
+
},
|
|
27
34
|
};
|
|
28
35
|
if (hasWs)
|
|
29
36
|
cfg.websocket = buildBunWebSocketConfig(app);
|
package/dist/adapters/deno.js
CHANGED
|
@@ -32,14 +32,20 @@ export function serve(app, opts = {}) {
|
|
|
32
32
|
if (stopped)
|
|
33
33
|
return;
|
|
34
34
|
stopped = true;
|
|
35
|
-
controller.abort();
|
|
36
35
|
opts.signal?.removeEventListener("abort", onSignal);
|
|
37
36
|
if (opts.handleSignals !== false && typeof D?.removeSignalListener === "function") {
|
|
38
37
|
D.removeSignalListener("SIGTERM", onSignal);
|
|
39
38
|
D.removeSignalListener("SIGINT", onSignal);
|
|
40
39
|
}
|
|
40
|
+
// Drain app-level hooks first (while the HTTP server can still respond),
|
|
41
|
+
// then ask Deno to stop gracefully — `server.shutdown()` stops accepting
|
|
42
|
+
// new connections and lets in-flight requests finish. Aborting the listen
|
|
43
|
+
// signal happens last, purely as a safety net for runtimes that lack
|
|
44
|
+
// `HttpServer.shutdown()`. Doing it the other way around (aborting first)
|
|
45
|
+
// tears the listener down before in-flight requests can complete.
|
|
41
46
|
await app.shutdown(opts.shutdownTimeoutMs ?? 10_000);
|
|
42
47
|
await server.shutdown?.();
|
|
48
|
+
controller.abort();
|
|
43
49
|
};
|
|
44
50
|
return { shutdown };
|
|
45
51
|
}
|
package/dist/adapters/node.d.ts
CHANGED
|
@@ -16,6 +16,17 @@ export interface NodeServerOptions {
|
|
|
16
16
|
handleSignals?: boolean;
|
|
17
17
|
/** Maximum HTTP header size bytes (DoS protection). Default: 16 KiB. */
|
|
18
18
|
maxHeaderBytes?: number;
|
|
19
|
+
/**
|
|
20
|
+
* Maximum number of concurrent sockets the server will accept, forwarded to
|
|
21
|
+
* Node's `server.maxConnections`. Acts as connection-layer admission
|
|
22
|
+
* control: once the limit is reached, additional incoming connections are
|
|
23
|
+
* rejected at accept time instead of being queued into the event loop,
|
|
24
|
+
* where they would otherwise inflate tail latency for everyone under
|
|
25
|
+
* overload. Pair it with an upstream load balancer / API gateway that
|
|
26
|
+
* translates the rejection into a `503 Retry-After` for clients. Leave
|
|
27
|
+
* unset for Node's default (unbounded). Default: unset.
|
|
28
|
+
*/
|
|
29
|
+
maxConnections?: number;
|
|
19
30
|
/**
|
|
20
31
|
* When true, honor `x-forwarded-proto` and `x-forwarded-host` headers when
|
|
21
32
|
* constructing the request URL. Enable this only when running behind a
|
package/dist/adapters/node.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { createServer, } from "node:http";
|
|
6
6
|
import { Readable } from "node:stream";
|
|
7
7
|
import { DALOY_RAW_BODY, DALOY_RAW_STREAM, DALOY_REQUEST_RAW_BODY } from "../app.js";
|
|
8
|
+
import { setClientCertificate, normalizePeerCertificate, } from "../mtls.js";
|
|
8
9
|
import { FrameSink, encodeFrame, encodeClosePayload, encodeSendPayload, validateUpgrade, validateSelectedSubprotocol, checkWebSocketOrigin, WS_OPCODE, WS_CLOSE_CODE, WS_READY_STATE, WS_MAX_CONTROL_PAYLOAD, WebSocketProtocolError, WebSocketPayloadTooLargeError, } from "../websocket.js";
|
|
9
10
|
/** Start a Node.js HTTP (and optional WebSocket) server bound to the given {@link App}. */
|
|
10
11
|
export function serve(app, opts = {}) {
|
|
@@ -35,6 +36,11 @@ export function serve(app, opts = {}) {
|
|
|
35
36
|
server.requestTimeout = opts.connectionTimeoutMs ?? 30_000;
|
|
36
37
|
server.headersTimeout = opts.connectionTimeoutMs ?? 30_000;
|
|
37
38
|
server.keepAliveTimeout = 5_000;
|
|
39
|
+
// Connection-layer admission control. Reject overflow sockets at accept time
|
|
40
|
+
// rather than queuing them into the event loop under overload.
|
|
41
|
+
if (typeof opts.maxConnections === "number" && opts.maxConnections > 0) {
|
|
42
|
+
server.maxConnections = opts.maxConnections;
|
|
43
|
+
}
|
|
38
44
|
const wsSockets = new Set();
|
|
39
45
|
if (app.webSocketRoutes.size > 0) {
|
|
40
46
|
server.on("upgrade", (req, socket, head) => {
|
|
@@ -90,6 +96,7 @@ function dispatchToApp(app, req, res, trustProxy, bufferedBody) {
|
|
|
90
96
|
writeAdapterError(res, e);
|
|
91
97
|
return;
|
|
92
98
|
}
|
|
99
|
+
attachClientCertificate(req, request);
|
|
93
100
|
const responseOrPromise = app.fetch(request);
|
|
94
101
|
if (responseOrPromise instanceof Promise) {
|
|
95
102
|
responseOrPromise.then((response) => {
|
|
@@ -175,6 +182,23 @@ function bufferRequestBody(req, expected) {
|
|
|
175
182
|
req.on("aborted", onErr);
|
|
176
183
|
});
|
|
177
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* Attach the TLS client certificate (if any) to the web `Request` so
|
|
187
|
+
* `clientCertAuth()` can enforce a mutual-TLS identity. The read is deferred
|
|
188
|
+
* behind a lazy thunk and only happens when a guarded route actually inspects
|
|
189
|
+
* the certificate, so plain-HTTP and ordinary TLS requests pay nothing beyond a
|
|
190
|
+
* single `encrypted` boolean check. Only runs when the peer socket is a
|
|
191
|
+
* `TLSSocket` exposing `getPeerCertificate`.
|
|
192
|
+
*/
|
|
193
|
+
function attachClientCertificate(req, request) {
|
|
194
|
+
const sock = req.socket;
|
|
195
|
+
if (!sock.encrypted || typeof sock.getPeerCertificate !== "function")
|
|
196
|
+
return;
|
|
197
|
+
setClientCertificate(request, () => {
|
|
198
|
+
const raw = sock.getPeerCertificate(true);
|
|
199
|
+
return normalizePeerCertificate(raw, sock.authorized === true);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
178
202
|
function writeAdapterError(res, e) {
|
|
179
203
|
if (!res.headersSent) {
|
|
180
204
|
res.statusCode = 500;
|
package/dist/app.d.ts
CHANGED
|
@@ -2,9 +2,11 @@ import { WebSocketRegistry, type WebSocketHandler } from "./websocket.js";
|
|
|
2
2
|
import { type Logger } from "./logger.js";
|
|
3
3
|
import type { HttpMethod, Hooks, PathString, RequestSchemas, ResponsesMap, RouteDefinition } from "./types.js";
|
|
4
4
|
import { type OpenAPIInfo, type OpenAPIOptions } from "./openapi.js";
|
|
5
|
-
import { type DocsContentSecurityPolicyOptions, type ScalarReferenceConfiguration } from "./docs.js";
|
|
5
|
+
import { type DocsAssetOptions, type DocsContentSecurityPolicyOptions, type ScalarReferenceConfiguration } from "./docs.js";
|
|
6
6
|
import { type SecureHeadersOptions } from "./middleware.js";
|
|
7
7
|
import { type LoadSheddingOptions } from "./load-shedding.js";
|
|
8
|
+
import { MetricsRegistry, type HttpMetricsOptions } from "./metrics.js";
|
|
9
|
+
import { Scheduler, type TaskDefinition, type TaskHandler } from "./scheduler.js";
|
|
8
10
|
import { type BehindProxyConfig } from "./conn-info.js";
|
|
9
11
|
/** @internal Test-only helper to reset the latch between tests. */
|
|
10
12
|
export declare function _resetCrashHandlersForTests(): void;
|
|
@@ -359,6 +361,15 @@ export interface DocsRouteOptions {
|
|
|
359
361
|
* Forwarded to {@link docsContentSecurityPolicy}.
|
|
360
362
|
*/
|
|
361
363
|
csp?: DocsContentSecurityPolicyOptions;
|
|
364
|
+
/**
|
|
365
|
+
* Override the docs UI asset URLs and pin Subresource Integrity (SRI)
|
|
366
|
+
* hashes so the browser refuses to execute a CDN asset whose bytes don't
|
|
367
|
+
* match. Pair each `*Integrity` hash with a version-pinned `*Url`, or point
|
|
368
|
+
* the URLs at self-hosted copies. See {@link DocsAssetOptions}.
|
|
369
|
+
*
|
|
370
|
+
* @since 0.37.0
|
|
371
|
+
*/
|
|
372
|
+
assets?: DocsAssetOptions;
|
|
362
373
|
}
|
|
363
374
|
/** Information passed to {@link App.onPluginInstalled} listeners. */
|
|
364
375
|
export interface PluginInstalledEvent {
|
|
@@ -434,6 +445,68 @@ export interface HealthRouteOptions {
|
|
|
434
445
|
*/
|
|
435
446
|
acknowledgeUnauthenticated?: boolean;
|
|
436
447
|
}
|
|
448
|
+
/**
|
|
449
|
+
* Configuration accepted by {@link App.metrics}. Every field is optional.
|
|
450
|
+
*
|
|
451
|
+
* The `/metrics` route inherits the same hardened posture as
|
|
452
|
+
* {@link App.healthcheck}: an optional `Authorization: Bearer <token>`
|
|
453
|
+
* compared via {@link timingSafeEqual}, a per-IP fixed-window rate limit,
|
|
454
|
+
* and a refuse-to-boot guard that blocks an unauthenticated scrape endpoint
|
|
455
|
+
* in production (metrics leak internal route names, latency, and request
|
|
456
|
+
* volume) unless a token is set or {@link MetricsRouteOptions.acknowledgeUnauthenticated}
|
|
457
|
+
* is `true`.
|
|
458
|
+
*
|
|
459
|
+
* @since 0.37.0
|
|
460
|
+
*/
|
|
461
|
+
export interface MetricsRouteOptions {
|
|
462
|
+
/** Override the default path (`/metrics`). */
|
|
463
|
+
path?: PathString;
|
|
464
|
+
/**
|
|
465
|
+
* Require `Authorization: Bearer <token>` on the scrape request, compared
|
|
466
|
+
* via {@link timingSafeEqual}. When set in production with
|
|
467
|
+
* `secureDefaults: true`, no further opt-in is required.
|
|
468
|
+
*/
|
|
469
|
+
token?: string;
|
|
470
|
+
/**
|
|
471
|
+
* Per-IP fixed-window rate limit. Defaults to `{ limit: 60, windowMs:
|
|
472
|
+
* 60_000 }` (in-memory, per-process). Pass `false` to disable.
|
|
473
|
+
*/
|
|
474
|
+
rateLimit?: {
|
|
475
|
+
limit?: number;
|
|
476
|
+
windowMs?: number;
|
|
477
|
+
} | false;
|
|
478
|
+
/**
|
|
479
|
+
* Acknowledge that the scrape endpoint is intentionally reachable without
|
|
480
|
+
* credentials in production. Required when `secureDefaults` is on and
|
|
481
|
+
* `token` is omitted; otherwise registration throws.
|
|
482
|
+
*/
|
|
483
|
+
acknowledgeUnauthenticated?: boolean;
|
|
484
|
+
/**
|
|
485
|
+
* Registry the RED metrics are recorded into and rendered from. Defaults
|
|
486
|
+
* to a fresh {@link MetricsRegistry}. Pass your own to register custom
|
|
487
|
+
* application metrics alongside the built-in HTTP series.
|
|
488
|
+
*/
|
|
489
|
+
registry?: MetricsRegistry;
|
|
490
|
+
/**
|
|
491
|
+
* Resolve the low-cardinality `route` label. Strongly recommended: return
|
|
492
|
+
* the route template (e.g. `/books/:id`) instead of the raw path.
|
|
493
|
+
* Forwarded to {@link httpMetrics}.
|
|
494
|
+
*/
|
|
495
|
+
route?: HttpMetricsOptions["route"];
|
|
496
|
+
/**
|
|
497
|
+
* Maximum distinct values for the default pathname-derived `route` label
|
|
498
|
+
* before further values collapse to `"<other>"`. Forwarded to
|
|
499
|
+
* {@link httpMetrics}. Default `100`.
|
|
500
|
+
*/
|
|
501
|
+
maxRouteCardinality?: number;
|
|
502
|
+
/** Latency histogram buckets, in seconds. Forwarded to {@link httpMetrics}. */
|
|
503
|
+
buckets?: readonly number[];
|
|
504
|
+
/**
|
|
505
|
+
* Skip RED instrumentation for matching request paths, in addition to the
|
|
506
|
+
* scrape path itself (always excluded). Forwarded to {@link httpMetrics}.
|
|
507
|
+
*/
|
|
508
|
+
exclude?: (path: string) => boolean;
|
|
509
|
+
}
|
|
437
510
|
/**
|
|
438
511
|
* Configuration accepted by {@link App.cspReportRoute}. Every field is
|
|
439
512
|
* optional.
|
|
@@ -626,6 +699,12 @@ export declare class App {
|
|
|
626
699
|
private installedPlugins;
|
|
627
700
|
private closeHooks;
|
|
628
701
|
private closeHooksRun;
|
|
702
|
+
/**
|
|
703
|
+
* Lazily-created in-process scheduler backing {@link App.cron}. Started on
|
|
704
|
+
* the first `cron()` call and stopped from an `onClose` hook so its lifecycle
|
|
705
|
+
* is tied to graceful shutdown.
|
|
706
|
+
*/
|
|
707
|
+
private scheduler?;
|
|
629
708
|
/** Idle-connection close hooks (adapter-registered, sync). */
|
|
630
709
|
private idleConnectionCloseHooks;
|
|
631
710
|
private pluginInstalledListeners;
|
|
@@ -888,6 +967,70 @@ export declare class App {
|
|
|
888
967
|
* @since 0.18.0
|
|
889
968
|
*/
|
|
890
969
|
readinesscheck(opts?: HealthRouteOptions): this;
|
|
970
|
+
/**
|
|
971
|
+
* Register an opt-in, auth-guarded Prometheus / OpenMetrics scrape route
|
|
972
|
+
* and install RED (Rate / Errors / Duration) instrumentation for every
|
|
973
|
+
* route registered **after** this call. The third observability pillar
|
|
974
|
+
* alongside the structured logger and the OpenTelemetry tracer.
|
|
975
|
+
*
|
|
976
|
+
* Exposes, in the Prometheus text exposition format:
|
|
977
|
+
* - `<prefix>http_requests_total{method,route,status}` — request counter,
|
|
978
|
+
* - `<prefix>http_request_duration_seconds{method,route}` — latency histogram,
|
|
979
|
+
* - `<prefix>http_requests_in_flight` — concurrency gauge,
|
|
980
|
+
* - process gauges (resident memory, heap used, uptime) on Node-like runtimes.
|
|
981
|
+
*
|
|
982
|
+
* The scrape route inherits the same hardened posture as
|
|
983
|
+
* {@link App.healthcheck}: optional bearer token compared via
|
|
984
|
+
* {@link timingSafeEqual}, a per-IP fixed-window rate limit, and a
|
|
985
|
+
* refuse-to-boot guard in production (an unauthenticated `/metrics`
|
|
986
|
+
* endpoint leaks internal route names, latency, and traffic volume) unless
|
|
987
|
+
* a token is supplied or `acknowledgeUnauthenticated: true` is passed.
|
|
988
|
+
*
|
|
989
|
+
* Call this **before** registering the routes you want measured — like any
|
|
990
|
+
* `app.use(...)` middleware, the instrumentation only wraps routes added
|
|
991
|
+
* afterwards. Pass `opts.registry` to register custom application metrics
|
|
992
|
+
* that are rendered alongside the built-in HTTP series.
|
|
993
|
+
*
|
|
994
|
+
* @param opts - Path, auth, rate-limit, registry, and label configuration.
|
|
995
|
+
* @returns `this` for chaining.
|
|
996
|
+
* @since 0.37.0
|
|
997
|
+
*/
|
|
998
|
+
metrics(opts?: MetricsRouteOptions): this;
|
|
999
|
+
/**
|
|
1000
|
+
* Register an in-process scheduled task (cron). The first call lazily creates
|
|
1001
|
+
* an app-managed {@link Scheduler}, wires it to the app logger, starts it,
|
|
1002
|
+
* and registers an `onClose` hook so it is drained on graceful shutdown
|
|
1003
|
+
* (in-flight runs are awaited, then aborted if they outlast the shutdown
|
|
1004
|
+
* grace period).
|
|
1005
|
+
*
|
|
1006
|
+
* The schedule is **queue-agnostic** — it runs work in *this* process on a
|
|
1007
|
+
* fixed interval or cron expression. Use it for periodic maintenance
|
|
1008
|
+
* (cache sweeps, token refresh, reconciliation) rather than as a distributed
|
|
1009
|
+
* job queue. Each task is **single-flight**: if a tick fires while the
|
|
1010
|
+
* previous run is still in progress, the tick is skipped and counted, so a
|
|
1011
|
+
* slow task can never pile up overlapping runs.
|
|
1012
|
+
*
|
|
1013
|
+
* @example
|
|
1014
|
+
* ```ts
|
|
1015
|
+
* app.cron({ name: "sweep", cron: "0 * * * *" }, async ({ signal }) => {
|
|
1016
|
+
* await purgeExpiredSessions({ signal });
|
|
1017
|
+
* });
|
|
1018
|
+
* ```
|
|
1019
|
+
*
|
|
1020
|
+
* @param def - The task definition. Exactly one of `intervalMs` or `cron`.
|
|
1021
|
+
* @param handler - The function to run on each tick.
|
|
1022
|
+
* @returns This `App` instance for chaining.
|
|
1023
|
+
* @throws {RangeError} on invalid options (see {@link Scheduler.define}).
|
|
1024
|
+
* @throws {@link CronParseError} if a `cron` expression is malformed.
|
|
1025
|
+
*/
|
|
1026
|
+
cron(def: TaskDefinition, handler: TaskHandler): this;
|
|
1027
|
+
/**
|
|
1028
|
+
* The app-managed {@link Scheduler} backing {@link App.cron}, or `undefined`
|
|
1029
|
+
* if no scheduled task has been registered. Exposed for inspection
|
|
1030
|
+
* (`getState()` / `list()`) and manual triggering (`runNow()`); the lifecycle
|
|
1031
|
+
* is owned by the app.
|
|
1032
|
+
*/
|
|
1033
|
+
get scheduledTasks(): Scheduler | undefined;
|
|
891
1034
|
private registerHealthRoute;
|
|
892
1035
|
/**
|
|
893
1036
|
* Register a built-in receiver for CSP / Reporting API
|