@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 +10 -0
- package/dist/defineConfig.d.ts +5 -0
- package/dist/defineConfig.js +1 -0
- package/dist/triggers/allowlist.d.ts +9 -0
- package/dist/triggers/allowlist.js +49 -0
- package/dist/triggers/http.d.ts +1 -0
- package/dist/triggers/http.js +9 -1
- 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,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,
|
package/dist/defineConfig.d.ts
CHANGED
|
@@ -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?: {
|
package/dist/defineConfig.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/triggers/http.d.ts
CHANGED
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",
|
|
@@ -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,
|
|
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