Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/codemode/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@robinbraemer/codemode",
"version": "0.3.0",
"version": "0.3.1",
"description": "Code Mode MCP tools from OpenAPI specs. Two tools (search + execute) replace hundreds of individual MCP tools.",
"type": "module",
"main": "./dist/index.js",
Expand Down Expand Up @@ -47,7 +47,7 @@
"url": "https://github.com/cnap-tech/codemode.git"
},
"peerDependencies": {
"@robinbraemer/llrt": "^0.1.0",
"@robinbraemer/llrt": "^0.1.1",
"isolated-vm": "6",
"quickjs-emscripten": ">=0.31"
},
Expand Down
9 changes: 7 additions & 2 deletions packages/codemode/src/codemode.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { createExecutor } from "./executor/auto.js";
import { createRequestBridge, type SandboxRequestOptions } from "./request-bridge.js";
import {
createRequestBridge,
type RequestBridgeOptions,
type SandboxRequestOptions,
} from "./request-bridge.js";
import { extractTags, processSpec } from "./spec.js";
import { createExecuteToolDefinition, createSearchToolDefinition } from "./tools.js";
import { truncateResponse } from "./truncate.js";
Expand Down Expand Up @@ -79,7 +83,7 @@ export class CodeMode {
// so the request counter resets each time.
private bridgeHandler: RequestHandler;
private bridgeBaseUrl: string;
private bridgeOptions: { maxRequests?: number; maxResponseBytes?: number; allowedHeaders?: string[] };
private bridgeOptions: RequestBridgeOptions;

// Cached processed spec & context for tool descriptions
private processedSpec: Record<string, unknown> | null = null;
Expand All @@ -102,6 +106,7 @@ export class CodeMode {
maxRequests: options.maxRequests,
maxResponseBytes: options.maxResponseBytes,
allowedHeaders: options.allowedHeaders,
exposedResponseHeaders: options.exposedResponseHeaders,
};
}

Expand Down
49 changes: 37 additions & 12 deletions packages/codemode/src/request-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export interface RequestBridgeOptions {
maxResponseBytes?: number;
/** Allowed headers whitelist. When undefined, uses default blocklist. */
allowedHeaders?: string[];
/** Response headers exposed to sandbox code. Default: none. */
exposedResponseHeaders?: string[];
}

