@michaelthielemann/kestrel 5.0.0 → 5.0.2

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,7 @@ export async function boot(input) {
228
237
  server = createHttpServer(routes, run, logger, {
229
238
  maxBodyBytes: http.maxBodyBytes,
230
239
  trustProxy: http.trustProxy,
240
+ allow: http.allow,
231
241
  healthPath: http.healthPath,
232
242
  inlineTypes: http.inlineTypes,
233
243
  timeouts: http.timeouts,
@@ -44,6 +44,7 @@ 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
+ allow: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
47
48
  healthPath: z.ZodDefault<z.ZodNullable<z.ZodString>>;
48
49
  inlineTypes: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
49
50
  timeouts: z.ZodDefault<z.ZodObject<{
@@ -64,6 +65,7 @@ export declare const configSchema: z.ZodObject<{
64
65
  host: string;
65
66
  maxBodyBytes: number;
66
67
  trustProxy: boolean;
68
+ allow: string[];
67
69
  healthPath: string | null;
68
70
  inlineTypes: string[];
69
71
  timeouts: {
@@ -78,6 +80,7 @@ export declare const configSchema: z.ZodObject<{
78
80
  corsOrigin?: string | undefined;
79
81
  maxBodyBytes?: number | undefined;
80
82
  trustProxy?: boolean | undefined;
83
+ allow?: string[] | undefined;
81
84
  healthPath?: string | null | undefined;
82
85
  inlineTypes?: string[] | undefined;
83
86
  timeouts?: {
@@ -94,6 +97,7 @@ export declare const configSchema: z.ZodObject<{
94
97
  host: string;
95
98
  maxBodyBytes: number;
96
99
  trustProxy: boolean;
100
+ allow: string[];
97
101
  healthPath: string | null;
98
102
  inlineTypes: string[];
99
103
  timeouts: {
@@ -140,6 +144,7 @@ export declare const configSchema: z.ZodObject<{
140
144
  corsOrigin?: string | undefined;
141
145
  maxBodyBytes?: number | undefined;
142
146
  trustProxy?: boolean | undefined;
147
+ allow?: string[] | undefined;
143
148
  healthPath?: string | null | undefined;
144
149
  inlineTypes?: string[] | undefined;
145
150
  timeouts?: {
@@ -18,6 +18,7 @@ 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
+ allow: z.array(z.string().min(1)).default([]),
21
22
  healthPath: z.string().regex(/^\/\S*$/).nullable().default("/health"),
22
23
  inlineTypes: z.array(z.string().min(1)).default([]),
23
24
  timeouts: z
@@ -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
+ }
@@ -46,6 +46,7 @@ export interface HttpTimeouts {
46
46
  keepAliveMs?: number;
47
47
  }
48
48
  export interface HttpOptions {
49
+ allow?: readonly string[];
49
50
  corsOrigin?: string;
50
51
  maxBodyBytes?: number;
51
52
  trustProxy?: boolean;
@@ -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",
@@ -225,7 +227,13 @@ export function clientIp(req, trustProxy) {
225
227
  export function createHttpServer(routes, run, logger, options = {}) {
226
228
  const started = Date.now();
227
229
  const healthPath = options.healthPath === undefined ? "/health" : options.healthPath;
230
+ const allowlist = createAllowlist(options.allow ?? []);
231
+ const trustProxy = options.trustProxy ?? false;
228
232
  const server = createServer((req, res) => {
233
+ if (allowlist !== null && !allowlist.check(clientIp(req, trustProxy))) {
234
+ sendError(res, 403, "forbidden", requestIdOf(req.headers["x-request-id"]));
235
+ return;
236
+ }
229
237
  if (options.corsOrigin !== undefined) {
230
238
  res.setHeader("access-control-allow-origin", options.corsOrigin);
231
239
  res.setHeader("access-control-allow-headers", "content-type, authorization");
@@ -273,7 +281,7 @@ export function createHttpServer(routes, run, logger, options = {}) {
273
281
  if (typeof v === "string")
274
282
  headers[k] = v;
275
283
  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);
284
+ const ip = clientIp(req, trustProxy);
277
285
  if (ip !== undefined)
278
286
  input.ip = ip;
279
287
  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.2",
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",