keryx 0.29.8 → 0.29.10

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.
@@ -4,4 +4,6 @@ export const configObservability = {
4
4
  enabled: await loadFromEnvIfSet("OTEL_METRICS_ENABLED", false),
5
5
  metricsRoute: await loadFromEnvIfSet("OTEL_METRICS_ROUTE", "/metrics"),
6
6
  serviceName: await loadFromEnvIfSet("OTEL_SERVICE_NAME", ""),
7
+ metricsAuthUsername: await loadFromEnvIfSet("OTEL_METRICS_AUTH_USERNAME", ""),
8
+ metricsAuthPassword: await loadFromEnvIfSet("OTEL_METRICS_AUTH_PASSWORD", ""),
7
9
  };
@@ -17,5 +17,6 @@ export const configServerMcp = {
17
17
  "MCP_OAUTH_REFRESH_TTL",
18
18
  60 * 60 * 24 * 30,
19
19
  ), // 30 days, in seconds
20
+ oauthTrustProxy: await loadFromEnvIfSet("MCP_OAUTH_TRUST_PROXY", false),
20
21
  markdownDepthLimit: await loadFromEnvIfSet("MCP_MARKDOWN_DEPTH_LIMIT", 5),
21
22
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keryx",
3
- "version": "0.29.8",
3
+ "version": "0.29.10",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/servers/web.ts CHANGED
@@ -1,6 +1,6 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { parse } from "node:url";
2
3
  import type { ServerWebSocket } from "bun";
3
- import { randomUUID } from "crypto";
4
4
  import { api, logger } from "../api";
5
5
  import { type HTTP_METHOD } from "../classes/Action";
6
6
  import { Connection } from "../classes/Connection";
@@ -11,6 +11,7 @@ import { config } from "../config";
11
11
  import type { PubSubMessage } from "../initializers/pubsub";
12
12
  import { ansi } from "../util/ansi";
13
13
  import { isOriginAllowed } from "../util/http";
14
+ import { verifyBasicAuth } from "../util/webBasicAuth";
14
15
  import { compressResponse } from "../util/webCompression";