const ALLOWED_METHODS = new Set([
Expand All @@ -53,6 +55,11 @@ const BLOCKED_HEADER_PATTERNS = [
/^connection$/i,
/^upgrade$/i,
/^te$/i,
/^forwarded$/i,
/^content-length$/i,
/^x-http-method-override$/i,
/^x-original-url$/i,
/^x-rewrite-url$/i,
];

const DEFAULT_MAX_REQUESTS = 50;
Expand Down Expand Up @@ -139,16 +146,18 @@ function validatePath(path: string): void {
*/
function filterHeaders(
headers: Record<string, string> | undefined,
allowedHeaders: string[] | undefined,
allowedHeaders: Set<string> | undefined,
): Record<string, string> {
if (!headers) return {};

const isBlocked = (key: string) => BLOCKED_HEADER_PATTERNS.some((p) => p.test(key));

if (allowedHeaders) {
// Whitelist mode: only forward explicitly allowed headers
const allowed = new Set(allowedHeaders.map((h) => h.toLowerCase()));
// Whitelist mode: only forward explicitly allowed headers after the
// hard denylist has removed credential, routing, and hop-by-hop headers.
const filtered: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
if (allowed.has(key.toLowerCase())) {
if (allowedHeaders.has(key.toLowerCase()) && !isBlocked(key)) {
filtered[key] = value;
}
}
Expand All @@ -158,8 +167,23 @@ function filterHeaders(
// Blocklist mode: strip dangerous headers
const filtered: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
const blocked = BLOCKED_HEADER_PATTERNS.some((p) => p.test(key));
if (!blocked) {
if (!isBlocked(key)) {
filtered[key] = value;
}
}
return filtered;
}

function filterResponseHeaders(
headers: Headers,
exposedResponseHeaders: Set<string> | undefined,
): Record<string, string> {
if (!exposedResponseHeaders) return {};

const filtered: Record<string, string> = {};
for (const key of exposedResponseHeaders) {
const value = headers.get(key);
if (value !== null) {
filtered[key] = value;
}
}
Expand All @@ -183,7 +207,12 @@ export function createRequestBridge(
): RequestBridgeFn {
const maxRequests = options.maxRequests ?? DEFAULT_MAX_REQUESTS;
const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
const allowedHeaders = options.allowedHeaders;
const allowedHeaders = options.allowedHeaders
? new Set(options.allowedHeaders.map((h) => h.toLowerCase()))
: undefined;
const exposedResponseHeaders = options.exposedResponseHeaders
? new Set(options.exposedResponseHeaders.map((h) => h.toLowerCase()))
: undefined;

let requestCount = 0;

Expand Down Expand Up @@ -234,11 +263,7 @@ export function createRequestBridge(
// Call the host handler
const response = await handler(url.toString(), init);

// Parse response headers
const responseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
const responseHeaders = filterResponseHeaders(response.headers, exposedResponseHeaders);

// Read response body with streaming size limit to avoid host OOM.
// Abort as soon as accumulated bytes exceed the limit.
Expand Down
10 changes: 8 additions & 2 deletions packages/codemode/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,17 @@ export interface CodeModeOptions {

/**
* Allowed headers whitelist. When set, only these headers are forwarded.
* When undefined, a default blocklist strips dangerous headers
* (Authorization, Cookie, Host, X-Forwarded-*, Proxy-*).
* Credential, routing override, forwarding, and hop-by-hop headers are
* always stripped even when listed here.
*/
allowedHeaders?: string[];

/**
* Response headers exposed to sandbox code.
* Default: none.
*/
exposedResponseHeaders?: string[];

/**
* Maximum $ref resolution depth.
* Default: 50.
Expand Down
26 changes: 26 additions & 0 deletions packages/codemode/test/executor-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,32 @@ export function executorContract(
expect(result3.result).toBe("undefined");
});

it("cannot dynamically import host capability modules", async () => {
const executor = factory();
const result = await executor.execute(
`async () => {
for (const specifier of ["node:fs", "fs", "node:process"]) {
try {
const imported = await import(specifier);
if (
typeof imported.readFileSync === "function" ||
typeof imported.default?.readFileSync === "function" ||
typeof imported.env === "object"
) {
return { leaked: specifier };
}
} catch {
// Expected: sandboxed execution cannot resolve host modules.
}
}
return { blocked: true };
}`,
{},
);
expect(result.error).toBeUndefined();
expect(result.result).toEqual({ blocked: true });
});

it("chains multiple async host calls", async () => {
const executor = factory();
const result = await executor.execute(
Expand Down
2 changes: 1 addition & 1 deletion packages/codemode/test/package-publication.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const publishWorkflowPath = join(root, ".github/workflows/publish.yml");
describe("codemode package publication", () => {
it("publishes the LLRT executor release with a compatible optional peer range", () => {
expect(codemodePackageJson.version).toMatch(/^(?!0\.2\.0$)\d+\.\d+\.\d+(?:[-+].*)?$/);
expect(codemodePackageJson.peerDependencies["@robinbraemer/llrt"]).toBe("^0.1.0");
expect(codemodePackageJson.peerDependencies["@robinbraemer/llrt"]).toBe("^0.1.1");
expect(codemodePackageJson.devDependencies["@robinbraemer/llrt"]).toBe("workspace:*");
});

Expand Down
78 changes: 78 additions & 0 deletions packages/codemode/test/request-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,11 @@ describe("header filtering", () => {
"connection": "keep-alive",
"upgrade": "websocket",
"te": "trailers",
"forwarded": "for=1.2.3.4;host=evil.com",
"content-length": "999",
"x-http-method-override": "DELETE",
"x-original-url": "/admin",
"x-rewrite-url": "/admin",
"x-custom": "safe",
"accept": "application/json",
},
Expand All @@ -168,6 +173,11 @@ describe("header filtering", () => {
expect(body.headers["connection"]).toBeUndefined();
expect(body.headers["upgrade"]).toBeUndefined();
expect(body.headers["te"]).toBeUndefined();
expect(body.headers["forwarded"]).toBeUndefined();
expect(body.headers["content-length"]).toBeUndefined();
expect(body.headers["x-http-method-override"]).toBeUndefined();
expect(body.headers["x-original-url"]).toBeUndefined();
expect(body.headers["x-rewrite-url"]).toBeUndefined();
expect(body.headers["x-custom"]).toBe("safe");
expect(body.headers["accept"]).toBe("application/json");
});
Expand All @@ -194,6 +204,74 @@ describe("header filtering", () => {
expect(body.headers["authorization"]).toBeUndefined();
expect(body.headers["x-custom"]).toBeUndefined();
});

it("never forwards protected headers even when allowedHeaders includes them", async () => {
const bridge = createRequestBridge(echoHandler, "http://localhost", {
allowedHeaders: ["authorization", "cookie", "host", "forwarded", "accept"],
});

const res = await bridge({
method: "GET",
path: "/test",
headers: {
"authorization": "Bearer secret",
"cookie": "session=abc",
"host": "evil.com",
"forwarded": "for=1.2.3.4;host=evil.com",
"accept": "application/json",
},
});

const body = res.body as { headers: Record<string, string> };
expect(body.headers["authorization"]).toBeUndefined();
expect(body.headers["cookie"]).toBeUndefined();
expect(body.headers["host"]).toBeUndefined();
expect(body.headers["forwarded"]).toBeUndefined();
expect(body.headers["accept"]).toBe("application/json");
});
});

describe("response header filtering", () => {
it("does not expose response headers to sandbox code by default", async () => {
const bridge = createRequestBridge(
() =>
Response.json(
{ ok: true },
{
headers: {
"set-cookie": "session=secret",
"x-internal-trace": "trace-secret",
},
},
),
"http://localhost",
);

const res = await bridge({ method: "GET", path: "/test" });

expect(res.headers).toEqual({});
});

it("exposes only explicitly allowed response headers", async () => {
const bridge = createRequestBridge(
() =>
Response.json(
{ ok: true },
{
headers: {
"etag": '"abc123"',
"set-cookie": "session=secret",
},
},
),
"http://localhost",
{ exposedResponseHeaders: ["etag"] },
);

const res = await bridge({ method: "GET", path: "/test" });

expect(res.headers).toEqual({ etag: '"abc123"' });
});
});

const largeHandler: RequestHandler = () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/llrt/native/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/llrt/native/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "llrt_node"
version = "0.1.0"
version = "0.1.1"
edition = "2021"
license = "MIT"

Expand Down
7 changes: 6 additions & 1 deletion packages/llrt/native/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ use std::{
time::{Duration, Instant},
};

use llrt_core::vm::{Vm, VmOptions};
use llrt_core::{
modules::module_builder::ModuleBuilder,
vm::{Vm, VmOptions},
};
use llrt_json::{parse::json_parse, stringify::json_stringify};
use napi::{
bindgen_prelude::Promise as NapiPromise, bindgen_prelude::*,
Expand Down Expand Up @@ -95,6 +98,8 @@ async fn call_json_inner(
.unwrap_or(64 * 1024 * 1024);

let vm = Vm::from_options(VmOptions {
module_builder: ModuleBuilder::new(),
allow_module_loading: false,
max_stack_size: max_stack_bytes,
..VmOptions::default()
})
Expand Down
7 changes: 6 additions & 1 deletion packages/llrt/npm/darwin-arm64/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@robinbraemer/llrt-darwin-arm64",
"version": "0.1.0",
"version": "0.1.1",
"cpu": [
"arm64"
],
Expand All @@ -18,6 +18,11 @@
],
"author": "Robin Braemer",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/cnap-tech/codemode.git",
"directory": "packages/llrt"
},
"publishConfig": {
"access": "public"
},
Expand Down
7 changes: 6 additions & 1 deletion packages/llrt/npm/darwin-x64/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@robinbraemer/llrt-darwin-x64",
"version": "0.1.0",
"version": "0.1.1",
"cpu": [
"x64"
],
Expand All @@ -18,6 +18,11 @@
],
"author": "Robin Braemer",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/cnap-tech/codemode.git",
"directory": "packages/llrt"
},
"publishConfig": {
"access": "public"
},
Expand Down
Loading
Loading