keryx 0.29.4 → 0.29.6

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.
@@ -291,6 +291,28 @@ export class Connection<
291
291
  return api.session.update(this.session, data);
292
292
  }
293
293
 
294
+ /**
295
+ * Regenerate the session ID to prevent session fixation attacks.
296
+ * Copies existing session data to a new key in Redis, deletes the old key,
297
+ * and updates this connection's IDs so the response sets a fresh cookie.
298
+ * Should be called after successful authentication.
299
+ *
300
+ * @returns The session data under the new ID.
301
+ * @throws {TypedError} With `ErrorType.CONNECTION_SESSION_NOT_FOUND` if no session exists.
302
+ */
303
+ async regenerateSession() {
304
+ await this.loadSession();
305
+
306
+ if (!this.session) {
307
+ throw new TypedError({
308
+ message: "Session not found",
309
+ type: ErrorType.CONNECTION_SESSION_NOT_FOUND,
310
+ });
311
+ }
312
+
313
+ return api.session.regenerate(this);
314
+ }
315
+
294
316
  /** Add a channel to this connection's subscription set. */
295
317
  subscribe(channel: string) {
296
318
  this.subscriptions.add(channel);
@@ -66,6 +66,7 @@ export const configServerWeb = {
66
66
  "strict-origin-when-cross-origin",
67
67
  ),
68
68
  } as Record<string, string>,
