@erenthedeveloper0/zen-middleware 0.1.0-alpha.1
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/LICENSE +9 -0
- package/README.md +85 -0
- package/dist/consistency.d.ts +30 -0
- package/dist/consistency.d.ts.map +1 -0
- package/dist/consistency.js +49 -0
- package/dist/consistency.js.map +1 -0
- package/dist/cors.d.ts +110 -0
- package/dist/cors.d.ts.map +1 -0
- package/dist/cors.js +276 -0
- package/dist/cors.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/rate-limit.d.ts +70 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +125 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/request-id.d.ts +17 -0
- package/dist/request-id.d.ts.map +1 -0
- package/dist/request-id.js +53 -0
- package/dist/request-id.js.map +1 -0
- package/dist/security.d.ts +80 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +65 -0
- package/dist/security.js.map +1 -0
- package/dist/shared.d.ts +55 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +25 -0
- package/dist/shared.js.map +1 -0
- package/dist/store.d.ts +115 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +126 -0
- package/dist/store.js.map +1 -0
- package/package.json +65 -0
- package/src/consistency.ts +62 -0
- package/src/cors.ts +468 -0
- package/src/index.ts +34 -0
- package/src/rate-limit.ts +217 -0
- package/src/request-id.ts +129 -0
- package/src/security.ts +161 -0
- package/src/shared.ts +61 -0
- package/src/store.ts +165 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import {
|
|
2
|
+
definePlugin, parseDuration, Codes, TooManyRequests, ZenError,
|
|
3
|
+
type Duration, type Plugin, type Reply,
|
|
4
|
+
} from '@erenthedeveloper0/zen-core'
|
|
5
|
+
import { MemoryStore, type Store, type Tally } from './store.ts'
|
|
6
|
+
import type { Answering, RawReading, Staging } from './shared.ts'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Rate limiting — rfcs/0001 §9.2, §19.2, §19.4, Annex B `ZEN_RATE_LIMITED`.
|
|
10
|
+
*
|
|
11
|
+
* ### Why it is a hook, and why that is the whole feature
|
|
12
|
+
*
|
|
13
|
+
* §9.2 states the defect this design exists to avoid, in the paragraph that
|
|
14
|
+
* made global `onRequest` hooks run on unmatched requests: *"a rate limiter
|
|
15
|
+
* that only sees matched routes is bypassed by requesting a path that does not
|
|
16
|
+
* exist."* Every framework whose rate limiter is route middleware has that
|
|
17
|
+
* hole, and it is not theoretical — `GET /aaaa` is unmatched, so the limiter
|
|
18
|
+
* never runs, so a 404 flood is free. A global `onRequest` hook counts it.
|
|
19
|
+
*
|
|
20
|
+
* §4.2 stage 5 puts it before body intake as well, so a request that is going
|
|
21
|
+
* to be refused is refused **without its body being read**. A limiter that
|
|
22
|
+
* counted after parsing would have already paid the expensive part.
|
|
23
|
+
*
|
|
24
|
+
* ### `Codes.RATE_LIMITED` was already here
|
|
25
|
+
*
|
|
26
|
+
* `TooManyRequests` and `ZEN_RATE_LIMITED` have been exported from
|
|
27
|
+
* `@erenthedeveloper0/zen-core` since 0.1 and read by nothing — the same state `COERCION_DEFAULTS`
|
|
28
|
+
* and `Codes.CONFIG_INVALID` were in before the two features that needed them.
|
|
29
|
+
* Using it rather than inventing a 429 means the refusal is an ordinary
|
|
30
|
+
* `HttpError`: it goes through the error engine, the RFC 9457 envelope, the
|
|
31
|
+
* registered `onError` hooks and `onSend`, and it appears in the same error
|
|
32
|
+
* dashboards as everything else. A rate limiter that answers with its own
|
|
33
|
+
* hand-built response is one whose refusals no error rate counts.
|
|
34
|
+
*
|
|
35
|
+
* ### What it does not do
|
|
36
|
+
*
|
|
37
|
+
* It is a **fixed-window counter**, and `store.ts` explains what that costs at
|
|
38
|
+
* the boundary. It is not distributed unless the `Store` is (§3.5). It does not
|
|
39
|
+
* read `X-Forwarded-For` — that is `ctx.ip`, and §19.4 makes it a deliberate
|
|
40
|
+
* `trustProxy` decision — but it *does* notice when the combination is the one
|
|
41
|
+
* §19.4 warns about, once, at the moment it is provably real. See {@link warnProxy}.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export interface RateLimitOptions {
|
|
45
|
+
/** Requests per window, per key. */
|
|
46
|
+
readonly limit?: number | undefined
|
|
47
|
+
/** Window length. Fixed, not sliding — see `store.ts`. */
|
|
48
|
+
readonly window?: Duration | undefined
|
|
49
|
+
/**
|
|
50
|
+
* What to count by. Defaults to `ctx.ip`.
|
|
51
|
+
*
|
|
52
|
+
* Return `null` to exempt a request entirely — that is how an authenticated
|
|
53
|
+
* service account or an internal health poller opts out, and it is a
|
|
54
|
+
* deliberate hole rather than a second allowlist option nobody would find.
|
|
55
|
+
*/
|
|
56
|
+
readonly key?: ((ctx: RateLimitContext) => string | null) | undefined
|
|
57
|
+
/** Where counters live. Defaults to an in-process {@link MemoryStore}. */
|
|
58
|
+
readonly store?: Store | undefined
|
|
59
|
+
/** Also emit `X-RateLimit-*`. Off by default: they were never standardised and they double the header cost. */
|
|
60
|
+
readonly legacyHeaders?: boolean | undefined
|
|
61
|
+
/** Emit `RateLimit` / `RateLimit-Policy` (IETF draft). On by default. */
|
|
62
|
+
readonly standardHeaders?: boolean | undefined
|
|
63
|
+
/** Message on the 429. The status, code and envelope are not configurable. */
|
|
64
|
+
readonly message?: string | undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface RateLimitContext {
|
|
68
|
+
readonly ip: string
|
|
69
|
+
readonly method: string
|
|
70
|
+
readonly path: string
|
|
71
|
+
readonly id: string
|
|
72
|
+
readonly raw: { header(name: never): string | undefined }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const DEFAULTS = { limit: 100, window: '1m' } as const
|
|
76
|
+
|
|
77
|
+
export function rateLimit(options: RateLimitOptions = {}): Plugin<void, {}> {
|
|
78
|
+
return definePlugin<void, {}>({
|
|
79
|
+
name: 'rate-limit',
|
|
80
|
+
version: '0.1.0',
|
|
81
|
+
config: { namespace: 'rateLimit', defaults: { limit: DEFAULTS.limit, window: DEFAULTS.window } },
|
|
82
|
+
|
|
83
|
+
setup(app) {
|
|
84
|
+
// §16.1 layer order: an explicit option beats the plugin's own layer-2
|
|
85
|
+
// defaults, which a deployment can beat from configuration. Readable at
|
|
86
|
+
// setup because §16.2 resolved the environment before any plugin ran.
|
|
87
|
+
const namespace = (app.config['rateLimit'] ?? {}) as { limit?: unknown; window?: unknown }
|
|
88
|
+
const limit = options.limit ?? numberOr(namespace.limit, DEFAULTS.limit)
|
|
89
|
+
const windowMs = parseDuration(options.window ?? (namespace.window as Duration | undefined) ?? DEFAULTS.window)
|
|
90
|
+
|
|
91
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
92
|
+
throw new ZenError(
|
|
93
|
+
Codes.CONFIG_INVALID,
|
|
94
|
+
`rateLimit needs a positive whole limit; got ${JSON.stringify(limit)}.`,
|
|
95
|
+
{
|
|
96
|
+
status: 500,
|
|
97
|
+
expose: false,
|
|
98
|
+
hint: 'Pass rateLimit({ limit: 100, window: "1m" }), or set rateLimit.limit in configuration.',
|
|
99
|
+
consequence: 'A limit below one refuses every request, including the health probes an orchestrator uses to decide the pod is broken.',
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Annotated `Store`, not inferred: the inferred union of the supplied
|
|
105
|
+
// store and `MemoryStore` drops the optional members, so `store.close?.()`
|
|
106
|
+
// below would stop type-checking the moment the default is in play — for
|
|
107
|
+
// an interface whose whole point is that the two are interchangeable.
|
|
108
|
+
const store: Store = options.store ?? new MemoryStore({ windowMs })
|
|
109
|
+
const keyOf = options.key ?? ((ctx: RateLimitContext) => ctx.ip)
|
|
110
|
+
const standard = options.standardHeaders !== false
|
|
111
|
+
const legacy = options.legacyHeaders === true
|
|
112
|
+
const policy = `${limit};w=${Math.floor(windowMs / 1000)}`
|
|
113
|
+
const message = options.message ?? `Rate limit exceeded: ${limit} requests per ${options.window ?? namespace.window ?? DEFAULTS.window}.`
|
|
114
|
+
|
|
115
|
+
// One warning per process, not per request — §12.7's rule applied to a
|
|
116
|
+
// runtime condition. See `warnProxy`.
|
|
117
|
+
let warned = false
|
|
118
|
+
|
|
119
|
+
app.hook('onRequest', function rateLimit(
|
|
120
|
+
ctx: RateLimitContext & RawReading & Staging & Answering,
|
|
121
|
+
): Reply | undefined | Promise<Reply | undefined> {
|
|
122
|
+
const key = keyOf(ctx)
|
|
123
|
+
if (key === null) return undefined
|
|
124
|
+
|
|
125
|
+
if (!warned && options.key === undefined) warned = warnProxy(ctx)
|
|
126
|
+
|
|
127
|
+
const tally = store.hit(key, Date.now())
|
|
128
|
+
// The in-memory store is synchronous and must stay on §8.4's fast path;
|
|
129
|
+
// a Redis store is a round trip. Branching on the *value* rather than
|
|
130
|
+
// declaring the hook async is what lets one implementation serve both
|
|
131
|
+
// without making every app that uses the default await a resolved
|
|
132
|
+
// promise per request.
|
|
133
|
+
return isThenable(tally)
|
|
134
|
+
? tally.then((settled) => verdict(ctx, settled))
|
|
135
|
+
: verdict(ctx, tally)
|
|
136
|
+
}, 'rate-limit')
|
|
137
|
+
|
|
138
|
+
app.hook('onClose', async () => { await store.close?.() })
|
|
139
|
+
|
|
140
|
+
function verdict(ctx: Staging & Answering, tally: Tally): Reply | undefined {
|
|
141
|
+
const remaining = Math.max(0, limit - tally.count)
|
|
142
|
+
const resetSeconds = Math.max(0, Math.ceil((tally.resetAt - Date.now()) / 1000))
|
|
143
|
+
|
|
144
|
+
// Staged, not stamped: the headers have to be on the 429 *and* on the
|
|
145
|
+
// 200, and the 429 leaves through the error engine rather than through
|
|
146
|
+
// this hook's return value. `ctx.res` is downstream of both (§13.6).
|
|
147
|
+
if (standard) {
|
|
148
|
+
ctx.res.header('ratelimit', `limit=${limit}, remaining=${remaining}, reset=${resetSeconds}`)
|
|
149
|
+
ctx.res.header('ratelimit-policy', policy)
|
|
150
|
+
}
|
|
151
|
+
if (legacy) {
|
|
152
|
+
ctx.res.header('x-ratelimit-limit', String(limit))
|
|
153
|
+
ctx.res.header('x-ratelimit-remaining', String(remaining))
|
|
154
|
+
ctx.res.header('x-ratelimit-reset', String(Math.ceil(tally.resetAt / 1000)))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (tally.count <= limit) return undefined
|
|
158
|
+
|
|
159
|
+
ctx.res.header('retry-after', String(resetSeconds))
|
|
160
|
+
// Thrown, not returned. A returned `Reply` would leave the error engine,
|
|
161
|
+
// the problem-details envelope and every registered `onError` hook out
|
|
162
|
+
// of the one response class an operator most wants counted.
|
|
163
|
+
throw new TooManyRequests(message, { retryable: true })
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { exports: { limit, windowMs, store } }
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* §19.4's detection, without the traffic sampling.
|
|
173
|
+
*
|
|
174
|
+
* The dangerous combination is: rate limiting keyed on the client IP,
|
|
175
|
+
* `trustProxy` off, and a proxy in front adding `X-Forwarded-For`. Then
|
|
176
|
+
* `ctx.ip` is the load balancer for every request, every caller shares one
|
|
177
|
+
* counter, and the limiter is globally throttling the service instead of
|
|
178
|
+
* limiting anybody — which looks exactly like the service being slow.
|
|
179
|
+
*
|
|
180
|
+
* §19.4 assigns this to `zen doctor` "with traffic samples", and that is the
|
|
181
|
+
* right home for the general check. But the specific one costs a header read on
|
|
182
|
+
* the first request that could possibly exhibit it, and it fires only when the
|
|
183
|
+
* misconfiguration is **already real**: a forwarding header is present and is
|
|
184
|
+
* being ignored. A boot-time version could not do that — at boot, "trustProxy
|
|
185
|
+
* is off" is also the correct configuration for a directly-exposed server, so a
|
|
186
|
+
* warning then would fire on every correct app until people turned it off.
|
|
187
|
+
*
|
|
188
|
+
* Returns `true` so the caller latches it: one line per process, naming the fix.
|
|
189
|
+
*/
|
|
190
|
+
function warnProxy(ctx: RateLimitContext & RawReading): boolean {
|
|
191
|
+
const forwarded = ctx.raw.header('x-forwarded-for')
|
|
192
|
+
if (forwarded === undefined) return false
|
|
193
|
+
// `ctx.ip` falls back to the socket address when trustProxy is off (§19.4),
|
|
194
|
+
// so the two disagreeing *is* the condition — no need to read the option.
|
|
195
|
+
const claimed = forwarded.split(',')[0]?.trim()
|
|
196
|
+
if (claimed === undefined || claimed === ctx.ip) return false
|
|
197
|
+
|
|
198
|
+
console.warn(
|
|
199
|
+
`[zen] ${Codes.RATE_LIMITED}: rate limiting is keyed on ctx.ip, this request carried ` +
|
|
200
|
+
`X-Forwarded-For: ${claimed}, and trustProxy is off — so it was counted as ${ctx.ip}. ` +
|
|
201
|
+
'fix: set trustProxy to the number of proxies in front of the app — trustProxy: 1 for one ' +
|
|
202
|
+
'load balancer — so ctx.ip is the address your proxy saw; not `true`, which reads the ' +
|
|
203
|
+
'leftmost entry, and a client that writes its own X-Forwarded-For then gets a fresh budget ' +
|
|
204
|
+
'per request. Or pass rateLimit({ key }) to count by something you control. ' +
|
|
205
|
+
'also: until then every caller behind the proxy shares one counter, which throttles the ' +
|
|
206
|
+
'whole service at the configured limit instead of limiting anyone. Reported once per process.',
|
|
207
|
+
)
|
|
208
|
+
return true
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function numberOr(value: unknown, fallback: number): number {
|
|
212
|
+
return typeof value === 'number' ? value : fallback
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isThenable(value: unknown): value is Promise<Tally> {
|
|
216
|
+
return typeof (value as { then?: unknown } | null)?.then === 'function'
|
|
217
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { definePlugin, Codes, ZenError, type Plugin } from '@erenthedeveloper0/zen-core'
|
|
2
|
+
import type { RawReading, Staging } from './shared.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Request id — rfcs/0001 §19.4, §31.1.
|
|
6
|
+
*
|
|
7
|
+
* Zen already generates one: the dispatcher assigns `ctx.id` before the first
|
|
8
|
+
* hook runs, so `ctx.log` and the RFC 9457 problem document already carry a
|
|
9
|
+
* correlation key with no plugin at all. This adds the two halves that need a
|
|
10
|
+
* decision rather than a default.
|
|
11
|
+
*
|
|
12
|
+
* ### Echoing it
|
|
13
|
+
*
|
|
14
|
+
* The id is useless to a caller who cannot see it. `x-request-id` on the reply
|
|
15
|
+
* is what turns "something failed at 14:02" into a log query, and staging it
|
|
16
|
+
* through `ctx.res` means it is on the 500 as well as the 200 — which is the
|
|
17
|
+
* only response anybody actually needs it on.
|
|
18
|
+
*
|
|
19
|
+
* ### Accepting one, which is a trust decision
|
|
20
|
+
*
|
|
21
|
+
* Continuing a caller's trace id is the reason people reach for this plugin,
|
|
22
|
+
* and it is **off by default** for the same reason `trustProxy` is (§19.4): an
|
|
23
|
+
* inbound `X-Request-Id` is attacker-controlled. It lands in every log line
|
|
24
|
+
* this request produces, in the problem document, and in whatever index those
|
|
25
|
+
* are shipped to — so an unbounded, unvalidated value is log injection with a
|
|
26
|
+
* framework helpfully doing the writing, and a value chosen to collide with a
|
|
27
|
+
* real id is a way to make one request's trail read as another's.
|
|
28
|
+
*
|
|
29
|
+
* So `trustHeader` is opt-in, and even opted in the value must survive
|
|
30
|
+
* {@link ACCEPTABLE}: 8–128 characters of `[A-Za-z0-9._-]`. That admits every
|
|
31
|
+
* id anybody actually sends — ULIDs, UUIDs, hex, W3C trace ids — and admits no
|
|
32
|
+
* newline, no space and no control character. A value that fails is not an
|
|
33
|
+
* error; the request gets a fresh id, because refusing traffic over the shape
|
|
34
|
+
* of a correlation header would be a worse failure than the one being avoided.
|
|
35
|
+
* `x-request-id-rejected: 1` says it happened, so the caller can find out
|
|
36
|
+
* without reading the server's logs.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The one field this plugin writes.
|
|
41
|
+
*
|
|
42
|
+
* `ctx.id` is `readonly` on `BaseContext` and is a plain field on both twins,
|
|
43
|
+
* assigned once by the dispatcher. Overwriting it is a same-type store on an
|
|
44
|
+
* existing field, so it changes no hidden class and I2 is untouched (§7.6) —
|
|
45
|
+
* but it is the only place in this package that writes to a context, and
|
|
46
|
+
* naming the type is how that stays deliberate.
|
|
47
|
+
*
|
|
48
|
+
* The alternative was a second id on a slot, and it is worse in the way that
|
|
49
|
+
* matters: two ids means every log line, every problem document and every
|
|
50
|
+
* downstream header has to say which one it means, and the first one that gets
|
|
51
|
+
* it wrong is discovered during an incident.
|
|
52
|
+
*/
|
|
53
|
+
interface MutableId {
|
|
54
|
+
id: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface RequestIdOptions {
|
|
58
|
+
/** Header to echo on the reply. `false` echoes nothing. */
|
|
59
|
+
readonly header?: string | false | undefined
|
|
60
|
+
/**
|
|
61
|
+
* Adopt an inbound id when it is well-formed. Off by default — see above.
|
|
62
|
+
*
|
|
63
|
+
* `true` reads the same header as `header`. A string reads that one instead,
|
|
64
|
+
* which is what a deployment behind a load balancer that stamps its own
|
|
65
|
+
* (`x-amzn-trace-id`, `x-cloud-trace-context`) needs.
|
|
66
|
+
*/
|
|
67
|
+
readonly trustHeader?: boolean | string | undefined
|
|
68
|
+
/** Header carrying the rejection notice. `false` stays silent. */
|
|
69
|
+
readonly rejectedHeader?: string | false | undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 8–128 of `[A-Za-z0-9._-]`.
|
|
74
|
+
*
|
|
75
|
+
* Anchored, no alternation, no nested quantifier: linear in the input and
|
|
76
|
+
* therefore inside §19.3's bounded-work rule, which forbids backtracking
|
|
77
|
+
* regexes anywhere on the request path. The upper bound is part of the
|
|
78
|
+
* validation, not a courtesy — an id is copied into every log line for the
|
|
79
|
+
* request, so an unbounded one is an amplification the caller chooses.
|
|
80
|
+
*/
|
|
81
|
+
const ACCEPTABLE = /^[A-Za-z0-9._-]{8,128}$/
|
|
82
|
+
|
|
83
|
+
export function requestId(options: RequestIdOptions = {}): Plugin<void, {}> {
|
|
84
|
+
const echo = options.header === undefined ? 'x-request-id' : options.header
|
|
85
|
+
const trust =
|
|
86
|
+
options.trustHeader === undefined || options.trustHeader === false ? null
|
|
87
|
+
: options.trustHeader === true
|
|
88
|
+
? (echo === false ? 'x-request-id' : echo)
|
|
89
|
+
: options.trustHeader
|
|
90
|
+
const rejected = options.rejectedHeader === undefined ? 'x-request-id-rejected' : options.rejectedHeader
|
|
91
|
+
|
|
92
|
+
if (echo === false && trust === null) {
|
|
93
|
+
throw new ZenError(
|
|
94
|
+
Codes.CONFIG_INVALID,
|
|
95
|
+
'requestId() was configured to neither echo an id nor adopt one, which leaves it with nothing to do.',
|
|
96
|
+
{
|
|
97
|
+
status: 500,
|
|
98
|
+
expose: false,
|
|
99
|
+
hint: 'Drop the registration — Zen assigns ctx.id with or without this plugin — or turn one of the two back on.',
|
|
100
|
+
consequence: 'As configured it registers a hook on every route that returns immediately.',
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return definePlugin<void, {}>({
|
|
106
|
+
name: 'request-id',
|
|
107
|
+
version: '0.1.0',
|
|
108
|
+
// First in the pack: a preflight answered by `cors` and a 429 refused by
|
|
109
|
+
// `rate-limit` both short-circuit, and both should still carry the id that
|
|
110
|
+
// the log line for them will be filed under.
|
|
111
|
+
before: ['cors', 'rate-limit', 'security-headers'],
|
|
112
|
+
|
|
113
|
+
setup(app) {
|
|
114
|
+
app.hook('onRequest', function requestId(ctx: RawReading & Staging & MutableId): undefined {
|
|
115
|
+
if (trust !== null) {
|
|
116
|
+
const inbound = ctx.raw.header(trust)
|
|
117
|
+
if (inbound !== undefined) {
|
|
118
|
+
if (ACCEPTABLE.test(inbound)) ctx.id = inbound
|
|
119
|
+
else if (rejected !== false) ctx.res.header(rejected, '1')
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (echo !== false) ctx.res.header(echo, ctx.id)
|
|
123
|
+
return undefined
|
|
124
|
+
}, 'request-id')
|
|
125
|
+
|
|
126
|
+
return { exports: { header: echo, trusts: trust } }
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
}
|
package/src/security.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { definePlugin, type Plugin } from '@erenthedeveloper0/zen-core'
|
|
2
|
+
import { assertCorsCorpConsistent } from './consistency.ts'
|
|
3
|
+
import type { CorsExports } from './cors.ts'
|
|
4
|
+
import type { Staging } from './shared.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Security response headers — rfcs/0001 §19.2.
|
|
8
|
+
*
|
|
9
|
+
* The header set is §19.2's table, and the table is the specification: every
|
|
10
|
+
* default here appears there with the sentence explaining why it is not looser.
|
|
11
|
+
* Nothing is invented in this file.
|
|
12
|
+
*
|
|
13
|
+
* ### Staged, so failures carry them too
|
|
14
|
+
*
|
|
15
|
+
* Same reason as `cors.ts`: an `after` middleware never runs on the error path
|
|
16
|
+
* (§4.6) or on an unmatched request, so a service that stamps `nosniff` in
|
|
17
|
+
* middleware does not have it on its 404s, its 500s or its validation errors —
|
|
18
|
+
* which are precisely the responses most likely to contain reflected input.
|
|
19
|
+
* `ctx.res` stages, and `prepareForWire` applies at egress on every path.
|
|
20
|
+
*
|
|
21
|
+
* ### It runs first, and that is a correctness requirement
|
|
22
|
+
*
|
|
23
|
+
* Staging is only half the answer. `cors` answers a preflight by returning a
|
|
24
|
+
* `Reply`, and `rate-limit` refuses by throwing — both short-circuit the
|
|
25
|
+
* `onRequest` chain, so a hook registered after them does not run at all on the
|
|
26
|
+
* responses that need it most. The first draft of this pack ordered
|
|
27
|
+
* `securityHeaders` last and every preflight and every 429 went out without
|
|
28
|
+
* `nosniff`; the manual end-to-end check found it before any test did, which is
|
|
29
|
+
* convention #2 again. Hence `before:` on all three siblings.
|
|
30
|
+
*
|
|
31
|
+
* ### The contradiction it can catch that a library cannot
|
|
32
|
+
*
|
|
33
|
+
* See `consistency.ts`. Both this plugin and `cors` call the same check with
|
|
34
|
+
* whatever the other has published, so whichever is registered second finds
|
|
35
|
+
* both halves — the ordering above means that is normally `cors`.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export type ReferrerPolicy =
|
|
39
|
+
| 'no-referrer'
|
|
40
|
+
| 'no-referrer-when-downgrade'
|
|
41
|
+
| 'origin'
|
|
42
|
+
| 'origin-when-cross-origin'
|
|
43
|
+
| 'same-origin'
|
|
44
|
+
| 'strict-origin'
|
|
45
|
+
| 'strict-origin-when-cross-origin'
|
|
46
|
+
| 'unsafe-url'
|
|
47
|
+
|
|
48
|
+
export interface SecurityHeadersOptions {
|
|
49
|
+
/** `X-Content-Type-Options: nosniff`. Off is not a supported configuration; the flag exists for tests. */
|
|
50
|
+
readonly noSniff?: boolean | undefined
|
|
51
|
+
/** `X-Frame-Options`. `false` omits it — do that only when a CSP `frame-ancestors` replaces it. */
|
|
52
|
+
readonly frameOptions?: 'DENY' | 'SAMEORIGIN' | false | undefined
|
|
53
|
+
readonly referrerPolicy?: ReferrerPolicy | false | undefined
|
|
54
|
+
/** `Cross-Origin-Opener-Policy`. */
|
|
55
|
+
readonly crossOriginOpener?: 'same-origin' | 'same-origin-allow-popups' | 'unsafe-none' | false | undefined
|
|
56
|
+
/**
|
|
57
|
+
* `Cross-Origin-Resource-Policy`. Defaults to `same-site` rather than
|
|
58
|
+
* `same-origin`: `same-origin` is the stricter reading of §19.2's
|
|
59
|
+
* "conservative", and it is also the value that silently breaks a CDN
|
|
60
|
+
* subdomain serving the same site's assets. `same-site` blocks the
|
|
61
|
+
* cross-*site* read that CORP exists to prevent and leaves the arrangement
|
|
62
|
+
* every real deployment has intact.
|
|
63
|
+
*/
|
|
64
|
+
readonly crossOriginResource?: 'same-origin' | 'same-site' | 'cross-origin' | false | undefined
|
|
65
|
+
/**
|
|
66
|
+
* `Strict-Transport-Security`. **Off by default**, and this is the one
|
|
67
|
+
* default in the table that looks wrong until you have been bitten by it.
|
|
68
|
+
*
|
|
69
|
+
* §19.2 says "HSTS on when `secure: true`". A framework cannot tell whether
|
|
70
|
+
* it is behind TLS — `ctx.secure` reads `X-Forwarded-Proto`, which §19.4
|
|
71
|
+
* refuses to trust unless `trustProxy` is configured — so "on when secure"
|
|
72
|
+
* resolves to "on when a header we do not trust says so". And HSTS is not a
|
|
73
|
+
* header you can take back: a browser that has seen `max-age=31536000`
|
|
74
|
+
* refuses plain HTTP to that host for a year, including on the developer's
|
|
75
|
+
* own machine if it ever reached one. So it is opt-in, one line, in the file
|
|
76
|
+
* that already knows it is behind a load balancer.
|
|
77
|
+
*/
|
|
78
|
+
readonly hsts?: { readonly maxAge?: number; readonly includeSubDomains?: boolean; readonly preload?: boolean } | false | undefined
|
|
79
|
+
/**
|
|
80
|
+
* `Content-Security-Policy`, verbatim. **Not set by default** — §19.2: "a
|
|
81
|
+
* wrong CSP is worse than none; we prompt instead of guessing." There is no
|
|
82
|
+
* builder here for the same reason: a policy assembled from options reads as
|
|
83
|
+
* if the framework vouched for it.
|
|
84
|
+
*/
|
|
85
|
+
readonly contentSecurityPolicy?: string | false | undefined
|
|
86
|
+
/** Extra headers, staged with the rest. */
|
|
87
|
+
readonly headers?: Readonly<Record<string, string>> | undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One resolved header, so the set is data and the hook is a loop over it. */
|
|
91
|
+
type Pair = readonly [name: string, value: string]
|
|
92
|
+
|
|
93
|
+
export function securityHeaders(options: SecurityHeadersOptions = {}): Plugin<void, {}> {
|
|
94
|
+
return definePlugin<void, {}>({
|
|
95
|
+
name: 'security-headers',
|
|
96
|
+
version: '0.1.0',
|
|
97
|
+
// Before everything that can short-circuit. `cors` answers preflights and
|
|
98
|
+
// `rate-limit` throws 429s; a hook that runs after either is absent from
|
|
99
|
+
// exactly the responses §19.2 most wants these headers on. Hints on
|
|
100
|
+
// unregistered plugins are ignored (§10.5 step 4), so this costs nothing
|
|
101
|
+
// when the siblings are not installed.
|
|
102
|
+
before: ['cors', 'rate-limit'],
|
|
103
|
+
config: { namespace: 'security' },
|
|
104
|
+
|
|
105
|
+
setup(app) {
|
|
106
|
+
const pairs = resolve(options)
|
|
107
|
+
const corp = options.crossOriginResource ?? 'same-site'
|
|
108
|
+
assertCorsCorpConsistent(corp, app.exportsOf('cors') as CorsExports | undefined, app.pluginName)
|
|
109
|
+
|
|
110
|
+
// Unrolled at boot into a fixed array; the hook is one loop over a frozen
|
|
111
|
+
// list of string pairs, with no option reads and no branches per request.
|
|
112
|
+
app.hook('onRequest', function securityHeaders(ctx: Staging): undefined {
|
|
113
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
114
|
+
const pair = pairs[i] as Pair
|
|
115
|
+
ctx.res.header(pair[0], pair[1])
|
|
116
|
+
}
|
|
117
|
+
return undefined
|
|
118
|
+
}, 'security-headers')
|
|
119
|
+
|
|
120
|
+
return { exports: { headers: pairs.map(([name]) => name), crossOriginResource: corp } }
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function resolve(options: SecurityHeadersOptions): readonly Pair[] {
|
|
126
|
+
const out: Pair[] = []
|
|
127
|
+
|
|
128
|
+
if (options.noSniff !== false) out.push(['x-content-type-options', 'nosniff'])
|
|
129
|
+
|
|
130
|
+
const frame = options.frameOptions ?? 'DENY'
|
|
131
|
+
if (frame !== false) out.push(['x-frame-options', frame])
|
|
132
|
+
|
|
133
|
+
const referrer = options.referrerPolicy ?? 'no-referrer'
|
|
134
|
+
if (referrer !== false) out.push(['referrer-policy', referrer])
|
|
135
|
+
|
|
136
|
+
const coop = options.crossOriginOpener ?? 'same-origin'
|
|
137
|
+
if (coop !== false) out.push(['cross-origin-opener-policy', coop])
|
|
138
|
+
|
|
139
|
+
const corp = options.crossOriginResource ?? 'same-site'
|
|
140
|
+
if (corp !== false) out.push(['cross-origin-resource-policy', corp])
|
|
141
|
+
|
|
142
|
+
const hsts = options.hsts
|
|
143
|
+
if (hsts !== undefined && hsts !== false) {
|
|
144
|
+
const maxAge = hsts.maxAge ?? 15_552_000 // 180 days
|
|
145
|
+
out.push([
|
|
146
|
+
'strict-transport-security',
|
|
147
|
+
`max-age=${maxAge}` +
|
|
148
|
+
(hsts.includeSubDomains === true ? '; includeSubDomains' : '') +
|
|
149
|
+
(hsts.preload === true ? '; preload' : ''),
|
|
150
|
+
])
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const csp = options.contentSecurityPolicy
|
|
154
|
+
if (typeof csp === 'string' && csp.length > 0) out.push(['content-security-policy', csp])
|
|
155
|
+
|
|
156
|
+
for (const [name, value] of Object.entries(options.headers ?? {})) {
|
|
157
|
+
out.push([name.toLowerCase(), value])
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return Object.freeze(out)
|
|
161
|
+
}
|
package/src/shared.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ReplyBuilder, Reply, LowercaseName } from '@erenthedeveloper0/zen-core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What this pack touches on a context, declared structurally — §10.4.
|
|
5
|
+
*
|
|
6
|
+
* `Registrar.hook` takes a `Function` on purpose: a plugin is written before
|
|
7
|
+
* the application's decoration set exists, so pinning its hooks to
|
|
8
|
+
* `Context<never, X>` would reject the pattern for an `X` the author cannot
|
|
9
|
+
* know. The convention the examples already follow is to declare exactly the
|
|
10
|
+
* surface the hook uses (`examples/openapi/src/plugins/request-id.ts` declares
|
|
11
|
+
* `{ id: string }` and nothing else), and the point of doing it is that a
|
|
12
|
+
* middleware cannot quietly start depending on `ctx.user` later.
|
|
13
|
+
*
|
|
14
|
+
* These are split by concern rather than merged into one context type for the
|
|
15
|
+
* same reason: `securityHeaders` may not read the request, and the type says so.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Reading the request without materialising the header record. */
|
|
19
|
+
export interface RawReading {
|
|
20
|
+
readonly method: string
|
|
21
|
+
readonly raw: { header(name: LowercaseName): string | undefined }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Staging response metadata (§13.6) — applied at egress on every path. */
|
|
25
|
+
export interface Staging {
|
|
26
|
+
readonly res: ReplyBuilder
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Producing a reply from a hook, which is how a phase short-circuits (§9.2). */
|
|
30
|
+
export interface Answering {
|
|
31
|
+
empty(status?: 204 | 205 | 304): Reply<null>
|
|
32
|
+
json<T>(body: T, init?: { status?: number }): Reply<T>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type CorsRequest = RawReading & Answering
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `ctx.raw.header` rather than `ctx.headers[name]`.
|
|
39
|
+
*
|
|
40
|
+
* `ctx.headers` is lazy and memoised, but the first touch walks every header
|
|
41
|
+
* the adapter received and builds a record (`buildHeaders`, §7.2). These hooks
|
|
42
|
+
* run on *every* request in the application, including the ones whose handlers
|
|
43
|
+
* never look at a header, so making them the reason that record exists would be
|
|
44
|
+
* a cost the application did not ask for — §9.4's rule applied to a plugin
|
|
45
|
+
* rather than to the compiler.
|
|
46
|
+
*/
|
|
47
|
+
export function headerOf(ctx: RawReading, name: LowercaseName): string | undefined {
|
|
48
|
+
return ctx.raw.header(name)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A CORS preflight — an `OPTIONS` carrying `Access-Control-Request-Method`.
|
|
53
|
+
*
|
|
54
|
+
* Both halves are required by the Fetch standard and both are load-bearing
|
|
55
|
+
* here: an `OPTIONS` without the header is an ordinary request for a resource's
|
|
56
|
+
* options and may well have a route, and answering it with 204 would shadow
|
|
57
|
+
* that route with something the application did not write.
|
|
58
|
+
*/
|
|
59
|
+
export function isPreflight(ctx: RawReading): boolean {
|
|
60
|
+
return ctx.method === 'OPTIONS' && ctx.raw.header('access-control-request-method') !== undefined
|
|
61
|
+
}
|