@michaelthielemann/kestrel 5.0.2 → 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
@@ -237,6 +237,8 @@ export async function boot(input) {
237
237
  server = createHttpServer(routes, run, logger, {
238
238
  maxBodyBytes: http.maxBodyBytes,
239
239
  trustProxy: http.trustProxy,
240
+ proxyHops: http.proxyHops,
241
+ ...(http.trustedHeader === undefined ? {} : { trustedHeader: http.trustedHeader }),
240
242
  allow: http.allow,
241
243
  healthPath: http.healthPath,
242
244
  inlineTypes: http.inlineTypes,
@@ -44,6 +44,8 @@ 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>;
47
49
  allow: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
48
50
  healthPath: z.ZodDefault<z.ZodNullable<z.ZodString>>;
49
51
  inlineTypes: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
@@ -61,11 +63,8 @@ export declare const configSchema: z.ZodObject<{
61
63
  keepAliveMs?: number | undefined;
62
64
  }>>;
63
65
  }, "strict", z.ZodTypeAny, {
64
- port: number;
65
- host: string;
66
- maxBodyBytes: number;
67
- trustProxy: boolean;
68
66
  allow: string[];
67
+ maxBodyBytes: number;
69
68
  healthPath: string | null;
70
69
  inlineTypes: string[];
71
70
  timeouts: {
@@ -73,14 +72,16 @@ export declare const configSchema: z.ZodObject<{
73
72
  headersMs: number;
74
73
  keepAliveMs: number;
75
74
  };
75
+ trustProxy: boolean;
76
+ proxyHops: number;
77
+ port: number;
78
+ host: string;
76
79
  corsOrigin?: string | undefined;
80
+ trustedHeader?: string | undefined;
77
81
  }, {
78
- port?: number | undefined;
79
- host?: string | undefined;
82
+ allow?: string[] | undefined;
80
83
  corsOrigin?: string | undefined;
81
84
  maxBodyBytes?: number | undefined;
82
- trustProxy?: boolean | undefined;
83
- allow?: string[] | undefined;
84
85
  healthPath?: string | null | undefined;
85
86
  inlineTypes?: string[] | undefined;
86
87
  timeouts?: {
@@ -88,16 +89,18 @@ export declare const configSchema: z.ZodObject<{
88
89
  headersMs?: number | undefined;
89
90
  keepAliveMs?: number | undefined;
90
91
  } | undefined;
92
+ trustProxy?: boolean | undefined;
93
+ proxyHops?: number | undefined;
94
+ trustedHeader?: string | undefined;
95
+ port?: number | undefined;
96
+ host?: string | undefined;
91
97
  }>]>>;
92
98
  pipelinesDir: z.ZodDefault<z.ZodString>;
93
99
  shutdownTimeoutMs: z.ZodDefault<z.ZodNumber>;
94
100
  }, "strict", z.ZodTypeAny, {
95
101
  http: {
96
- port: number;
97
- host: string;
98
- maxBodyBytes: number;
99
- trustProxy: boolean;
100
102
  allow: string[];
103
+ maxBodyBytes: number;
101
104
  healthPath: string | null;
102
105
  inlineTypes: string[];
103
106
  timeouts: {
@@ -105,7 +108,12 @@ export declare const configSchema: z.ZodObject<{
105
108
  headersMs: number;
106
109
  keepAliveMs: number;
107
110
  };
111
+ trustProxy: boolean;
112
+ proxyHops: number;
113
+ port: number;
114
+ host: string;
108
115
  corsOrigin?: string | undefined;
116
+ trustedHeader?: string | undefined;
109
117
  } | null;
110
118
  modules: {
111
119
  use: string;
@@ -139,12 +147,9 @@ export declare const configSchema: z.ZodObject<{
139
147
  pipeline: string;
140
148
  })[];
141
149
  http?: {
142
- port?: number | undefined;
143
- host?: string | undefined;
150
+ allow?: string[] | undefined;
144
151
  corsOrigin?: string | undefined;
145
152
  maxBodyBytes?: number | undefined;
146
- trustProxy?: boolean | undefined;
147
- allow?: string[] | undefined;
148
153
  healthPath?: string | null | undefined;
149
154
  inlineTypes?: string[] | undefined;
150
155
  timeouts?: {
@@ -152,6 +157,11 @@ export declare const configSchema: z.ZodObject<{
152
157
  headersMs?: number | undefined;
153
158
  keepAliveMs?: number | undefined;
154
159
  } | undefined;
160
+ trustProxy?: boolean | undefined;
161
+ proxyHops?: number | undefined;
162
+ trustedHeader?: string | undefined;
163
+ port?: number | undefined;
164
+ host?: string | undefined;
155
165
  } | null | undefined;
156
166
  pipelinesDir?: string | undefined;
157
167
  shutdownTimeoutMs?: number | undefined;
@@ -18,6 +18,8 @@ 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(),
21
23
  allow: z.array(z.string().min(1)).default([]),
22
24
  healthPath: z.string().regex(/^\/\S*$/).nullable().default("/health"),
23
25
  inlineTypes: z.array(z.string().min(1)).default([]),
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";
@@ -45,15 +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 {
49
54
  allow?: readonly string[];
50
55
  corsOrigin?: string;
51
56
  maxBodyBytes?: number;
52
- trustProxy?: boolean;
53
57
  healthPath?: string | null;
54
58
  inlineTypes?: readonly string[];
55
59
  timeouts?: HttpTimeouts;
56
60
  }
57
- export declare function clientIp(req: IncomingMessage, trustProxy: boolean): string | undefined;
61
+ export declare function clientIp(req: IncomingMessage, options?: boolean | ClientIpOptions): string | undefined;
58
62
  export declare function createHttpServer(routes: readonly Route[], run: Runner, logger: Logger, options?: HttpOptions): Server;
59
63
  export declare function listen(server: Server, port: number, host: string): Promise<AddressInfo>;
@@ -215,12 +215,21 @@ function write(res, response) {
215
215
  res.writeHead(response.status, response.headers);
216
216
  res.end(typeof response.body === "string" ? response.body : Buffer.from(response.body));
217
217
  }
218
- 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];
219
230
  if (trustProxy) {
220
- const forwarded = req.headers["x-forwarded-for"];
221
- const first = (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(",")[0]?.trim();
222
- if (first)
223
- return first;
231
+ const chain = headerValues(req.headers["x-forwarded-for"]);
232
+ return chain.length >= proxyHops ? chain[chain.length - proxyHops] : undefined;
224
233
  }
225
234
  return req.socket.remoteAddress ?? undefined;
226
235
  }
@@ -228,9 +237,9 @@ export function createHttpServer(routes, run, logger, options = {}) {
228
237
  const started = Date.now();
229
238
  const healthPath = options.healthPath === undefined ? "/health" : options.healthPath;
230
239
  const allowlist = createAllowlist(options.allow ?? []);
231
- const trustProxy = options.trustProxy ?? false;
240
+ const peer = { trustProxy: options.trustProxy ?? false, proxyHops: options.proxyHops ?? 1, ...(options.trustedHeader === undefined ? {} : { trustedHeader: options.trustedHeader }) };
232
241
  const server = createServer((req, res) => {
233
- if (allowlist !== null && !allowlist.check(clientIp(req, trustProxy))) {
242
+ if (allowlist !== null && !allowlist.check(clientIp(req, peer))) {
234
243
  sendError(res, 403, "forbidden", requestIdOf(req.headers["x-request-id"]));
235
244
  return;
236
245
  }
@@ -281,7 +290,7 @@ export function createHttpServer(routes, run, logger, options = {}) {
281
290
  if (typeof v === "string")
282
291
  headers[k] = v;
283
292
  const input = { trigger: { kind: "http", name: `${req.method} ${url.pathname}` }, payload, params: match.params, headers, files: body.files };
284
- const ip = clientIp(req, trustProxy);
293
+ const ip = clientIp(req, peer);
285
294
  if (ip !== undefined)
286
295
  input.ip = ip;
287
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.2",
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",