15
16
  import {
16
17
  buildError,
@@ -325,6 +326,23 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
325
326
  config.observability.enabled &&
326
327
  parsedUrl.pathname === config.observability.metricsRoute
327
328
  ) {
329
+ if (
330
+ !verifyBasicAuth(
331
+ req,
332
+ config.observability.metricsAuthUsername,
333
+ config.observability.metricsAuthPassword,
334
+ )
335
+ ) {
336
+ return {
337
+ response: new Response("Unauthorized", {
338
+ status: 401,
339
+ headers: {
340
+ "WWW-Authenticate": 'Basic realm="Metrics"',
341
+ "Content-Type": "text/plain",
342
+ },
343
+ }),
344
+ };
345
+ }
328
346
  const body = await api.observability.collectMetrics();
329
347
  return {
330
348
  response: new Response(body || "", {
@@ -17,7 +17,7 @@ WEB_SERVER_ALLOWED_METHODS="GET, POST, PUT, DELETE, OPTIONS"
17
17
 
18
18
  MCP_SERVER_ENABLED=true
19
19
 
20
- SESSION_TTL=86400000
20
+ SESSION_TTL=86400
21
21
  SESSION_COOKIE_NAME="__session"
22
22
 
23
23
  DATABASE_URL="postgres://$USER@localhost:5432/{{projectName}}"
package/util/config.ts CHANGED
@@ -1,25 +1,24 @@
1
- /**
2
- Deep-merges source into target, mutating target in place.
3
- Only plain objects are recursively merged; arrays and primitives are overwritten.
4
- */
5
- export function deepMerge<T extends Record<string, any>>(
1
+ type MergeMode = "overwrite" | "defaults";
2
+
3
+ function mergeWith<T extends Record<string, any>>(
6
4
  target: T,
7
5
  source: Record<string, any>,
6
+ mode: MergeMode,
8
7
  ): T {
9
8
  for (const key of Object.keys(source)) {
10
9
  const targetVal = target[key];
11
10
  const sourceVal = source[key];
12
-
13
- if (
11
+ const bothPlainObjects =
14
12
  targetVal &&
15
13
  sourceVal &&
16
14
  typeof targetVal === "object" &&
17
15
  typeof sourceVal === "object" &&
18
16
  !Array.isArray(targetVal) &&
19
- !Array.isArray(sourceVal)
20
- ) {
21
- deepMerge(targetVal, sourceVal);
22
- } else {
17
+ !Array.isArray(sourceVal);
18
+
19
+ if (bothPlainObjects) {
20
+ mergeWith(targetVal, sourceVal, mode);
21
+ } else if (mode === "overwrite" || !(key in target)) {
23
22
  (target as any)[key] = sourceVal;
24
23
  }
25
24
  }
@@ -27,6 +26,17 @@ export function deepMerge<T extends Record<string, any>>(
27
26
  return target;
28
27
  }
29
28
 
29
+ /**
30
+ Deep-merges source into target, mutating target in place.
31
+ Only plain objects are recursively merged; arrays and primitives are overwritten.
32
+ */
33
+ export function deepMerge<T extends Record<string, any>>(
34
+ target: T,
35
+ source: Record<string, any>,
36
+ ): T {
37
+ return mergeWith(target, source, "overwrite");
38
+ }
39
+
30
40
  /**
31
41
  * Like `deepMerge`, but only sets values that don't already exist in target.
32
42
  * Useful for applying plugin config defaults without overwriting user-set values.
@@ -35,28 +45,7 @@ export function deepMergeDefaults<T extends Record<string, any>>(
35
45
  target: T,
36
46
  source: Record<string, any>,
37
47
  ): T {
38
- for (const key of Object.keys(source)) {
39
- if (!(key in target)) {
40
- (target as any)[key] = source[key];
41
- } else {
42
- const targetVal = target[key];
43
- const sourceVal = source[key];
44
-
45
- if (
46
- targetVal &&
47
- sourceVal &&
48
- typeof targetVal === "object" &&
49
- typeof sourceVal === "object" &&
50
- !Array.isArray(targetVal) &&
51
- !Array.isArray(sourceVal)
52
- ) {
53
- deepMergeDefaults(targetVal, sourceVal);
54
- }
55
- // If key exists in target and isn't a nested object, keep the target value
56
- }
57
- }
58
-
59
- return target;
48
+ return mergeWith(target, source, "defaults");
60
49
  }
61
50
 
62
51
  /**
package/util/http.ts CHANGED
@@ -35,10 +35,20 @@ export function buildCorsHeaders(
35
35
  }
36
36
 
37
37
  /**
38
- * Derive the external-facing origin for a request.
39
- * Respects reverse-proxy headers (`X-Forwarded-Proto` / `X-Forwarded-Host`)
40
- * so that URLs are correct when behind ngrok, a load balancer, etc.
41
- * Falls back to the parsed request-URL origin.
38
+ * Derive the external-facing origin for a request. Used to construct OAuth
39
+ * metadata URLs (issuer, endpoints) and the MCP `WWW-Authenticate` resource
40
+ * metadata URL.
41
+ *
42
+ * Resolution order:
43
+ * 1. `applicationUrl` config (when set to a non-localhost value).
44
+ * 2. `X-Forwarded-Proto` / `X-Forwarded-Host` (or `Host`) headers — only when
45
+ * `config.server.mcp.oauthTrustProxy` is enabled. These headers are
46
+ * spoofable by any client when the server is reachable directly, so
47
+ * trusting them unconditionally would let an attacker poison OAuth
48
+ * metadata and MCP `WWW-Authenticate` URLs. Operators must opt in via
49
+ * `MCP_OAUTH_TRUST_PROXY=true` after confirming a reverse proxy strips
50
+ * client-supplied forwarded headers.
51
+ * 3. The parsed request-URL origin.
42
52
  */
43
53
  export function getExternalOrigin(req: Request, url: URL): string {
44
54
  // Prefer explicitly configured APPLICATION_URL (for proxy/tunnel scenarios
@@ -48,17 +58,18 @@ export function getExternalOrigin(req: Request, url: URL): string {
48
58
  return new URL(appUrl).origin;
49
59
  }
50
60
 
51
- // Fall back to reverse-proxy headers
52
- const forwardedProto = req.headers.get("x-forwarded-proto");
53
- const forwardedHost =
54
- req.headers.get("x-forwarded-host") || req.headers.get("host");
61
+ if (config.server.mcp.oauthTrustProxy) {
62
+ const forwardedProto = req.headers.get("x-forwarded-proto");
63
+ const forwardedHost =
64
+ req.headers.get("x-forwarded-host") || req.headers.get("host");
55
65
 
56
- if (forwardedProto && forwardedHost) {
57
- return `${forwardedProto}://${forwardedHost}`;
58
- }
66
+ if (forwardedProto && forwardedHost) {
67
+ return `${forwardedProto}://${forwardedHost}`;
68
+ }
59
69
 
60
- if (forwardedHost) {
61
- return `${url.protocol}//${forwardedHost}`;
70
+ if (forwardedHost) {
71
+ return `${url.protocol}//${forwardedHost}`;
72
+ }
62
73
  }
63
74
 
64
75
  return url.origin;
@@ -0,0 +1,53 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+
3
+ // Pads to a common length so we don't leak the expected length via early-return.
4
+ function timingSafeStringEqual(a: string, b: string): boolean {
5
+ const aBuf = Buffer.from(a, "utf8");
6
+ const bBuf = Buffer.from(b, "utf8");
7
+ const len = Math.max(aBuf.length, bBuf.length, 1);
8
+ const aPadded = Buffer.alloc(len);
9
+ const bPadded = Buffer.alloc(len);
10
+ aBuf.copy(aPadded);
11
+ bBuf.copy(bPadded);
12
+ return timingSafeEqual(aPadded, bPadded) && aBuf.length === bBuf.length;
13
+ }
14
+
15
+ /**
16
+ * Verifies an HTTP Basic auth `Authorization` header against expected credentials
17
+ * using a constant-time string compare.
18
+ *
19
+ * @param req - The incoming `Request`. Read for its `Authorization` header.
20
+ * @param expectedUsername - The username to match. If empty, auth is treated as
21
+ * disabled and the function returns `true`.
22
+ * @param expectedPassword - The password to match. If empty, auth is treated as
23
+ * disabled and the function returns `true`.
24
+ * @returns `true` when auth is disabled (either credential empty) or when the
25
+ * header carries valid `Basic <base64>` credentials matching both expected
26
+ * values. `false` for any malformed, missing, or wrong header.
27
+ */
28
+ export function verifyBasicAuth(
29
+ req: Request,
30
+ expectedUsername: string,
31
+ expectedPassword: string,
32
+ ): boolean {
33
+ if (!expectedUsername || !expectedPassword) return true;
34
+
35
+ const header = req.headers.get("Authorization");
36
+ if (!header?.startsWith("Basic ")) return false;
37
+
38
+ let decoded: string;
39
+ try {
40
+ decoded = atob(header.slice(6).trim());
41
+ } catch {
42
+ return false;
43
+ }
44
+
45
+ const idx = decoded.indexOf(":");
46
+ if (idx === -1) return false;
47
+ const user = decoded.slice(0, idx);
48
+ const pass = decoded.slice(idx + 1);
49
+
50
+ const userOk = timingSafeStringEqual(user, expectedUsername);
51
+ const passOk = timingSafeStringEqual(pass, expectedPassword);
52
+ return userOk && passOk;
53
+ }
@@ -112,6 +112,28 @@ async function readBodyWithLimit(req: Request): Promise<string> {
112
112
  return new TextDecoder().decode(merged);
113
113
  }
114
114
 
115
+ /**
116
+ * Merge a value into the params object under `key`, appending to any existing
117
+ * value rather than replacing it. If `key` is not set, assigns `value` as-is.
118
+ * If it is set, produces an array containing the existing value(s) followed by
119
+ * the incoming value(s). Used to fold body, form-data, and query string
120
+ * sources into a single params object while preserving repeated keys.
121
+ */
122
+ function appendParam(
123
+ params: Record<string, unknown>,
124
+ key: string,
125
+ value: unknown,
126
+ ): void {
127
+ if (params[key] === undefined) {
128
+ params[key] = value;
129
+ return;
130
+ }
131
+ const incoming = Array.isArray(value) ? value : [value];
132
+ params[key] = Array.isArray(params[key])
133
+ ? [...(params[key] as unknown[]), ...incoming]
134
+ : [params[key], ...incoming];
135
+ }
136
+
115
137
  /**
116
138
  * Parse request parameters from path params, request body (JSON or form-data),
117
139
  * and query string into a single plain object.
@@ -179,44 +201,12 @@ export async function parseRequestParams(
179
201
  }
180
202
 
181
203
  const f = await req.formData();
182
- f.forEach((value, key) => {
183
- if (params[key] !== undefined) {
184
- if (Array.isArray(params[key])) {
185
- (params[key] as unknown[]).push(value);
186
- } else {
187
- params[key] = [params[key], value];
188
- }
189
- } else {
190
- params[key] = value;
191
- }
192
- });
204
+ f.forEach((value, key) => appendParam(params, key, value));
193
205
  }
194
206
 
195
207
  if (url.query) {
196
208
  for (const [key, values] of Object.entries(url.query)) {
197
- if (values !== undefined) {
198
- if (Array.isArray(values)) {
199
- if (params[key] !== undefined) {
200
- if (Array.isArray(params[key])) {
201
- (params[key] as unknown[]).push(...values);
202
- } else {
203
- params[key] = [params[key], ...values];
204
- }
205
- } else {
206
- params[key] = values;
207
- }
208
- } else {
209
- if (params[key] !== undefined) {
210
- if (Array.isArray(params[key])) {
211
- (params[key] as unknown[]).push(values);
212
- } else {
213
- params[key] = [params[key], values];
214
- }
215
- } else {
216
- params[key] = values;
217
- }
218
- }
219
- }
209
+ if (values !== undefined) appendParam(params, key, values);
220
210
  }
221
211
  }
222
212