69
+ maxBodySize: await loadFromEnvIfSet("WEB_MAX_BODY_SIZE", 10 * 1024 * 1024),
69
70
  compression: {
70
71
  enabled: await loadFromEnvIfSet("WEB_COMPRESSION_ENABLED", true),
71
72
  threshold: await loadFromEnvIfSet("WEB_COMPRESSION_THRESHOLD", 1024),
@@ -61,7 +61,10 @@ export class McpInitializer extends Initializer {
61
61
  const mcpServers: McpServer[] = [];
62
62
  const transports = new Map<
63
63
  string,
64
- WebStandardStreamableHTTPServerTransport
64
+ {
65
+ transport: WebStandardStreamableHTTPServerTransport;
66
+ clientId: string;
67
+ }
65
68
  >();
66
69
 
67
70
  function sendNotification(payload: PubSubMessage) {
@@ -207,11 +210,12 @@ export class McpInitializer extends Initializer {
207
210
  const mcpServer = createMcpServer();
208
211
  mcpServers.push(mcpServer);
209
212
 
213
+ const sessionClientId = authInfo.clientId;
210
214
  const transport = new WebStandardStreamableHTTPServerTransport({
211
215
  sessionIdGenerator: () => randomUUID(),
212
216
  enableJsonResponse: true,
213
217
  onsessioninitialized: async (sid) => {
214
- transports.set(sid, transport);
218
+ transports.set(sid, { transport, clientId: sessionClientId });
215
219
  for (const hook of api.hooks.mcp.onConnectHooks) {
216
220
  await hook(sid);
217
221
  }
@@ -244,8 +248,8 @@ export class McpInitializer extends Initializer {
244
248
  }
245
249
 
246
250
  if (sessionId) {
247
- const transport = transports.get(sessionId);
248
- if (!transport) {
251
+ const session = transports.get(sessionId);
252
+ if (!session) {
249
253
  return mcpJsonResponse(
250
254
  { error: "Session not found" },
251
255
  404,
@@ -253,10 +257,23 @@ export class McpInitializer extends Initializer {
253
257
  );
254
258
  }
255
259
 
260
+ if (session.clientId !== authInfo.clientId) {
261
+ return mcpJsonResponse(
262
+ { error: "Token does not match session" },
263
+ 403,
264
+ corsHeaders,
265
+ );
266
+ }
267
+
256
268
  for (const hook of api.hooks.mcp.onMessageHooks) {
257
269
  await hook(sessionId);
258
270
  }
259
- return handleTransportRequest(transport, req, authInfo, corsHeaders);
271
+ return handleTransportRequest(
272
+ session.transport,
273
+ req,
274
+ authInfo,
275
+ corsHeaders,
276
+ );
260
277
  }
261
278
 
262
279
  // GET/DELETE without session ID
@@ -276,7 +293,7 @@ export class McpInitializer extends Initializer {
276
293
  if (!config.server.mcp.enabled) return;
277
294
 
278
295
  // Close all transports
279
- for (const transport of api.mcp.transports.values()) {
296
+ for (const { transport } of api.mcp.transports.values()) {
280
297
  try {
281
298
  await transport.close();
282
299
  } catch {
@@ -193,8 +193,10 @@ export class Resque extends Initializer {
193
193
  worker.on("end", () => {
194
194
  logger.info(`[resque:${worker.name}] ended`);
195
195
  });
196
- worker.on("cleaning_worker", () => {
197
- logger.debug(`[resque:${worker.name}] cleaning worker`);
196
+ worker.on("cleaning_worker", (workerName, pid) => {
197
+ logger.debug(
198
+ `[resque:${worker.name}] cleaning worker, ${workerName}, ${pid}`,
199
+ );
198
200
  });
199
201
  worker.on("poll", (queue) => {
200
202
  logger.debug(`[resque:${worker.name}] polling, ${queue}`);
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "crypto";
1
2
  import { api, Connection } from "../api";
2
3
  import { Initializer } from "../classes/Initializer";
3
4
  import { config } from "../config";
@@ -75,6 +76,46 @@ async function update<T extends Record<string, any>>(
75
76
  return session.data;
76
77
  }
77
78
 
79
+ /**
80
+ * Regenerate the session ID for a connection to prevent session fixation attacks.
81
+ * Creates a new session with a fresh UUID, copies existing data, deletes the old
82
+ * session key from Redis, and updates the connection's IDs so the next response
83
+ * sets a new cookie value.
84
+ *
85
+ * @param connection - The connection whose session to regenerate.
86
+ * @returns The session data under the new ID, or `null` if no session existed.
87
+ */
88
+ async function regenerate<T extends Record<string, any>>(
89
+ connection: Connection,
90
+ ) {
91
+ const oldSessionId = connection.sessionId;
92
+ const oldKey = getKey(oldSessionId);
93
+ const newSessionId = randomUUID();
94
+ const newKey = getKey(newSessionId);
95
+
96
+ const raw = await api.redis.redis.get(oldKey);
97
+ if (!raw) return null;
98
+
99
+ const sessionData = JSON.parse(raw) as SessionData<T>;
100
+ sessionData.id = newSessionId;
101
+
102
+ await api.redis.redis.set(newKey, JSON.stringify(sessionData));
103
+ await api.redis.redis.expire(newKey, config.session.ttl);
104
+ await api.redis.redis.del(oldKey);
105
+
106
+ // Update the connection map when connection.id tracks the session cookie
107
+ const oldId = connection.id;
108
+ if (oldId === oldSessionId) {
109
+ api.connections.connections.delete(oldId);
110
+ connection.id = newSessionId;
111
+ api.connections.connections.set(newSessionId, connection);
112
+ }
113
+ connection.sessionId = newSessionId;
114
+ connection.session = sessionData as SessionData<T>;
115
+
116
+ return connection.session;
117
+ }
118
+
78
119
  /**
79
120
  * Delete a session from Redis.
80
121
  *
@@ -104,6 +145,7 @@ export class Session extends Initializer {
104
145
  load,
105
146
  create,
106
147
  update,
148
+ regenerate,
107
149
  destroy,
108
150
  };
109
151
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keryx",
3
- "version": "0.29.4",
3
+ "version": "0.29.6",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/servers/web.ts CHANGED
@@ -17,7 +17,11 @@ import {
17
17
  buildErrorPayload,
18
18
  buildResponse,
19
19
  } from "../util/webResponse";
20
- import { determineActionName, parseRequestParams } from "../util/webRouting";
20
+ import {
21
+ checkBodySize,
22
+ determineActionName,
23
+ parseRequestParams,
24
+ } from "../util/webRouting";
21
25
  import {
22
26
  handleWebsocketAction,
23
27
  handleWebsocketSubscribe,
@@ -409,14 +413,16 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
409
413
  await hook(connection, message);
410
414
  }
411
415
 
416
+ let messageId: unknown;
412
417
  try {
413
418
  const parsedMessage = JSON.parse(message.toString());
419
+ messageId = parsedMessage["messageId"];
414
420
  if (parsedMessage["messageType"] === "action") {
415
- handleWebsocketAction(connection, ws, parsedMessage);
421
+ await handleWebsocketAction(connection, ws, parsedMessage);
416
422
  } else if (parsedMessage["messageType"] === "subscribe") {
417
- handleWebsocketSubscribe(connection, ws, parsedMessage);
423
+ await handleWebsocketSubscribe(connection, ws, parsedMessage);
418
424
  } else if (parsedMessage["messageType"] === "unsubscribe") {
419
- handleWebsocketUnsubscribe(connection, ws, parsedMessage);
425
+ await handleWebsocketUnsubscribe(connection, ws, parsedMessage);
420
426
  } else {
421
427
  throw new TypedError({
422
428
  message: `messageType either missing or unknown`,
@@ -426,6 +432,7 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
426
432
  } catch (e) {
427
433
  ws.send(
428
434
  JSON.stringify({
435
+ messageId,
429
436
  error: buildErrorPayload(
430
437
  new TypedError({
431
438
  message: `${e}`,
@@ -508,13 +515,38 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
508
515
  return { response: buildResponse(connection, {}, 200, requestOrigin) };
509
516
  }
510
517
 
518
+ // Reject oversized request bodies before reading them
519
+ try {
520
+ checkBodySize(req);
521
+ } catch (e) {
522
+ connection.destroy();
523
+ if (e instanceof TypedError) {
524
+ return {
525
+ response: buildError(connection, e, 413, requestOrigin),
526
+ };
527
+ }
528
+ throw e;
529
+ }
530
+
511
531
  const { actionName, pathParams } = await determineActionName(
512
532
  url,
513
533
  httpMethod,
514
534
  );
515
535
  if (!actionName) errorStatusCode = 404;
516
536
 
517
- const params = await parseRequestParams(req, url, pathParams ?? undefined);
537
+ let params: Record<string, unknown>;
538
+ try {
539
+ params = await parseRequestParams(req, url, pathParams ?? undefined);
540
+ } catch (e) {
541
+ if (
542
+ e instanceof TypedError &&
543
+ e.message.startsWith("Payload Too Large")
544
+ ) {
545
+ connection.destroy();
546
+ return { response: buildError(connection, e, 413, requestOrigin) };
547
+ }
548
+ throw e;
549
+ }
518
550
 
519
551
  const { response, error } = await connection.act(
520
552
  actionName!,
@@ -61,6 +61,7 @@ export class SessionCreate implements Action {
61
61
  }
62
62
 
63
63
  await connection.updateSession({ userId: user.id });
64
+ await connection.regenerateSession();
64
65
 
65
66
  return {
66
67
  user: serializeUser(user),
@@ -34,6 +34,84 @@ export async function determineActionName(
34
34
  return { actionName: match.actionName, pathParams: match.pathParams };
35
35
  }
36
36
 
37
+ /**
38
+ * Reject requests whose body exceeds {@link config.server.web.maxBodySize}
39
+ * based on the `Content-Length` header.
40
+ *
41
+ * This is a fast, zero-I/O pre-flight check. Requests that declare a body
42
+ * size above the limit are rejected immediately with no body reading.
43
+ * Chunked/streaming requests without `Content-Length` bypass this check —
44
+ * they are caught by {@link readBodyWithLimit} during body parsing.
45
+ *
46
+ * @param req - The incoming HTTP request.
47
+ * @throws {TypedError} With type {@link ErrorType.CONNECTION_ACTION_RUN} and
48
+ * an HTTP-friendly "Payload Too Large" message when the declared body
49
+ * size exceeds the configured limit.
50
+ */
51
+ export function checkBodySize(req: Request): void {
52
+ const maxBodySize = config.server.web.maxBodySize;
53
+ if (maxBodySize <= 0) return;
54
+ if (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS")
55
+ return;
56
+
57
+ const contentLength = req.headers.get("content-length");
58
+ if (contentLength !== null) {
59
+ const length = parseInt(contentLength, 10);
60
+ if (!Number.isNaN(length) && length > maxBodySize) {
61
+ throw new TypedError({
62
+ message: `Payload Too Large — body of ${length} bytes exceeds the ${maxBodySize} byte limit`,
63
+ type: ErrorType.CONNECTION_ACTION_RUN,
64
+ });
65
+ }
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Read a request body as bytes, aborting early if the configured
71
+ * {@link config.server.web.maxBodySize} is exceeded. Returns the body as a
72
+ * UTF-8 string. Unlike `req.text()`, this never allocates the full oversized
73
+ * payload — it reads the stream chunk-by-chunk and cancels as soon as the
74
+ * limit is breached.
75
+ *
76
+ * @param req - The incoming HTTP request (body must not already be consumed).
77
+ * @returns The body decoded as a UTF-8 string.
78
+ * @throws {TypedError} With type {@link ErrorType.CONNECTION_ACTION_RUN}
79
+ * when the body exceeds the configured limit.
80
+ */
81
+ async function readBodyWithLimit(req: Request): Promise<string> {
82
+ const maxBodySize = config.server.web.maxBodySize;
83
+
84
+ if (maxBodySize <= 0 || !req.body) {
85
+ return await req.text();
86
+ }
87
+
88
+ const reader = req.body.getReader();
89
+ const chunks: Uint8Array[] = [];
90
+ let received = 0;
91
+
92
+ while (true) {
93
+ const { done, value } = await reader.read();
94
+ if (done) break;
95
+ received += value.byteLength;
96
+ if (received > maxBodySize) {
97
+ reader.cancel();
98
+ throw new TypedError({
99
+ message: `Payload Too Large — body exceeds the ${maxBodySize} byte limit`,
100
+ type: ErrorType.CONNECTION_ACTION_RUN,
101
+ });
102
+ }
103
+ chunks.push(value);
104
+ }
105
+
106
+ const merged = new Uint8Array(received);
107
+ let offset = 0;
108
+ for (const chunk of chunks) {
109
+ merged.set(chunk, offset);
110
+ offset += chunk.byteLength;
111
+ }
112
+ return new TextDecoder().decode(merged);
113
+ }
114
+
37
115
  /**
38
116
  * Parse request parameters from path params, request body (JSON or form-data),
39
117
  * and query string into a single plain object.
@@ -68,12 +146,16 @@ export async function parseRequestParams(
68
146
  req.headers.get("content-type") === "application/json"
69
147
  ) {
70
148
  try {
71
- const bodyContent = (await req.json()) as Record<string, unknown>;
149
+ // Use streaming reader that aborts early if the body exceeds the
150
+ // configured limit — never allocates the full oversized payload.
151
+ const text = await readBodyWithLimit(req);
152
+ const bodyContent = JSON.parse(text) as Record<string, unknown>;
72
153
  // Merge JSON body directly — preserves types (objects, arrays, booleans, numbers)
73
154
  for (const [key, value] of Object.entries(bodyContent)) {
74
155
  params[key] = value;
75
156
  }
76
157
  } catch (e) {
158
+ if (e instanceof TypedError) throw e;
77
159
  throw new TypedError({
78
160
  message: `cannot parse request body: ${e}`,
79
161
  type: ErrorType.CONNECTION_ACTION_RUN,
@@ -87,6 +169,15 @@ export async function parseRequestParams(
87
169
  .get("content-type")
88
170
  ?.includes("application/x-www-form-urlencoded"))
89
171
  ) {
172
+ // For form data without a Content-Length header, stream-read the clone
173
+ // to enforce the body size limit before handing off to the FormData parser.
174
+ if (
175
+ config.server.web.maxBodySize > 0 &&
176
+ !req.headers.get("content-length")
177
+ ) {
178
+ await readBodyWithLimit(req.clone() as Request);
179
+ }
180
+
90
181
  const f = await req.formData();
91
182
  f.forEach((value, key) => {
92
183
  if (params[key] !== undefined) {