Skip to content

Commit 942cff9

Browse files
committed
fix: cancel LLRT host calls on timeout
Rationale: Security review found that LLRT wall-time timeouts returned to callers while host-side request work could keep running, which could exhaust handler concurrency or backend capacity. This also adds a request-body byte cap so sandbox code cannot amplify payloads into unbounded host allocations. Rejected: Passing abort context as an extra user argument because rest-argument callbacks would observe it as guest input. The context is bound as the host function this value instead, preserving guest argument arity. Risk: Host callbacks must cooperate with AbortSignal for their own long-running async work, but CodeMode's request bridge now forwards the signal to RequestInit and cancels response readers on abort/error. Tested: mise exec -- task ci Tested: pnpm --filter @robinbraemer/llrt run test:native
1 parent b3d6365 commit 942cff9

18 files changed

Lines changed: 209 additions & 33 deletions

File tree

packages/codemode/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@robinbraemer/codemode",
3-
"version": "0.3.1",
3+
"version": "0.3.2",
44
"description": "Code Mode MCP tools from OpenAPI specs. Two tools (search + execute) replace hundreds of individual MCP tools.",
55
"type": "module",
66
"main": "./dist/index.js",
@@ -47,7 +47,7 @@
4747
"url": "https://github.com/cnap-tech/codemode.git"
4848
},
4949
"peerDependencies": {
50-
"@robinbraemer/llrt": "^0.1.1",
50+
"@robinbraemer/llrt": "^0.1.2",
5151
"isolated-vm": "6",
5252
"quickjs-emscripten": ">=0.31"
5353
},

