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.
- package/classes/Connection.ts +22 -0
- package/config/server/web.ts +1 -0
- package/initializers/mcp.ts +23 -6
- package/initializers/resque.ts +4 -2
- package/initializers/session.ts +42 -0
- package/package.json +1 -1
- package/servers/web.ts +37 -5
- package/templates/scaffold/actions-session.ts.mustache +1 -0
- package/util/webRouting.ts +92 -1
package/classes/Connection.ts
CHANGED
|
@@ -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);
|
package/config/server/web.ts
CHANGED
|
@@ -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),
|
package/initializers/mcp.ts
CHANGED
|
@@ -61,7 +61,10 @@ export class McpInitializer extends Initializer {
|
|
|
61
61
|
const mcpServers: McpServer[] = [];
|
|
62
62
|
const transports = new Map<
|
|
63
63
|
string,
|
|
64
|
-
|
|
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
|
|
248
|
-
if (!
|
|
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(
|
|
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 {
|
package/initializers/resque.ts
CHANGED
|
@@ -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(
|
|
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}`);
|
package/initializers/session.ts
CHANGED
|
@@ -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
package/servers/web.ts
CHANGED
|
@@ -17,7 +17,11 @@ import {
|
|
|
17
17
|
buildErrorPayload,
|
|
18
18
|
buildResponse,
|
|
19
19
|
} from "../util/webResponse";
|
|
20
|
-
import {
|
|
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
|
-
|
|
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!,
|
package/util/webRouting.ts
CHANGED
|
@@ -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
|
-
|
|
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) {
|