@michaelthielemann/kestrel 5.0.0 → 5.0.3

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/boot.js CHANGED
@@ -8,6 +8,7 @@ import { StepRegistry } from "./registry.js";
8
8
  import { createRunTracker, runPipeline } from "./runner.js";
9
9
  import { sortModules } from "./sort.js";
10
10
  import { createCronEntry, startCron } from "./triggers/cron.js";
11
+ import { createAllowlist } from "./triggers/allowlist.js";
11
12
  import { createHttpServer, listen, parseRoute, routePattern } from "./triggers/http.js";
12
13
  const CORE = "kestrel";
13
14
  const SVG = "image/svg+xml";
@@ -151,6 +152,14 @@ export async function boot(input) {
151
152
  if (config.http?.inlineTypes.includes(SVG) && !steps.has(SANITIZE_SVG)) {
152
153
  throw new KestrelBootError(CORE, `http.inlineTypes contains "${SVG}" but no module registers the step "${SANITIZE_SVG}"`);
153
154
  }
155
+ if (config.http !== null) {
156
+ try {
157
+ createAllowlist(config.http.allow);
158
+ }
159
+ catch (err) {
160
+ throw new KestrelBootError(CORE, `http.allow: ${err instanceof Error ? err.message : String(err)}`);
161
+ }
162
+ }
154
163
  const definitions = new Map();
155
164
  for (const p of input.pipelines) {
156
165
  if (definitions.has(p.name))
@@ -228,6 +237,9 @@ export async function boot(input) {
228
237
  server = createHttpServer(routes, run, logger, {
229
238
  maxBodyBytes: http.maxBodyBytes,
230
239
  trustProxy: http.trustProxy,
240
+ proxyHops: http.proxyHops,
241
+ ...(http.trustedHeader === undefined ? {} : { trustedHeader: http.trustedHeader }),
242
+ allow: http.allow,
231
243
  healthPath: http.healthPath,
232
244
  inlineTypes: http.inlineTypes,
233
245
  timeouts: http.timeouts,
@@ -44,6 +44,9 @@ export declare const configSchema: z.ZodObject<{
44
44
  corsOrigin: z.ZodOptional<z.ZodString>;
45
45
  maxBodyBytes: z.ZodDefault<z.ZodNumber>;
46
46
  trustProxy: z.ZodDefault<z.ZodBoolean>;
47
+ proxyHops: z.ZodDefault<z.ZodNumber>;
48
+ trustedHeader: z.ZodOptional<z.ZodString>;
49
+ allow: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
47
50
  healthPath: z.ZodDefault<z.ZodNullable<z.ZodString>>;
48
51
  inlineTypes: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
49
52
  timeouts: z.ZodDefault<z.ZodObject<{
@@ -60,10 +63,8 @@ export declare const configSchema: z.ZodObject<{
60
63
  keepAliveMs?: number | undefined;
61
64
  }>>;
62
65
  }, "strict", z.ZodTypeAny, {
63
- port: number;
64
- host: string;
66
+ allow: string[];
65
67
  maxBodyBytes: number;
66
- trustProxy: boolean;
67
68
  healthPath: string | null;
68
69
  inlineTypes: string[];
69
70
  timeouts: {
@@ -71,13 +72,16 @@ export declare const configSchema: z.ZodObject<{
71
72
  headersMs: number;
72
73
  keepAliveMs: number;
73
74
  };
75
+ trustProxy: boolean;
76
+ proxyHops: number;
77
+ port: number;
78
+ host: string;
74
79
  corsOrigin?: string | undefined;
80
+ trustedHeader?: string | undefined;
75
81
  }, {
76
- port?: number | undefined;
77
- host?: string | undefined;
82
+ allow?: string[] | undefined;
78
83
  corsOrigin?: string | undefined;
79
84
  maxBodyBytes?: number | undefined;
80
- trustProxy?: boolean | undefined;
81
85
  healthPath?: string | null | undefined;
82
86
  inlineTypes?: string[] | undefined;
83
87
  timeouts?: {
@@ -85,15 +89,18 @@ export declare const configSchema: z.ZodObject<{
85
89
  headersMs?: number | undefined;
86
90
  keepAliveMs?: number | undefined;
87
91
  } | undefined;
92
+ trustProxy?: boolean | undefined;
93
+ proxyHops?: number | undefined;
94
+ trustedHeader?: string | undefined;
95
+ port?: number | undefined;
96
+ host?: string | undefined;
88
97
  }>]>>;
89
98
  pipelinesDir: z.ZodDefault<z.ZodString>;
90
99
  shutdownTimeoutMs: z.ZodDefault<z.ZodNumber>;
91
100
  }, "strict", z.ZodTypeAny, {
92
101
  http: {
93
- port: number;
94
- host: string;
102
+ allow: string[];
95
103
  maxBodyBytes: number;
96
- trustProxy: boolean;
97
104
  healthPath: string | null;
98
105
  inlineTypes: string[];
99
106
  timeouts: {
@@ -101,7 +108,12 @@ export declare const configSchema: z.ZodObject<{
101
108
  headersMs: number;
102
109
  keepAliveMs: number;
103
110
  };
111
+ trustProxy: boolean;
112
+ proxyHops: number;
113
+ port: number;
114
+ host: string;
104
115
  corsOrigin?: string | undefined;
116
+ trustedHeader?: string | undefined;
105
117
  } | null;
106
118
  modules: {
107
119
  use: string;
@@ -135,11 +147,9 @@ export declare const configSchema: z.ZodObject<{
135
147
  pipeline: string;
136
148
  })[];
137
149
  http?: {
138
- port?: number | undefined;
139
- host?: string | undefined;
150
+ allow?: string[] | undefined;
140
151
  corsOrigin?: string | undefined;
141
152
  maxBodyBytes?: number | undefined;
142
- trustProxy?: boolean | undefined;
143
153
  healthPath?: string | null | undefined;
144
154
  inlineTypes?: string[] | undefined;
145
155
  timeouts?: {
@@ -147,6 +157,11 @@ export declare const configSchema: z.ZodObject<{
147
157
  headersMs?: number | undefined;
148
158
  keepAliveMs?: number | undefined;
149
159
  } | undefined;
160
+ trustProxy?: boolean | undefined;
161
+ proxyHops?: number | undefined;
162
+ trustedHeader?: string | undefined;
163
+ port?: number | undefined;
164
+ host?: string | undefined;
150
165
  } | null | undefined;
151
166
  pipelinesDir?: string | undefined;
152
167
  shutdownTimeoutMs?: number | undefined;
@@ -18,6 +18,9 @@ export const configSchema = z
18
18
  corsOrigin: z.string().min(1).optional(),
19
19
  maxBodyBytes: z.number().int().positive().default(10 * 1024 * 1024),
20
20
  trustProxy: z.boolean().default(false),
21
+ proxyHops: z.number().int().positive().default(1),
22
+ trustedHeader: z.string().min(1).optional(),
23
+ allow: z.array(z.string().min(1)).default([]),
21
24
  healthPath: z.string().regex(/^\/\S*$/).nullable().default("/health"),
22
25
  inlineTypes: z.array(z.string().min(1)).default([]),
23
26
  timeouts: z
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { boot, type Kestrel, type BootInput, type Triggers } from "./boot.ts";
2
- export { matchRoute, routePattern, parseRequestBody, parseQuery, buildResponse, responseForRun, errorResponse, clientIp, BodyTooLarge, type Route, type ParsedBody, type HttpResponse, type HttpTimeouts, type ResponseMeta } from "./triggers/http.ts";
2
+ export { matchRoute, routePattern, parseRequestBody, parseQuery, buildResponse, responseForRun, errorResponse, clientIp, BodyTooLarge, type Route, type ParsedBody, type HttpResponse, type HttpTimeouts, type ClientIpOptions, type ResponseMeta } from "./triggers/http.ts";
3
3
  export { defineConfig, type KestrelConfig, type KestrelConfigInput } from "./defineConfig.ts";
4
4
  export { defineContract, type Contract } from "./defineContract.ts";
5
5
  export { defineModule, type ModuleDefinition, type ModuleInput, type ModuleTriggers, type Deps, type EventEntry, type ContextPath, type JsonSchema, type StepDescription, type StepDescriptions } from "./defineModule.ts";
@@ -0,0 +1,9 @@
1
+ export interface Allowlist {
2
+ check(ip: string | undefined): boolean;
3
+ }
4
+ export declare function normalizeIp(ip: string): {
5
+ address: string;
6
+ family: "ipv4" | "ipv6";
7
+ } | null;
8
+ /** `null` for an empty list: nothing to enforce. Throws on an entry that is neither an address nor a CIDR. */
9
+ export declare function createAllowlist(entries: readonly string[]): Allowlist | null;
@@ -0,0 +1,49 @@
1
+ import { BlockList, isIP } from "node:net";
2
+ const MAPPED_IPV4 = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i;
3
+ function invalid(entry, reason) {
4
+ return new Error(`ip allowlist: invalid entry ${JSON.stringify(entry)} (${reason})`);
5
+ }
6
+ // Strips an IPv6 zone id and unwraps an IPv4-mapped IPv6 address so both sides of the check use
7
+ // the same family the operator wrote into the list.
8
+ export function normalizeIp(ip) {
9
+ const bare = ip.includes("%") ? (ip.split("%")[0] ?? "") : ip;
10
+ const mapped = MAPPED_IPV4.exec(bare);
11
+ const address = mapped ? (mapped[1] ?? bare) : bare;
12
+ const version = isIP(address);
13
+ if (version === 4)
14
+ return { address, family: "ipv4" };
15
+ if (version === 6)
16
+ return { address, family: "ipv6" };
17
+ return null;
18
+ }
19
+ /** `null` for an empty list: nothing to enforce. Throws on an entry that is neither an address nor a CIDR. */
20
+ export function createAllowlist(entries) {
21
+ if (entries.length === 0)
22
+ return null;
23
+ const list = new BlockList();
24
+ for (const entry of entries) {
25
+ const [raw, prefixText, ...rest] = entry.trim().split("/");
26
+ if (raw === undefined || raw === "" || rest.length > 0)
27
+ throw invalid(entry, "expected an IP address or a CIDR range");
28
+ const ip = normalizeIp(raw);
29
+ if (ip === null)
30
+ throw invalid(entry, "not an IPv4 or IPv6 address");
31
+ if (prefixText === undefined) {
32
+ list.addAddress(ip.address, ip.family);
33
+ continue;
34
+ }
35
+ const bits = ip.family === "ipv4" ? 32 : 128;
36
+ const prefix = /^\d{1,3}$/.test(prefixText) ? Number(prefixText) : Number.NaN;
37
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > bits)
38
+ throw invalid(entry, `prefix must be 0-${bits}`);
39
+ list.addSubnet(ip.address, prefix, ip.family);
40
+ }
41
+ return {
42
+ check(ip) {
43
+ if (ip === undefined)
44
+ return false;
45
+ const normalized = normalizeIp(ip);
46
+ return normalized !== null && list.check(normalized.address, normalized.family);
47
+ },
48
+ };
49
+ }
@@ -45,14 +45,19 @@ export interface HttpTimeouts {
45
45
  headersMs?: number;
46
46
  keepAliveMs?: number;
47
47
  }
48
- export interface HttpOptions {
48
+ export interface ClientIpOptions {
49
+ trustProxy?: boolean;
50
+ proxyHops?: number;
51
+ trustedHeader?: string;
52
+ }
53
+ export interface HttpOptions extends ClientIpOptions {
54
+ allow?: readonly string[];
49
55
  corsOrigin?: string;
50
56
  maxBodyBytes?: number;
51
- trustProxy?: boolean;
52
57
  healthPath?: string | null;
53
58
  inlineTypes?: readonly string[];
54
59
  timeouts?: HttpTimeouts;
55
60
  }
56
- export declare function clientIp(req: IncomingMessage, trustProxy: boolean): string | undefined;
61
+ export declare function clientIp(req: IncomingMessage, options?: boolean | ClientIpOptions): string | undefined;
57
62
  export declare function createHttpServer(routes: readonly Route[], run: Runner, logger: Logger, options?: HttpOptions): Server;
58
63
  export declare function listen(server: Server, port: number, host: string): Promise<AddressInfo>;
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
3
  import { isBinaryResult, requestIdOf } from "../context.js";
4
+ import { createAllowlist } from "./allowlist.js";
4
5
  export function parseRoute(http, pipeline) {
5
6
  const [method, path] = http.split(" ");
6
7
  if (method === undefined || path === undefined || !path.startsWith("/"))
@@ -182,6 +183,7 @@ export function responseForRun(result, extraInline = []) {
182
183
  /** Fixed code for errors raised before a pipeline runs, where there is no KestrelError to read one from. */
183
184
  const EDGE_CODE_BY_STATUS = {
184
185
  400: "VALIDATION",
186
+ 403: "FORBIDDEN",
185
187
  404: "NOT_FOUND",
186
188
  413: "PAYLOAD_TOO_LARGE",
187
189
  500: "INTERNAL",
@@ -213,19 +215,34 @@ function write(res, response) {
213
215
  res.writeHead(response.status, response.headers);
214
216
  res.end(typeof response.body === "string" ? response.body : Buffer.from(response.body));
215
217
  }
216
- export function clientIp(req, trustProxy) {
218
+ function headerValues(value) {
219
+ return (Array.isArray(value) ? value : value === undefined ? [] : [value])
220
+ .flatMap((v) => v.split(","))
221
+ .map((v) => v.trim())
222
+ .filter((v) => v !== "");
223
+ }
224
+ // A proxy appends the peer it saw to X-Forwarded-For, so only the entries counted from the right are
225
+ // trustworthy; the left end is whatever the client sent. A trusted header names the client outright.
226
+ export function clientIp(req, options = {}) {
227
+ const { trustProxy = false, proxyHops = 1, trustedHeader } = typeof options === "boolean" ? { trustProxy: options } : options;
228
+ if (trustedHeader !== undefined)
229
+ return headerValues(req.headers[trustedHeader.toLowerCase()])[0];
217
230
  if (trustProxy) {
218
- const forwarded = req.headers["x-forwarded-for"];
219
- const first = (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(",")[0]?.trim();
220
- if (first)
221
- return first;
231
+ const chain = headerValues(req.headers["x-forwarded-for"]);
232
+ return chain.length >= proxyHops ? chain[chain.length - proxyHops] : undefined;
222
233
  }
223
234
  return req.socket.remoteAddress ?? undefined;
224
235
  }
225
236
  export function createHttpServer(routes, run, logger, options = {}) {
226
237
  const started = Date.now();
227
238
  const healthPath = options.healthPath === undefined ? "/health" : options.healthPath;
239
+ const allowlist = createAllowlist(options.allow ?? []);
240
+ const peer = { trustProxy: options.trustProxy ?? false, proxyHops: options.proxyHops ?? 1, ...(options.trustedHeader === undefined ? {} : { trustedHeader: options.trustedHeader }) };
228
241
  const server = createServer((req, res) => {
242
+ if (allowlist !== null && !allowlist.check(clientIp(req, peer))) {
243
+ sendError(res, 403, "forbidden", requestIdOf(req.headers["x-request-id"]));
244
+ return;
245
+ }
229
246
  if (options.corsOrigin !== undefined) {
230
247
  res.setHeader("access-control-allow-origin", options.corsOrigin);
231
248
  res.setHeader("access-control-allow-headers", "content-type, authorization");
@@ -273,7 +290,7 @@ export function createHttpServer(routes, run, logger, options = {}) {
273
290
  if (typeof v === "string")
274
291
  headers[k] = v;
275
292
  const input = { trigger: { kind: "http", name: `${req.method} ${url.pathname}` }, payload, params: match.params, headers, files: body.files };
276
- const ip = clientIp(req, options.trustProxy ?? false);
293
+ const ip = clientIp(req, peer);
277
294
  if (ip !== undefined)
278
295
  input.ip = ip;
279
296
  const result = await run(match.route.pipeline, input);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@michaelthielemann/kestrel",
3
- "version": "5.0.0",
3
+ "version": "5.0.3",
4
4
  "description": "Kestrel core: contract registry, boot check and pipeline runner. Ships no contracts and no modules.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",