packages/codemode/src/codemode.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createExecutor } from "./executor/auto.js";
22
import {
33
createRequestBridge,
4+
type RequestBridgeContext,
45
type RequestBridgeOptions,
56
type SandboxRequestOptions,
67
} from "./request-bridge.js";
@@ -104,6 +105,7 @@ export class CodeMode {
104105
this.bridgeBaseUrl = options.baseUrl ?? "http://localhost";
105106
this.bridgeOptions = {
106107
maxRequests: options.maxRequests,
108+
maxRequestBytes: options.maxRequestBytes,
107109
maxResponseBytes: options.maxResponseBytes,
108110
allowedHeaders: options.allowedHeaders,
109111
exposedResponseHeaders: options.exposedResponseHeaders,
@@ -174,7 +176,9 @@ export class CodeMode {
174176
this.bridgeHandler, this.bridgeBaseUrl, this.bridgeOptions,
175177
);
176178
const client = {
177-
request: (...args: unknown[]) => bridge(args[0] as SandboxRequestOptions),
179+
request(this: RequestBridgeContext, options: SandboxRequestOptions) {
180+
return bridge(options, this);
181+
},
178182
};
179183

180184
const result = await executor.execute(code, {

packages/codemode/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ export { createExecutor } from "./executor/auto.js";
2525

2626
// Request bridge (for advanced usage / custom request handling)
2727
export { createRequestBridge } from "./request-bridge.js";
28-
export type { SandboxRequestOptions, SandboxResponse, RequestBridgeFn } from "./request-bridge.js";
28+
export type {
29+
RequestBridgeContext,
30+
RequestBridgeFn,
31+
SandboxRequestOptions,
32+
SandboxResponse,
33+
} from "./request-bridge.js";
2934

3035
// Spec processing
3136
export { resolveRefs, processSpec, extractTags, extractServerBasePath } from "./spec.js";

packages/codemode/src/request-bridge.ts

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export interface SandboxResponse {
2727
export interface RequestBridgeOptions {
2828
/** Maximum number of requests per bridge instance. Default: 50. */
2929
maxRequests?: number;
30+
/** Maximum request body size in bytes. Default: 1MB. */
31+
maxRequestBytes?: number;
3032
/** Maximum response body size in bytes. Default: 10MB. */
3133
maxResponseBytes?: number;
3234
/** Allowed headers whitelist. When undefined, uses default blocklist. */
@@ -35,6 +37,10 @@ export interface RequestBridgeOptions {
3537
exposedResponseHeaders?: string[];
3638
}
3739

40+
export interface RequestBridgeContext {
41+
signal?: AbortSignal;
42+
}
43+
3844
const ALLOWED_METHODS = new Set([
3945
"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS",
4046
]);
@@ -63,8 +69,45 @@ const BLOCKED_HEADER_PATTERNS = [
6369
];
6470

6571
const DEFAULT_MAX_REQUESTS = 50;
72+
const DEFAULT_MAX_REQUEST_BYTES = 1024 * 1024; // 1MB
6673
const DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10MB
6774

75+
function requestAbortedError(): Error {
76+
return new Error("Request aborted");
77+
}
78+
79+
function throwIfAborted(signal: AbortSignal | undefined): void {
80+
if (signal?.aborted) {
81+
throw requestAbortedError();
82+
}
83+
}
84+
85+
function utf8ByteLength(text: string): number {
86+
return Buffer.byteLength(text, "utf8");
87+
}
88+
89+
async function abortable<T>(
90+
operation: Promise<T>,
91+
signal: AbortSignal | undefined,
92+
): Promise<T> {
93+
if (!signal) return await operation;
94+
throwIfAborted(signal);
95+
96+
let onAbort: (() => void) | undefined;
97+
const aborted = new Promise<T>((_resolve, reject) => {
98+
onAbort = () => reject(requestAbortedError());
99+
signal.addEventListener("abort", onAbort, { once: true });
100+
});
101+
102+
try {
103+
return await Promise.race([operation, aborted]);
104+
} finally {
105+
if (onAbort) {
106+
signal.removeEventListener("abort", onAbort);
107+
}
108+
}
109+
}
110+
68111
/**
69112
* Read a response body as text, aborting early if it exceeds maxBytes.
70113
* Streams the body in chunks to avoid buffering the entire response
@@ -73,11 +116,13 @@ const DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10MB
73116
async function readResponseWithLimit(
74117
response: Response,
75118
maxBytes: number,
119+
signal?: AbortSignal,
76120
): Promise<string> {
121+
throwIfAborted(signal);
77122
const reader = response.body?.getReader();
78123
if (!reader) {
79124
// No body stream — fall back to .text() (e.g., empty responses)
80-
const text = await response.text();
125+
const text = await abortable(response.text(), signal);
81126
if (text.length > maxBytes) {
82127
throw new Error(
83128
`Response too large: ${text.length} bytes exceeds limit of ${maxBytes} bytes`,
@@ -88,20 +133,28 @@ async function readResponseWithLimit(
88133

89134
const chunks: Uint8Array[] = [];
90135
let totalBytes = 0;
136+
let shouldCancel = false;
91137
try {
92138
// Streaming read — must be sequential
93139
for (;;) {
94-
const { done, value } = await reader.read(); // oxlint-disable-line no-await-in-loop
140+
const { done, value } = await abortable(reader.read(), signal); // oxlint-disable-line no-await-in-loop
95141
if (done) break;
96142
totalBytes += value.byteLength;
97143
if (totalBytes > maxBytes) {
144+
shouldCancel = true;
98145
throw new Error(
99146
`Response too large: exceeded limit of ${maxBytes} bytes`,
100147
);
101148
}
102149
chunks.push(value);
103150
}
151+
} catch (error) {
152+
shouldCancel = true;
153+
throw error;
104154
} finally {
155+
if (shouldCancel) {
156+
await reader.cancel().catch(() => {});
157+
}
105158
reader.releaseLock();
106159
}
107160

@@ -195,7 +248,10 @@ function filterResponseHeaders(
195248
* Bridges sandbox API calls to the host request handler (Hono app.request, fetch, etc.).
196249
*/
197250
/** Bridge function with an exposed request count. */
198-
export type RequestBridgeFn = ((options: SandboxRequestOptions) => Promise<SandboxResponse>) & {
251+
export type RequestBridgeFn = ((
252+
options: SandboxRequestOptions,
253+
context?: RequestBridgeContext,
254+
) => Promise<SandboxResponse>) & {
199255
/** Number of requests made through this bridge instance. */
200256
readonly requestCount: number;
201257
};
@@ -206,6 +262,7 @@ export function createRequestBridge(
206262
options: RequestBridgeOptions = {},
207263
): RequestBridgeFn {
208264
const maxRequests = options.maxRequests ?? DEFAULT_MAX_REQUESTS;
265+
const maxRequestBytes = options.maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES;
209266
const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
210267
const allowedHeaders = options.allowedHeaders
211268
? new Set(options.allowedHeaders.map((h) => h.toLowerCase()))
@@ -216,8 +273,13 @@ export function createRequestBridge(
216273

217274
let requestCount = 0;
218275

219-
const bridge = async (opts: SandboxRequestOptions): Promise<SandboxResponse> => {
276+
const bridge = async (
277+
opts: SandboxRequestOptions,
278+
context?: RequestBridgeContext,
279+
): Promise<SandboxResponse> => {
280+
const signal = context?.signal;
220281
const { method, path, query, body, headers } = opts;
282+
throwIfAborted(signal);
221283

222284
// Validate request count
223285
if (++requestCount > maxRequests) {
@@ -252,23 +314,35 @@ export function createRequestBridge(
252314
const init: RequestInit = {
253315
method: upperMethod,
254316
headers: { ...filteredHeaders },
317+
signal,
255318
};
256319

257320
if (body !== undefined && body !== null) {
258-
init.body = JSON.stringify(body);
321+
const bodyJson = JSON.stringify(body);
322+
const bodyBytes = utf8ByteLength(bodyJson);
323+
if (bodyBytes > maxRequestBytes) {
324+
throw new Error(
325+
`Request body too large: ${bodyBytes} bytes exceeds limit of ${maxRequestBytes} bytes`,
326+
);
327+
}
328+
init.body = bodyJson;
259329
(init.headers as Record<string, string>)["content-type"] =
260330
(init.headers as Record<string, string>)["content-type"] ?? "application/json";
261331
}
262332

263333
// Call the host handler
264-
const response = await handler(url.toString(), init);
334+
const response = await abortable(
335+
Promise.resolve(handler(url.toString(), init)),
336+
signal,
337+
);
338+
throwIfAborted(signal);
265339

266340
const responseHeaders = filterResponseHeaders(response.headers, exposedResponseHeaders);
267341

268342
// Read response body with streaming size limit to avoid host OOM.
269343
// Abort as soon as accumulated bytes exceed the limit.
270344
const contentType = response.headers.get("content-type") ?? "";
271-
const text = await readResponseWithLimit(response, maxResponseBytes);
345+
const text = await readResponseWithLimit(response, maxResponseBytes, signal);
272346

273347
let responseBody: unknown;
274348
if (contentType.includes("application/json")) {

packages/codemode/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,12 @@ export interface CodeModeOptions {
164164
*/
165165
maxResponseBytes?: number;
166166

167+
/**
168+
* Maximum request body size in bytes.
169+
* Default: 1MB (1_048_576).
170+
*/
171+
maxRequestBytes?: number;
172+
167173
/**
168174
* Allowed headers whitelist. When set, only these headers are forwarded.
169175
* Credential, routing override, forwarding, and hop-by-hop headers are

packages/codemode/test/llrt-native-executor.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
describeWithLlrtNativeBinding as describe,
66
llrtNativeBindingAvailable,
77
} from "./llrt-native-test-helper.js";
8+
import type { LlrtHostCallContext } from "@robinbraemer/llrt";
89

910
if (llrtNativeBindingAvailable) {
1011
executorContract(
@@ -65,4 +66,58 @@ describe("LlrtNativeExecutor", () => {
6566
expect(result.error).toBeUndefined();
6667
expect(result.result).toEqual({ title: "Petstore", path: "/v1/pets" });
6768
});
69+
70+
it("does not expose host call context as a guest argument", async () => {
71+
const executor = new LlrtNativeExecutor({ memoryMB: 8, wallTimeMs: 1000 });
72+
73+
const result = await executor.execute(
74+
`async () => countArgs("a", "b")`,
75+
{ countArgs: (...args: unknown[]) => args.length },
76+
);
77+
78+
expect(result.error).toBeUndefined();
79+
expect(result.result).toBe(2);
80+
});
81+
82+
it("aborts in-flight host functions when execution times out", async () => {
83+
const executor = new LlrtNativeExecutor({ memoryMB: 8, wallTimeMs: 20 });
84+
let sawAbortSignal = false;
85+
let resolveAborted: (() => void) | undefined;
86+
const aborted = new Promise<void>((resolve) => {
87+
resolveAborted = resolve;
88+
});
89+
90+
const result = await executor.execute(
91+
`async () => {
92+
await api.request({ path: "/slow" });
93+
}`,
94+
{
95+
api: {
96+
request: async function (
97+
this: LlrtHostCallContext,
98+
_request: { path: string },
99+
) {
100+
if (!this.signal) {
101+
throw new Error("missing abort signal");
102+
}
103+
sawAbortSignal = true;
104+
await new Promise<void>((resolve) => {
105+
this.signal.addEventListener("abort", resolve, { once: true });
106+
});
107+
resolveAborted?.();
108+
return { status: 499, body: { aborted: true } };
109+
},
110+
},
111+
},
112+
);
113+
114+
expect(result.error).toContain("Wall-clock timeout exceeded");
115+
await Promise.race([
116+
aborted,
117+
new Promise<never>((_resolve, reject) => {
118+
setTimeout(() => reject(new Error("host function was not aborted")), 100);
119+
}),
120+
]);
121+
expect(sawAbortSignal).toBe(true);
122+
});
68123
});

packages/codemode/test/package-publication.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const publishWorkflowPath = join(root, ".github/workflows/publish.yml");
1010
describe("codemode package publication", () => {
1111
it("publishes the LLRT executor release with a compatible optional peer range", () => {
1212
expect(codemodePackageJson.version).toMatch(/^(?!0\.2\.0$)\d+\.\d+\.\d+(?:[-+].*)?$/);
13-
expect(codemodePackageJson.peerDependencies["@robinbraemer/llrt"]).toBe("^0.1.1");
13+
expect(codemodePackageJson.peerDependencies["@robinbraemer/llrt"]).toBe("^0.1.2");
1414
expect(codemodePackageJson.devDependencies["@robinbraemer/llrt"]).toBe("workspace:*");
1515
});
1616

packages/codemode/test/request-bridge.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,20 @@ describe("request limits", () => {
122122
bridge({ method: "GET", path: "/req-51" }),
123123
).rejects.toThrow("Request limit exceeded");
124124
});
125+
126+
it("rejects request bodies exceeding maxRequestBytes", async () => {
127+
const bridge = createRequestBridge(echoHandler, "http://localhost", {
128+
maxRequestBytes: 64,
129+
});
130+
131+
await expect(
132+
bridge({
133+
method: "POST",
134+
path: "/too-large",
135+
body: { payload: "x".repeat(128) },
136+
}),
137+
).rejects.toThrow("Request body too large");
138+
});
125139
});
126140

127141
describe("header filtering", () => {

packages/llrt/native/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/llrt/native/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "llrt_node"
3-
version = "0.1.1"
3+
version = "0.1.2"
44
edition = "2021"
55
license = "MIT"
66

0 commit comments

Comments
 (0)