@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 +12 -0
- package/dist/defineConfig.d.ts +27 -12
- package/dist/defineConfig.js +3 -0
- package/dist/index.d.ts +1 -1
- package/dist/triggers/allowlist.d.ts +9 -0
- package/dist/triggers/allowlist.js +49 -0
- package/dist/triggers/http.d.ts +8 -3
- package/dist/triggers/http.js +23 -6
- package/package.json +1 -1
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,
|
package/dist/defineConfig.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/defineConfig.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/triggers/http.d.ts
CHANGED
|
@@ -45,14 +45,19 @@ export interface HttpTimeouts {
|
|
|
45
45
|
headersMs?: number;
|
|
46
46
|
keepAliveMs?: number;
|
|
47
47
|
}
|
|
48
|
-
export interface
|
|
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,
|
|
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>;
|
package/dist/triggers/http.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
219
|
-
|
|
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,
|
|
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