@nifrajs/core 5.7.0 → 5.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nifrajs/core",
3
- "version": "5.7.0",
3
+ "version": "5.8.0",
4
4
  "description": "Bun-native, contract-first HTTP framework - the router, server, and route descriptor.",
5
5
  "keywords": [
6
6
  "bun",
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * read its own package.json at runtime. `scripts/version.ts` rewrites it on every release bump and
17
17
  * `check:publish` asserts it equals `@nifrajs/core`'s package version.
18
18
  */
19
- export const VERSION = "5.7.0" as const
19
+ export const VERSION = "5.8.0" as const
20
20
 
21
21
  export type Version = typeof VERSION
22
22
 
@@ -26,7 +26,7 @@ if (!copies.includes(here) && copies.push(here) > 1) {
26
26
 
27
27
  /** This copy's version: a literal like the package root's `VERSION` (no fs on the edge), which
28
28
  * `scripts/version.ts` rewrites on every release and `check:publish` asserts. */
29
- const COPY_VERSION = "5.7.0"
29
+ const COPY_VERSION = "5.8.0"
30
30
 
31
31
  /** Record this copy's version beside its URL. Called by each `server()` behind the development guard,
32
32
  * so a production bundle drops it with the version; a copy that built a server has recorded one. */
@@ -89,6 +89,18 @@ export interface ServerOptions {
89
89
  * untrusted input.
90
90
  */
91
91
  readonly trustBodyFraming?: boolean
92
+ /**
93
+ * Answer `400 invalid_host` on Bun to every Host that Node and Deno refuse: one sent twice, one
94
+ * no URL can be built on (a space, a byte past ASCII, a port past 65535, an IPv6 literal no parser
95
+ * takes), a percent-encoded or sub-delimiter one, and serve an HTTP/1.0 request with no Host as
96
+ * `localhost`. Bun serves those requests itself, building `c.req.url` on the first Host or leaving
97
+ * it relative, so `new URL(c.req.url)` can throw in a handler.
98
+ *
99
+ * Disabled by default: the check reads the Host on every request, which measured about 6% of a bare
100
+ * route on Bun. Left off, a target with `.` or `..` segments is still checked, so a dot segment is
101
+ * never routed raw. Node and Deno refuse these Hosts either way; the option changes nothing there.
102
+ */
103
+ readonly validateHost?: boolean
92
104
  /**
93
105
  * Max inbound WebSocket message size (bytes). A larger message never reaches the handler and the
94
106
  * socket closes with `1009`; when Bun's own frame limit refuses it, Bun up to 1.4.3 drops the
@@ -713,7 +713,8 @@ function resolvedSource(req: Request, url: string): RequestSource {
713
713
  const HOST_AUTHORITY = /^(?:\[[0-9a-f:.%]+\]|[\w.-]+)(?::(\d{1,5}))?$/i
714
714
 
715
715
  // Clients repeat one Host on every request; comparing to the last valid one is ~7x cheaper than the regex.
716
- let lastValidHost = ""
716
+ // `null` until one passes, so an empty Host is never taken as the cached one.
717
+ let lastValidHost: string | null = null
717
718
 
718
719
  // A port past 65535, or an IPv6 literal no URL parser takes (`[1:2]`), is refused as Node refuses it:
719
720
  // no URL can be built on either, so resolving a dot segment against one threw out of `fetch`.
@@ -733,10 +734,11 @@ const invalidHost = (host: string): boolean => {
733
734
  * its dot segments are resolved; so does Bun when HTTP/1.0 leaves the Host out, and its route table
734
735
  * matched `/files/..` raw. Such a request is served as `localhost`, as Node and Deno serve it.
735
736
  * `headerOf`, not `req.headers`: on Deno that reads the Host alone instead of building every
736
- * header.
737
+ * header. On Bun only a target that needs resolving is checked unless `validateHost` is set.
737
738
  */
738
- const requestTarget = (req: Request): string | boolean => {
739
- if (!rawRequestTargets && !unparsedBunTargets) return false
739
+ const requestTarget = (req: Request, validateHost: boolean): string | boolean => {
740
+ if (!rawRequestTargets && !(unparsedBunTargets && (validateHost || needsResolving(req.url))))
741
+ return false
740
742
  const host = headerOf(req, "host")
741
743
  if (host !== null && invalidHost(host)) {
742
744
  if (rawRequestTargets) return true
@@ -1270,6 +1272,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1270
1272
  private readonly formOrigins: FormOrigins | undefined
1271
1273
  /** `trustBodyFraming`: mark every `app.fetch` request as runtime-framed (see ServerOptions). */
1272
1274
  private readonly trustBodyFraming: boolean
1275
+ private readonly validateHost: boolean
1273
1276
  private readonly wsMaxPayloadBytes: number
1274
1277
  private readonly requestTimeoutMs: number
1275
1278
  private readonly wsUpgradeTimeoutMs: number
@@ -1414,6 +1417,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1414
1417
  this.protoPoisoning = options.protoPoisoning ?? "reject"
1415
1418
  this.formOrigins = options.formOrigins
1416
1419
  this.trustBodyFraming = options.trustBodyFraming ?? false
1420
+ this.validateHost = options.validateHost ?? false
1417
1421
  const wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? maxBodyBytes
1418
1422
  assertByteLimit(wsMaxPayloadBytes, "wsMaxPayloadBytes")
1419
1423
  this.maxBodyBytes = maxBodyBytes
@@ -3391,7 +3395,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3391
3395
  fetch(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<Response> {
3392
3396
  // Deno's `Request.url` is the target as sent; Bun, workerd and every `new Request()` parse it.
3393
3397
  // Route what the parsed URL says, so `/users/../admin` is `/admin` on every runtime.
3394
- const target = requestTarget(req)
3398
+ const target = requestTarget(req, this.validateHost)
3395
3399
  if (target === true) return jsonError(400, "invalid_host")
3396
3400
  if (target !== false) {
3397
3401
  const rebuilt = withResolvedTarget(req, target)
@@ -3895,7 +3899,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3895
3899
  // The adapter answers a pass by handing this same object to `fetch`.
3896
3900
  const received = req
3897
3901
  // The handshake routes the path `fetch` would: Host checked, dot segments resolved (see `fetch`).
3898
- const target = requestTarget(req)
3902
+ const target = requestTarget(req, this.validateHost)
3899
3903
  if (target === true) {
3900
3904
  return { kind: "reject", response: this.wrapWebResponse(jsonError(400, "invalid_host")) }
3901
3905
  }
@@ -7048,9 +7052,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7048
7052
  peer,
7049
7053
  fallback,
7050
7054
  )
7051
- // The table matched the target as sent; one `fetch` refuses or rewrites goes through it.
7052
- tableAt(path, pattern.segments)[method as Method] = (request, server) =>
7053
- requestTarget(request) ? fallback(request, server) : handler(request, server)
7055
+ // The table matched the target as sent; one `fetch` refuses or rewrites goes through it. Left
7056
+ // off, `validateHost` costs nothing here: the table holds no wildcard, a `..` parameter falls
7057
+ // back, and reading the Host on every request measured about 6% of a bare route.
7058
+ tableAt(path, pattern.segments)[method as Method] = this.validateHost
7059
+ ? (request, server) =>
7060
+ requestTarget(request, true) ? fallback(request, server) : handler(request, server)
7061
+ : handler
7054
7062
  count += 1
7055
7063
  if (paramNames.length > 0) {
7056
7064
  below.add(pattern.segments.length + method)