@nifrajs/core 5.0.0 → 5.1.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/dist/assurance.d.ts +15 -1
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +97 -71
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +2 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +1 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/edge-kit.d.ts +1 -1
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/route-compiler.d.ts +9 -0
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +95 -13
- package/dist/internal/route-compiler.js.map +1 -1
- package/dist/internal/same-origin.d.ts +11 -1
- package/dist/internal/same-origin.d.ts.map +1 -1
- package/dist/internal/same-origin.js +8 -0
- package/dist/internal/same-origin.js.map +1 -1
- package/dist/server/body-lane.d.ts +8 -2
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +27 -2
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/client-ip.js +6 -5
- package/dist/server/client-ip.js.map +1 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +13 -1
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +2 -1
- package/dist/server/edge.js.map +1 -1
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +5 -1
- package/dist/server/http.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +21 -6
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +139 -14
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/server-types.d.ts +16 -5
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +23 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +37 -13
- package/dist/server/server.js.map +1 -1
- package/dist/server/websocket.d.ts +11 -0
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +26 -12
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts +7 -1
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +42 -2
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server/ws-hook.d.ts +9 -3
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +35 -5
- package/dist/single-copy.js.map +1 -1
- package/dist/wire.d.ts +5 -0
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js +5 -0
- package/dist/wire.js.map +1 -1
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +2 -1
- package/dist/ws.js.map +1 -1
- package/package.json +1 -1
- package/src/assurance.ts +120 -81
- package/src/capabilities.ts +3 -1
- package/src/edge-kit.ts +1 -1
- package/src/index.ts +2 -2
- package/src/internal/route-compiler.ts +107 -16
- package/src/internal/same-origin.ts +14 -1
- package/src/server/body-lane.ts +36 -1
- package/src/server/client-ip.ts +5 -5
- package/src/server/cookies.ts +12 -1
- package/src/server/edge.ts +2 -1
- package/src/server/http.ts +7 -1
- package/src/server/response-contract-lane.ts +162 -10
- package/src/server/server-types.ts +16 -5
- package/src/server/server.ts +48 -9
- package/src/server/websocket.ts +39 -13
- package/src/server/ws-bun.ts +48 -1
- package/src/server/ws-hook.ts +9 -2
- package/src/server.ts +1 -1
- package/src/single-copy.ts +35 -4
- package/src/wire.ts +14 -0
- package/src/ws.ts +2 -0
package/src/server/body-lane.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* reuse the exact trust-boundary lane without bundling the full context implementation (cookies,
|
|
4
4
|
* deferred responses, and request helpers).
|
|
5
5
|
*/
|
|
6
|
+
import { isSameOriginForm } from "../internal/same-origin.ts"
|
|
6
7
|
import { drainCapped, hasTrustedBodyFraming, parseContentLength, RAW_BODY_READERS } from "./body.ts"
|
|
7
8
|
import { plainError } from "./http.ts"
|
|
8
9
|
import { guardParsedValue, type ProtoPoisoning, parseJsonGuarded } from "./proto-guard.ts"
|
|
@@ -191,10 +192,39 @@ export type SchemaBodyReader = ((
|
|
|
191
192
|
readonly mediaTypes?: readonly string[]
|
|
192
193
|
}
|
|
193
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Origins besides the request's own whose HTML forms may post a body: exact origins, or a function
|
|
197
|
+
* that receives the request's `Origin`.
|
|
198
|
+
*/
|
|
199
|
+
export type FormOrigins = ReadonlyArray<string> | ((origin: string) => boolean)
|
|
200
|
+
|
|
201
|
+
/** The media types a cross-site HTML form can send with the visitor's cookies and no preflight. */
|
|
202
|
+
function isFormMediaType(contentType: string): boolean {
|
|
203
|
+
const semi = contentType.indexOf(";")
|
|
204
|
+
const essence = (semi === -1 ? contentType : contentType.slice(0, semi)).trim().toLowerCase()
|
|
205
|
+
return (
|
|
206
|
+
essence === "application/x-www-form-urlencoded" ||
|
|
207
|
+
essence === "multipart/form-data" ||
|
|
208
|
+
essence === "text/plain"
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function formOriginAllowed(source: RequestSource, formOrigins: FormOrigins | undefined): boolean {
|
|
213
|
+
const origin = headerOf(source, "origin")
|
|
214
|
+
if (origin === null || isSameOriginForm(origin, headerOf(source, "sec-fetch-site"), source)) {
|
|
215
|
+
return true
|
|
216
|
+
}
|
|
217
|
+
if (formOrigins === undefined) return false
|
|
218
|
+
return typeof formOrigins === "function"
|
|
219
|
+
? formOrigins(origin) === true
|
|
220
|
+
: formOrigins.includes(origin)
|
|
221
|
+
}
|
|
222
|
+
|
|
194
223
|
/**
|
|
195
224
|
* The shared content-type dispatcher around the JSON and urlencoded lanes. A `bodySchema` branded
|
|
196
225
|
* with {@link SCHEMA_BODY_READER} takes every other media type; JSON and urlencoded bodies keep
|
|
197
|
-
* their lanes whatever the schema carries.
|
|
226
|
+
* their lanes whatever the schema carries. A form media type whose `Origin` is another site's, and
|
|
227
|
+
* not one of `formOrigins`, is refused with `403` before the body is read.
|
|
198
228
|
*/
|
|
199
229
|
export function readBodyFramed<T>(
|
|
200
230
|
source: RequestSource,
|
|
@@ -204,9 +234,14 @@ export function readBodyFramed<T>(
|
|
|
204
234
|
wrapResponse: (response: Response | ResponseResult) => T,
|
|
205
235
|
onError: (err: unknown) => MaybePromise<T>,
|
|
206
236
|
bodySchema?: object,
|
|
237
|
+
formOrigins?: FormOrigins,
|
|
207
238
|
): Promise<T> {
|
|
208
239
|
const contentType = headerOf(source, "content-type") ?? ""
|
|
209
240
|
if (!isJsonMediaType(contentType)) {
|
|
241
|
+
// JSON needs a preflight, so CORS judges it; a form body reaches here from any site with cookies.
|
|
242
|
+
if (isFormMediaType(contentType) && !formOriginAllowed(source, formOrigins)) {
|
|
243
|
+
return Promise.resolve(wrapResponse(plainError(403, "forbidden_origin")))
|
|
244
|
+
}
|
|
210
245
|
if (isUrlEncodedForm(contentType)) {
|
|
211
246
|
return readBoundedForm(source, maxBodyBytes).then(
|
|
212
247
|
(form) => (isResponseResult(form) ? wrapResponse(form) : onParsed(form)),
|
package/src/server/client-ip.ts
CHANGED
|
@@ -56,7 +56,9 @@ export function resolveClientIp(
|
|
|
56
56
|
if (trust === undefined) return peer
|
|
57
57
|
if ("header" in trust) return firstHeaderValue(req, trust.header)
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
if (trust.trustedHops === 0) return peer
|
|
60
|
+
// Hop 0 is the socket peer and hop k the k-th XFF entry from the right, so the caller is read from the
|
|
61
|
+
// header alone: a peer-less request (Workers, a unix socket) must not shift an attacker's entry in.
|
|
60
62
|
const xff = req.headers.get("x-forwarded-for")
|
|
61
63
|
const forwarded =
|
|
62
64
|
xff === null
|
|
@@ -65,8 +67,6 @@ export function resolveClientIp(
|
|
|
65
67
|
.split(",")
|
|
66
68
|
.map((entry) => entry.trim())
|
|
67
69
|
.filter((entry) => entry !== "")
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
const index = chain.length - 1 - trust.trustedHops
|
|
71
|
-
return index >= 0 ? chain[index] : undefined
|
|
70
|
+
const index = forwarded.length - trust.trustedHops
|
|
71
|
+
return index >= 0 ? forwarded[index] : undefined
|
|
72
72
|
}
|
package/src/server/cookies.ts
CHANGED
|
@@ -212,17 +212,28 @@ export function serializeCookie(name: string, value: string, options: CookieOpti
|
|
|
212
212
|
|
|
213
213
|
const TEXT = new TextEncoder()
|
|
214
214
|
|
|
215
|
+
// Imported once per secret: every signed-cookie read and write, and every auth session check, signs or
|
|
216
|
+
// verifies. Bounded, since a secret can come from per-tenant configuration.
|
|
217
|
+
const HMAC_KEYS = new Map<string, Promise<CryptoKey>>()
|
|
218
|
+
const MAX_HMAC_KEYS = 64
|
|
219
|
+
|
|
215
220
|
const importHmacKey = (secret: string): Promise<CryptoKey> => {
|
|
221
|
+
const cached = HMAC_KEYS.get(secret)
|
|
222
|
+
if (cached !== undefined) return cached
|
|
216
223
|
// Reject a weak secret at the choke point both signing and verification flow through (so signed
|
|
217
224
|
// cookies AND @nifrajs/auth sessions, which delegate here via signValue/unsignValue, are covered).
|
|
218
225
|
requireSecretBytes(secret, "signed-cookie")
|
|
219
|
-
|
|
226
|
+
const key = crypto.subtle.importKey(
|
|
220
227
|
"raw",
|
|
221
228
|
TEXT.encode(secret),
|
|
222
229
|
{ name: "HMAC", hash: "SHA-256" },
|
|
223
230
|
false,
|
|
224
231
|
["sign", "verify"],
|
|
225
232
|
)
|
|
233
|
+
if (HMAC_KEYS.size >= MAX_HMAC_KEYS) HMAC_KEYS.clear()
|
|
234
|
+
HMAC_KEYS.set(secret, key)
|
|
235
|
+
key.catch(() => HMAC_KEYS.delete(secret))
|
|
236
|
+
return key
|
|
226
237
|
}
|
|
227
238
|
|
|
228
239
|
const toBase64Url = (buf: ArrayBuffer): string => {
|
package/src/server/edge.ts
CHANGED
|
@@ -116,7 +116,8 @@ export function toFetchHandler<Env = unknown>(
|
|
|
116
116
|
const platform: Platform<Env> = { env, waitUntil: (promise) => ctx.waitUntil(promise) }
|
|
117
117
|
// WebSocket broadcast on Workers: route upgrades to the hub Durable Object (it holds the
|
|
118
118
|
// connections + runs the app's pub/sub, so `app.publish` reaches every client). The hub itself
|
|
119
|
-
// resolves the route and
|
|
119
|
+
// resolves the route, and a request that is no handshake for a WebSocket route gets its HTTP
|
|
120
|
+
// answer there. See `@nifrajs/workers`' `createWebSocketHub`.
|
|
120
121
|
if (
|
|
121
122
|
webSocketHub !== undefined &&
|
|
122
123
|
request.headers.get("upgrade")?.toLowerCase() === "websocket"
|
package/src/server/http.ts
CHANGED
|
@@ -30,7 +30,13 @@ export function isSameOriginPath(value: string): boolean {
|
|
|
30
30
|
const code = value.charCodeAt(i)
|
|
31
31
|
if (code === 0x5c /* \ */ || code <= 0x1f || code === 0x7f) return false
|
|
32
32
|
}
|
|
33
|
-
|
|
33
|
+
// `/.//host` and `/a/..//host` (or their `%2e` forms) resolve to the path `//host`, which any
|
|
34
|
+
// relative reference later built from that path - a client router's redirect - reads as a host.
|
|
35
|
+
// A path-absolute reference against a valid base always parses, so this cannot throw.
|
|
36
|
+
return (
|
|
37
|
+
!(value.includes(".") || value.includes("%")) ||
|
|
38
|
+
!new URL(value, "http://n.invalid").pathname.startsWith("//")
|
|
39
|
+
)
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
/**
|
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
*
|
|
29
29
|
* That is not an inconsistency to paper over: a strict schema has already declared that extra fields
|
|
30
30
|
* are an error, and a stripping one has declared they are ignorable. Enforcement honours what the
|
|
31
|
-
* author wrote rather than overriding it
|
|
31
|
+
* author wrote rather than overriding it - unless the app asks for `extra: "strip"`. A schema that
|
|
32
|
+
* carries its JSON Schema as `jsonSchema` (every `t` schema does) CAN list its declared keys, so for
|
|
33
|
+
* those the payload is projected onto the declared shape before it is validated.
|
|
32
34
|
*
|
|
33
35
|
* ## What enforcement costs
|
|
34
36
|
*
|
|
@@ -62,6 +64,18 @@ import type { AnyServer } from "./server.ts"
|
|
|
62
64
|
*/
|
|
63
65
|
export type ResponseContractMode = "warn" | "enforce"
|
|
64
66
|
|
|
67
|
+
export interface ResponseContractOptions {
|
|
68
|
+
/**
|
|
69
|
+
* Fields the schema does not declare. `"reject"` (the default) leaves them to the validator: a
|
|
70
|
+
* closed `t.object` reports them, so an enforced response becomes a 500 and the log names them.
|
|
71
|
+
* `"strip"` drops them first, for any schema exposing its JSON Schema as `jsonSchema` (every
|
|
72
|
+
* `@nifrajs/schema` `t` schema) - nested objects, arrays and records included. A union is projected
|
|
73
|
+
* only when exactly one branch can hold an object. Schemas without `jsonSchema` (zod, valibot) are
|
|
74
|
+
* unaffected: they already strip or reject by their own rules.
|
|
75
|
+
*/
|
|
76
|
+
readonly extra?: "reject" | "strip"
|
|
77
|
+
}
|
|
78
|
+
|
|
65
79
|
/** Success and status-specific error schemas captured by one route at registration time. */
|
|
66
80
|
export interface ResponseContractDefinition {
|
|
67
81
|
readonly response?: StandardSchemaV1
|
|
@@ -80,6 +94,127 @@ export type ResponseContractOutcome =
|
|
|
80
94
|
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
|
81
95
|
typeof v === "object" && v !== null && !Array.isArray(v)
|
|
82
96
|
|
|
97
|
+
/** Rebuilds a value with only what its schema declares; absent when the schema can strip nothing. */
|
|
98
|
+
type Projector = (value: unknown) => unknown
|
|
99
|
+
|
|
100
|
+
const projectors = new WeakMap<object, Projector | null>()
|
|
101
|
+
|
|
102
|
+
/** The projector for a schema's JSON Schema, compiled once per schema. */
|
|
103
|
+
function projectorOf(schema: StandardSchemaV1): Projector | undefined {
|
|
104
|
+
const node: unknown = Reflect.get(schema, "jsonSchema")
|
|
105
|
+
if (typeof node !== "object" || node === null) return undefined
|
|
106
|
+
let found = projectors.get(node)
|
|
107
|
+
if (found === undefined) {
|
|
108
|
+
found = compileProjector(node) ?? null
|
|
109
|
+
projectors.set(node, found)
|
|
110
|
+
}
|
|
111
|
+
return found ?? undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const canHoldObject = (node: object): boolean => {
|
|
115
|
+
const type: unknown = Reflect.get(node, "type")
|
|
116
|
+
return type === undefined || type === "object" || type === "array"
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const objectField = (node: object, key: string): object | undefined => {
|
|
120
|
+
const value: unknown = Reflect.get(node, key)
|
|
121
|
+
return typeof value === "object" && value !== null ? value : undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function compileProjector(node: unknown): Projector | undefined {
|
|
125
|
+
if (typeof node !== "object" || node === null) return undefined
|
|
126
|
+
const schema: object = node
|
|
127
|
+
const type: unknown = Reflect.get(schema, "type")
|
|
128
|
+
const branches: unknown = Reflect.get(schema, "anyOf") ?? Reflect.get(schema, "oneOf")
|
|
129
|
+
if (Array.isArray(branches)) {
|
|
130
|
+
// Which branch a value meets is the validator's call; projecting onto one of two object branches
|
|
131
|
+
// could strip the fields that make the other match.
|
|
132
|
+
const holders = branches.filter(
|
|
133
|
+
(branch): branch is object =>
|
|
134
|
+
typeof branch === "object" && branch !== null && canHoldObject(branch),
|
|
135
|
+
)
|
|
136
|
+
const only = holders.length === 1 ? compileProjector(holders[0]) : undefined
|
|
137
|
+
return only === undefined
|
|
138
|
+
? undefined
|
|
139
|
+
: (value) => (typeof value === "object" && value !== null ? only(value) : value)
|
|
140
|
+
}
|
|
141
|
+
if (type === "array") {
|
|
142
|
+
const items: unknown = Reflect.get(schema, "items")
|
|
143
|
+
const item = Array.isArray(items) ? undefined : compileProjector(items)
|
|
144
|
+
return item === undefined
|
|
145
|
+
? undefined
|
|
146
|
+
: (value) => (Array.isArray(value) ? value.map(item) : value)
|
|
147
|
+
}
|
|
148
|
+
if (type !== "object") return undefined
|
|
149
|
+
const properties = objectField(schema, "properties") ?? {}
|
|
150
|
+
const keys = Object.keys(properties)
|
|
151
|
+
const nested = new Map<string, Projector>()
|
|
152
|
+
for (const key of keys) {
|
|
153
|
+
const projector = compileProjector(Reflect.get(properties, key))
|
|
154
|
+
if (projector !== undefined) nested.set(key, projector)
|
|
155
|
+
}
|
|
156
|
+
const patterns: Array<readonly [RegExp, Projector | undefined]> = []
|
|
157
|
+
for (const [source, child] of Object.entries(objectField(schema, "patternProperties") ?? {})) {
|
|
158
|
+
let pattern: RegExp
|
|
159
|
+
try {
|
|
160
|
+
pattern = new RegExp(source, "u")
|
|
161
|
+
} catch {
|
|
162
|
+
return undefined // a pattern this engine can't read: the validator judges the value whole
|
|
163
|
+
}
|
|
164
|
+
patterns.push([pattern, compileProjector(child)])
|
|
165
|
+
}
|
|
166
|
+
const closed = Reflect.get(schema, "additionalProperties") === false
|
|
167
|
+
if (!closed && nested.size === 0 && patterns.every(([, projector]) => projector === undefined))
|
|
168
|
+
return undefined
|
|
169
|
+
const declared = new Set(keys)
|
|
170
|
+
return (value) => {
|
|
171
|
+
if (!isPlainObject(value)) return value
|
|
172
|
+
const out: Record<string, unknown> = closed ? {} : { ...value }
|
|
173
|
+
for (const key of closed ? keys : nested.keys()) {
|
|
174
|
+
// A getter on a row class's prototype is a declared field; `Object.prototype`'s members are not.
|
|
175
|
+
if (!Object.hasOwn(value, key) && (!(key in value) || key in Object.prototype)) continue
|
|
176
|
+
const projector = nested.get(key)
|
|
177
|
+
define(out, key, projector === undefined ? value[key] : projector(value[key]))
|
|
178
|
+
}
|
|
179
|
+
if (patterns.length > 0) projectPatternKeys(value, out, declared, patterns, closed)
|
|
180
|
+
return out
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Carry the undeclared keys a `patternProperties` entry admits: a closed record's keys are all
|
|
186
|
+
* pattern keys, so dropping them empties a valid value. A key two patterns match is copied verbatim,
|
|
187
|
+
* since projecting it onto one could strip what the other requires.
|
|
188
|
+
*/
|
|
189
|
+
function projectPatternKeys(
|
|
190
|
+
value: Record<string, unknown>,
|
|
191
|
+
out: Record<string, unknown>,
|
|
192
|
+
declared: ReadonlySet<string>,
|
|
193
|
+
patterns: ReadonlyArray<readonly [RegExp, Projector | undefined]>,
|
|
194
|
+
closed: boolean,
|
|
195
|
+
): void {
|
|
196
|
+
for (const key of Object.keys(value)) {
|
|
197
|
+
if (declared.has(key)) continue
|
|
198
|
+
let projector: Projector | undefined
|
|
199
|
+
let matches = 0
|
|
200
|
+
for (const [pattern, child] of patterns)
|
|
201
|
+
if (pattern.test(key)) {
|
|
202
|
+
projector = child
|
|
203
|
+
matches++
|
|
204
|
+
}
|
|
205
|
+
if (matches === 0) continue
|
|
206
|
+
if (matches === 1 && projector !== undefined) define(out, key, projector(value[key]))
|
|
207
|
+
else if (closed) define(out, key, value[key])
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** `__proto__` is a declared name like any other here; plain assignment would set the prototype. */
|
|
212
|
+
function define(out: Record<string, unknown>, key: string, value: unknown): void {
|
|
213
|
+
if (key === "__proto__")
|
|
214
|
+
Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true })
|
|
215
|
+
else out[key] = value
|
|
216
|
+
}
|
|
217
|
+
|
|
83
218
|
/** Top-level keys present in the handler's result but absent from the validated value. */
|
|
84
219
|
function droppedKeys(result: unknown, value: unknown): string[] {
|
|
85
220
|
if (!isPlainObject(result) || !isPlainObject(value)) return []
|
|
@@ -96,8 +231,9 @@ export function checkResponseContract(
|
|
|
96
231
|
schema: StandardSchemaV1,
|
|
97
232
|
result: unknown,
|
|
98
233
|
mode: "warn" | "enforce",
|
|
234
|
+
options?: ResponseContractOptions,
|
|
99
235
|
): ResponseContractOutcome | Promise<ResponseContractOutcome> {
|
|
100
|
-
return checkRouteResponseContract({ response: schema }, result, mode)
|
|
236
|
+
return checkRouteResponseContract({ response: schema }, result, mode, undefined, options)
|
|
101
237
|
}
|
|
102
238
|
|
|
103
239
|
/** The schema a reply with this status is held to: `response` for 2xx, else `errors[status]`. */
|
|
@@ -127,12 +263,14 @@ export function checkRouteResponseContract(
|
|
|
127
263
|
result: unknown,
|
|
128
264
|
mode: "warn" | "enforce",
|
|
129
265
|
jsonReply?: { readonly body: unknown },
|
|
266
|
+
options?: ResponseContractOptions,
|
|
130
267
|
): ResponseContractOutcome | Promise<ResponseContractOutcome> {
|
|
268
|
+
const strip = options?.extra === "strip"
|
|
131
269
|
const plain = isResponseResult(result) ? result.plain : undefined
|
|
132
270
|
if (plain !== undefined) {
|
|
133
271
|
const schema = schemaFor(definition, plain.status)
|
|
134
272
|
if (schema === undefined) return { kind: "ok", value: result }
|
|
135
|
-
return validateContract(schema, plain.body, result, mode, (value) =>
|
|
273
|
+
return validateContract(schema, plain.body, result, mode, strip, (value) =>
|
|
136
274
|
status(
|
|
137
275
|
plain.status,
|
|
138
276
|
value,
|
|
@@ -143,7 +281,7 @@ export function checkRouteResponseContract(
|
|
|
143
281
|
if (jsonReply !== undefined && result instanceof Response) {
|
|
144
282
|
const schema = schemaFor(definition, result.status)
|
|
145
283
|
if (schema === undefined) return { kind: "ok", value: result }
|
|
146
|
-
return validateContract(schema, jsonReply.body, result, mode, (value) =>
|
|
284
|
+
return validateContract(schema, jsonReply.body, result, mode, strip, (value) =>
|
|
147
285
|
reserialize(result, value),
|
|
148
286
|
)
|
|
149
287
|
}
|
|
@@ -155,7 +293,7 @@ export function checkRouteResponseContract(
|
|
|
155
293
|
if (result instanceof Response || result === undefined) return { kind: "ok", value: result }
|
|
156
294
|
const schema = definition.response
|
|
157
295
|
if (schema === undefined) return { kind: "ok", value: result }
|
|
158
|
-
return validateContract(schema, result, result, mode, (value) => value)
|
|
296
|
+
return validateContract(schema, result, result, mode, strip, (value) => value)
|
|
159
297
|
}
|
|
160
298
|
|
|
161
299
|
/** Validate `payload`; `served` is what goes out unchanged when nothing is enforced - the whole
|
|
@@ -165,9 +303,11 @@ function validateContract(
|
|
|
165
303
|
payload: unknown,
|
|
166
304
|
served: unknown,
|
|
167
305
|
mode: "warn" | "enforce",
|
|
306
|
+
strip: boolean,
|
|
168
307
|
replace: (value: unknown) => unknown,
|
|
169
308
|
): ResponseContractOutcome | Promise<ResponseContractOutcome> {
|
|
170
|
-
const
|
|
309
|
+
const project = strip ? projectorOf(schema) : undefined
|
|
310
|
+
const settled = schema["~standard"].validate(project === undefined ? payload : project(payload))
|
|
171
311
|
return settled instanceof Promise
|
|
172
312
|
? settled.then((r) => interpret(r, payload, served, mode, replace))
|
|
173
313
|
: interpret(settled, payload, served, mode, replace)
|
|
@@ -195,7 +335,7 @@ function interpret(
|
|
|
195
335
|
: {
|
|
196
336
|
kind: "warn",
|
|
197
337
|
value: served,
|
|
198
|
-
message: `response carries fields its contract does not declare: ${dropped.join(", ")}. They are being sent; \`responseContract
|
|
338
|
+
message: `response carries fields its contract does not declare: ${dropped.join(", ")}. They are being sent; \`responseContract("enforce")\` would strip them.`,
|
|
199
339
|
}
|
|
200
340
|
}
|
|
201
341
|
|
|
@@ -219,10 +359,12 @@ interface ResponseContractInstallable {
|
|
|
219
359
|
/**
|
|
220
360
|
* Hold every route's declared `response` schema to what the handler actually returned.
|
|
221
361
|
*
|
|
222
|
-
* app.use(responseContract("enforce"))
|
|
362
|
+
* app.use(responseContract("enforce")) // a closed t.object -> 500 on extras
|
|
363
|
+
* app.use(responseContract("enforce", { extra: "strip" })) // extras dropped, declared fields sent
|
|
223
364
|
*
|
|
224
365
|
* Install it before the routes it should cover - like `idempotency()`, the decision is made per route
|
|
225
|
-
* at registration, so routes registered earlier are not retroactively covered.
|
|
366
|
+
* at registration, so routes registered earlier are not retroactively covered. It covers routes on
|
|
367
|
+
* the server it is installed on, its `group()`s, and servers `merge()`d into it afterwards.
|
|
226
368
|
*
|
|
227
369
|
* The check itself is cheap - with a compiled validator it measures in the ~100ns-per-response
|
|
228
370
|
* range, within benchmark noise of an uncontracted route on any route that carries middleware or a
|
|
@@ -231,7 +373,16 @@ interface ResponseContractInstallable {
|
|
|
231
373
|
* taking anyway. Opt-in because not installing the plugin keeps the lane out of the bundle, not
|
|
232
374
|
* because enforcement is expensive.
|
|
233
375
|
*/
|
|
234
|
-
export function responseContract(
|
|
376
|
+
export function responseContract(
|
|
377
|
+
mode: ResponseContractMode = "warn",
|
|
378
|
+
options: ResponseContractOptions = {},
|
|
379
|
+
): IdentityPlugin {
|
|
380
|
+
if (options.extra !== undefined && options.extra !== "reject" && options.extra !== "strip") {
|
|
381
|
+
throw new TypeError(
|
|
382
|
+
`responseContract(): extra must be "reject" or "strip", got ${JSON.stringify(options.extra)}`,
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
const contractOptions = options.extra === "strip" ? options : undefined
|
|
235
386
|
const runtime: ResponseContractRuntime = {
|
|
236
387
|
mode,
|
|
237
388
|
check: (definition, result, context) =>
|
|
@@ -242,6 +393,7 @@ export function responseContract(mode: ResponseContractMode = "warn"): IdentityP
|
|
|
242
393
|
result instanceof Response && result === context?.jsonReply
|
|
243
394
|
? { body: context.jsonBody }
|
|
244
395
|
: undefined,
|
|
396
|
+
contractOptions,
|
|
245
397
|
),
|
|
246
398
|
}
|
|
247
399
|
const apply = <S extends AnyServer>(app: S): S => {
|
|
@@ -59,10 +59,18 @@ export interface ServerOptions {
|
|
|
59
59
|
* `__proto__` key, or a `constructor` carrying a `prototype`, is the payload shape that turns a
|
|
60
60
|
* later innocent merge/assign into prototype pollution. `"reject"` (default) answers the same
|
|
61
61
|
* flat 400 as malformed JSON; `"strip"` removes the offending keys and continues; `"ignore"`
|
|
62
|
-
* parses as-is (only for routes that never merge body input into other objects).
|
|
63
|
-
*
|
|
62
|
+
* parses as-is (only for routes that never merge body input into other objects). Every guarded
|
|
63
|
+
* payload pays one iterative pass over its parsed object nodes; `"ignore"` skips it.
|
|
64
64
|
*/
|
|
65
65
|
readonly protoPoisoning?: "reject" | "strip" | "ignore"
|
|
66
|
+
/**
|
|
67
|
+
* Other sites whose HTML forms may post to a body-schema route: exact origins, or a function that
|
|
68
|
+
* receives the `Origin`. A urlencoded, multipart or `text/plain` body is one a form on any site can
|
|
69
|
+
* send with the visitor's cookies and no preflight, so one whose `Origin` names another site is
|
|
70
|
+
* refused with `403` before it is read. Same-origin posts and requests without an `Origin`, such
|
|
71
|
+
* as server-to-server calls, pass. JSON is not affected: a browser preflights it, so CORS decides.
|
|
72
|
+
*/
|
|
73
|
+
readonly formOrigins?: ReadonlyArray<string> | ((origin: string) => boolean)
|
|
66
74
|
/**
|
|
67
75
|
* Declare that every `Request` reaching `app.fetch` was framed by a runtime HTTP parser, so its
|
|
68
76
|
* `Content-Length` is an exact transport boundary rather than a hint. Set this ONLY when the
|
|
@@ -81,9 +89,12 @@ export interface ServerOptions {
|
|
|
81
89
|
* untrusted input.
|
|
82
90
|
*/
|
|
83
91
|
readonly trustBodyFraming?: boolean
|
|
84
|
-
/**
|
|
85
|
-
*
|
|
86
|
-
* Default: `maxBodyBytes` (1 MB).
|
|
92
|
+
/**
|
|
93
|
+
* Max inbound WebSocket message size (bytes). A larger message never reaches the handler and the
|
|
94
|
+
* socket closes with `1009`. Default: `maxBodyBytes` (1 MB). Bun and Node stop buffering at the cap,
|
|
95
|
+
* so a huge frame is never held in memory; on Bun a mounted app's smaller cap, and on Deno and
|
|
96
|
+
* Workers every cap, is checked after the runtime has buffered the whole message.
|
|
97
|
+
*/
|
|
87
98
|
readonly wsMaxPayloadBytes?: number
|
|
88
99
|
/** Per-request timeout (ms): a slower request gets a 503 and `ctx.signal` aborts. 0 disables (default). */
|
|
89
100
|
readonly requestTimeoutMs?: number
|
package/src/server/server.ts
CHANGED
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
assuranceEvidenceFor,
|
|
33
33
|
} from "../internal/route-assurance.ts"
|
|
34
34
|
import { type CatalogRoute, RouteCatalog } from "../internal/route-catalog.ts"
|
|
35
|
-
import { compileRouteOptions } from "../internal/route-compiler.ts"
|
|
35
|
+
import { adoptRouteRuntimes, compileRouteOptions } from "../internal/route-compiler.ts"
|
|
36
36
|
import {
|
|
37
37
|
compileRouteExecutionPlan,
|
|
38
38
|
type FusedBodyRunner,
|
|
@@ -74,6 +74,7 @@ import {
|
|
|
74
74
|
} from "./client-ip.ts"
|
|
75
75
|
import type { Context, Platform, ResponseControls, RouteSchema } from "./context.ts"
|
|
76
76
|
import "./core-copies.ts"
|
|
77
|
+
import type { FormOrigins } from "./body-lane.ts"
|
|
77
78
|
import {
|
|
78
79
|
hasLowercaseHeaderKeysMark,
|
|
79
80
|
headerKeysAllLowercase,
|
|
@@ -982,6 +983,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
982
983
|
| undefined
|
|
983
984
|
private readonly maxBodyBytes: number
|
|
984
985
|
private readonly protoPoisoning: "reject" | "strip" | "ignore"
|
|
986
|
+
private readonly formOrigins: FormOrigins | undefined
|
|
985
987
|
/** `trustBodyFraming`: mark every `app.fetch` request as runtime-framed (see ServerOptions). */
|
|
986
988
|
private readonly trustBodyFraming: boolean
|
|
987
989
|
private readonly wsMaxPayloadBytes: number
|
|
@@ -1117,6 +1119,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
1117
1119
|
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
|
|
1118
1120
|
assertByteLimit(maxBodyBytes, "maxBodyBytes")
|
|
1119
1121
|
this.protoPoisoning = options.protoPoisoning ?? "reject"
|
|
1122
|
+
this.formOrigins = options.formOrigins
|
|
1120
1123
|
this.trustBodyFraming = options.trustBodyFraming ?? false
|
|
1121
1124
|
const wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? maxBodyBytes
|
|
1122
1125
|
assertByteLimit(wsMaxPayloadBytes, "wsMaxPayloadBytes")
|
|
@@ -2586,6 +2589,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2586
2589
|
* registered before a `derive`). Fail closed: a path+method collision throws
|
|
2587
2590
|
* `RouteConfigError` at merge time, and a group with WebSocket routes or mounts is refused
|
|
2588
2591
|
* (register those on the parent).
|
|
2592
|
+
*
|
|
2593
|
+
* What this server's `.use()`, made BEFORE the merge, covers on a merged route versus a
|
|
2594
|
+
* `group()` route:
|
|
2595
|
+
*
|
|
2596
|
+
* | installed on this server | group() route | merge()d route |
|
|
2597
|
+
* | ------------------------------------------ | ------------- | ------------------------------ |
|
|
2598
|
+
* | derive/decorate/beforeHandle/afterHandle, | yes | no - wire it on the merged |
|
|
2599
|
+
* | onError/around, authenticate, route-scoped | | server |
|
|
2600
|
+
* | assurance | | |
|
|
2601
|
+
* | onRequest/onResponse/responseHeaders hooks | yes | yes (app-wide) |
|
|
2602
|
+
* | responseContract() | yes | yes, when the route declares a |
|
|
2603
|
+
* | | | response and its own server |
|
|
2604
|
+
* | | | installed no contract |
|
|
2605
|
+
* | effectLedger() | yes | yes, when the route declares |
|
|
2606
|
+
* | | | capabilities and has no ledger |
|
|
2607
|
+
* | idempotency() | yes | the merged server's own; a |
|
|
2608
|
+
* | | | route declaring it without one |
|
|
2609
|
+
* | | | is refused at registration |
|
|
2610
|
+
*
|
|
2611
|
+
* The response contract and the ledger can only add a check or a record, so a merged route is
|
|
2612
|
+
* recompiled under them. A runtime installed AFTER the merge covers neither kind of route.
|
|
2589
2613
|
*/
|
|
2590
2614
|
merge<R2 extends Registry, Ctx2, HookOutput2>(
|
|
2591
2615
|
other: Server<R2, Ctx2, HookOutput2>,
|
|
@@ -2630,7 +2654,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2630
2654
|
* The builder receives a scope that INHERITS this server's route chain as it stands at the call
|
|
2631
2655
|
* (`derive`/`decorate`/`authenticate`/`beforeHandle`/`afterHandle`/`onError`/`around`, assurance,
|
|
2632
2656
|
* capability interceptors, installed runtimes, applied plugins), exactly as a route declared here
|
|
2633
|
-
* would. A plugin this server already applied is in effect for the group: the group's `use()` of
|
|
2657
|
+
* would. {@link merge} documents how that differs for a merged server. A plugin this server already applied is in effect for the group: the group's `use()` of
|
|
2634
2658
|
* that same instance does nothing, and its `use()` of another instance under the same name throws a
|
|
2635
2659
|
* `RouteConfigError` (`PLUGIN_RECONFIGURED`), because that instance could never replace the parent's.
|
|
2636
2660
|
* A plugin the group applies still shares the parent's copy of a dependency it `use()`s itself.
|
|
@@ -2738,7 +2762,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
2738
2762
|
})
|
|
2739
2763
|
: (route: CatalogRoute): CatalogRoute => route
|
|
2740
2764
|
this.catalog.addBatch(
|
|
2741
|
-
sourceRoutes.map((route) =>
|
|
2765
|
+
sourceRoutes.map((route) =>
|
|
2766
|
+
this.bindFusedRuntime(
|
|
2767
|
+
foldedAssurance(
|
|
2768
|
+
adoptRouteRuntimes(route, this.responseContractRuntime, this.effectLedgerRuntime),
|
|
2769
|
+
),
|
|
2770
|
+
),
|
|
2771
|
+
),
|
|
2742
2772
|
)
|
|
2743
2773
|
// Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
|
|
2744
2774
|
// runtime object supplies the generic execution machinery. If the parent already has a runtime,
|
|
@@ -3547,6 +3577,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3547
3577
|
pubsub,
|
|
3548
3578
|
attach: runtime.attach,
|
|
3549
3579
|
maxPayloadBytes: this.wsMaxPayloadBytes,
|
|
3580
|
+
sender: runtime.sender,
|
|
3550
3581
|
}
|
|
3551
3582
|
}
|
|
3552
3583
|
const wrap = (response: Response | ResponseResult): WebSocketUpgradeOutcome =>
|
|
@@ -3659,9 +3690,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
3659
3690
|
if (o.kind === "pass")
|
|
3660
3691
|
return this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>)
|
|
3661
3692
|
if (o.kind === "reject") return o.response
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
})
|
|
3693
|
+
// A copy: the Bun socket keeps per-connection state on it, and a hand-built outcome may be shared.
|
|
3694
|
+
return server.upgrade(req, { data: { ...o } })
|
|
3665
3695
|
? undefined
|
|
3666
3696
|
: jsonError(426, "upgrade_required")
|
|
3667
3697
|
}
|
|
@@ -4689,6 +4719,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
4689
4719
|
wrapResponse,
|
|
4690
4720
|
(err) => logError(err, ctx, finalize, wrapResponse),
|
|
4691
4721
|
bodySchema,
|
|
4722
|
+
this.formOrigins,
|
|
4692
4723
|
)
|
|
4693
4724
|
}
|
|
4694
4725
|
}
|
|
@@ -5103,6 +5134,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
5103
5134
|
(response) => this.wrapWebResponse(response),
|
|
5104
5135
|
(err) => Promise.resolve(logError(err, ctx)),
|
|
5105
5136
|
bodySchema,
|
|
5137
|
+
this.formOrigins,
|
|
5106
5138
|
) as MaybePromise<Response>
|
|
5107
5139
|
}
|
|
5108
5140
|
}
|
|
@@ -5534,6 +5566,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
5534
5566
|
wrapResponse,
|
|
5535
5567
|
(err) => logError(err, ctx, finalize, wrapResponse),
|
|
5536
5568
|
bodySchema,
|
|
5569
|
+
this.formOrigins,
|
|
5537
5570
|
)
|
|
5538
5571
|
}
|
|
5539
5572
|
}
|
|
@@ -6189,6 +6222,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6189
6222
|
(response) => response,
|
|
6190
6223
|
(error) => Promise.reject(error),
|
|
6191
6224
|
bodySchema,
|
|
6225
|
+
this.formOrigins,
|
|
6192
6226
|
)
|
|
6193
6227
|
}
|
|
6194
6228
|
|
|
@@ -6554,7 +6588,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6554
6588
|
// the parent.
|
|
6555
6589
|
const nativePubsub =
|
|
6556
6590
|
this.wsRoutes.length > 0 && !this.mountsTakeUpgrades() && !this.wsHasValidatedSend
|
|
6557
|
-
const wsHandlers = wsRuntime?.bunHandlers(
|
|
6591
|
+
const wsHandlers = wsRuntime?.bunHandlers(
|
|
6592
|
+
this.topics ?? wsRuntime.createTopics(),
|
|
6593
|
+
nativePubsub,
|
|
6594
|
+
this,
|
|
6595
|
+
)
|
|
6558
6596
|
// Spread rather than pass `hostname: undefined` (or an undefined idle timeout or TLS) - Bun treats
|
|
6559
6597
|
// an explicit undefined as a value on some option paths, and omitting is what selects its own
|
|
6560
6598
|
// defaults, 0.0.0.0 among them.
|
|
@@ -6602,8 +6640,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
|
|
|
6602
6640
|
// `unknown` params bridge a TS structural-variance quirk. Round-trip covered by websocket.test.ts.
|
|
6603
6641
|
websocket: {
|
|
6604
6642
|
// Cap inbound frames so a huge message can't be buffered/JSON-parsed into memory; the runtime
|
|
6605
|
-
// closes an over-cap connection before the handler runs. Default 1 MB (maxBodyBytes).
|
|
6606
|
-
|
|
6643
|
+
// closes an over-cap connection before the handler runs. Default 1 MB (maxBodyBytes). One
|
|
6644
|
+
// limit per listener: the largest cap among this app and the apps mounted on it.
|
|
6645
|
+
maxPayloadLength: wsHandlers.maxPayloadLength,
|
|
6607
6646
|
open: (ws) => wsHandlers.open(ws),
|
|
6608
6647
|
message: (ws, message) => wsHandlers.message(ws, message),
|
|
6609
6648
|
close: (ws, code, reason) => wsHandlers.close(ws, code, reason),
|