diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 07dd874..6a6fc3a 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -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", @@ -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" }, diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index d7b79a0..34cd555 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -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"; @@ -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 | null = null; @@ -102,6 +106,7 @@ export class CodeMode { maxRequests: options.maxRequests, maxResponseBytes: options.maxResponseBytes, allowedHeaders: options.allowedHeaders, + exposedResponseHeaders: options.exposedResponseHeaders, }; } diff --git a/packages/codemode/src/request-bridge.ts b/packages/codemode/src/request-bridge.ts index a15eaf2..03c3c48 100644 --- a/packages/codemode/src/request-bridge.ts +++ b/packages/codemode/src/request-bridge.ts @@ -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([ @@ -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; @@ -139,16 +146,18 @@ function validatePath(path: string): void { */ function filterHeaders( headers: Record | undefined, - allowedHeaders: string[] | undefined, + allowedHeaders: Set | undefined, ): Record { 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 = {}; for (const [key, value] of Object.entries(headers)) { - if (allowed.has(key.toLowerCase())) { + if (allowedHeaders.has(key.toLowerCase()) && !isBlocked(key)) { filtered[key] = value; } } @@ -158,8 +167,23 @@ function filterHeaders( // Blocklist mode: strip dangerous headers const filtered: Record = {}; 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 | undefined, +): Record { + if (!exposedResponseHeaders) return {}; + + const filtered: Record = {}; + for (const key of exposedResponseHeaders) { + const value = headers.get(key); + if (value !== null) { filtered[key] = value; } } @@ -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; @@ -234,11 +263,7 @@ export function createRequestBridge( // Call the host handler const response = await handler(url.toString(), init); - // Parse response headers - const responseHeaders: Record = {}; - 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. diff --git a/packages/codemode/src/types.ts b/packages/codemode/src/types.ts index cd7aac9..361c3ab 100644 --- a/packages/codemode/src/types.ts +++ b/packages/codemode/src/types.ts @@ -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. diff --git a/packages/codemode/test/executor-contract.ts b/packages/codemode/test/executor-contract.ts index b614a79..f9a7cf6 100644 --- a/packages/codemode/test/executor-contract.ts +++ b/packages/codemode/test/executor-contract.ts @@ -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( diff --git a/packages/codemode/test/package-publication.test.ts b/packages/codemode/test/package-publication.test.ts index e9c936e..ce25418 100644 --- a/packages/codemode/test/package-publication.test.ts +++ b/packages/codemode/test/package-publication.test.ts @@ -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:*"); }); diff --git a/packages/codemode/test/request-bridge.test.ts b/packages/codemode/test/request-bridge.test.ts index c269302..1702045 100644 --- a/packages/codemode/test/request-bridge.test.ts +++ b/packages/codemode/test/request-bridge.test.ts @@ -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", }, @@ -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"); }); @@ -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 }; + 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 = () => { diff --git a/packages/llrt/native/Cargo.lock b/packages/llrt/native/Cargo.lock index da6d868..ae0c789 100644 --- a/packages/llrt/native/Cargo.lock +++ b/packages/llrt/native/Cargo.lock @@ -1623,7 +1623,7 @@ dependencies = [ [[package]] name = "llrt_node" -version = "0.1.0" +version = "0.1.1" dependencies = [ "llrt_core", "llrt_json", diff --git a/packages/llrt/native/Cargo.toml b/packages/llrt/native/Cargo.toml index 5c6c057..8e75a98 100644 --- a/packages/llrt/native/Cargo.toml +++ b/packages/llrt/native/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "llrt_node" -version = "0.1.0" +version = "0.1.1" edition = "2021" license = "MIT" diff --git a/packages/llrt/native/src/runtime.rs b/packages/llrt/native/src/runtime.rs index 7dcd204..57e1cf2 100644 --- a/packages/llrt/native/src/runtime.rs +++ b/packages/llrt/native/src/runtime.rs @@ -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::*, @@ -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() }) diff --git a/packages/llrt/npm/darwin-arm64/package.json b/packages/llrt/npm/darwin-arm64/package.json index 06eced6..d1ba9c8 100644 --- a/packages/llrt/npm/darwin-arm64/package.json +++ b/packages/llrt/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@robinbraemer/llrt-darwin-arm64", - "version": "0.1.0", + "version": "0.1.1", "cpu": [ "arm64" ], @@ -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" }, diff --git a/packages/llrt/npm/darwin-x64/package.json b/packages/llrt/npm/darwin-x64/package.json index dcc73a9..7b656c4 100644 --- a/packages/llrt/npm/darwin-x64/package.json +++ b/packages/llrt/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@robinbraemer/llrt-darwin-x64", - "version": "0.1.0", + "version": "0.1.1", "cpu": [ "x64" ], @@ -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" }, diff --git a/packages/llrt/npm/linux-arm64-gnu/package.json b/packages/llrt/npm/linux-arm64-gnu/package.json index a947c0b..96bde61 100644 --- a/packages/llrt/npm/linux-arm64-gnu/package.json +++ b/packages/llrt/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@robinbraemer/llrt-linux-arm64-gnu", - "version": "0.1.0", + "version": "0.1.1", "cpu": [ "arm64" ], @@ -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" }, diff --git a/packages/llrt/npm/linux-x64-gnu/package.json b/packages/llrt/npm/linux-x64-gnu/package.json index 54e8b80..63d2e34 100644 --- a/packages/llrt/npm/linux-x64-gnu/package.json +++ b/packages/llrt/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@robinbraemer/llrt-linux-x64-gnu", - "version": "0.1.0", + "version": "0.1.1", "cpu": [ "x64" ], @@ -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" }, diff --git a/packages/llrt/package.json b/packages/llrt/package.json index 3a6fed5..15afb63 100644 --- a/packages/llrt/package.json +++ b/packages/llrt/package.json @@ -1,6 +1,6 @@ { "name": "@robinbraemer/llrt", - "version": "0.1.0", + "version": "0.1.1", "description": "TypeScript-friendly Node bindings for AWS LLRT.", "type": "module", "main": "./dist/index.js", @@ -46,6 +46,11 @@ ], "author": "Robin Braemer", "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/cnap-tech/codemode.git", + "directory": "packages/llrt" + }, "napi": { "binaryName": "llrt_node", "targets": [ diff --git a/packages/llrt/patches/disable-default-module-loading.patch b/packages/llrt/patches/disable-default-module-loading.patch new file mode 100644 index 0000000..16a39f1 --- /dev/null +++ b/packages/llrt/patches/disable-default-module-loading.patch @@ -0,0 +1,98 @@ +diff --git a/llrt_core/src/vm.rs b/llrt_core/src/vm.rs +index c323af3..88f7b6c 100644 +--- a/llrt_core/src/vm.rs ++++ b/llrt_core/src/vm.rs +@@ -34,6 +34,7 @@ pub struct Vm { + + pub struct VmOptions { + pub module_builder: ModuleBuilder, ++ pub allow_module_loading: bool, + pub max_stack_size: usize, + pub gc_threshold_mb: usize, + } +@@ -58,6 +59,7 @@ impl Default for VmOptions { + + Self { + module_builder, ++ allow_module_loading: true, + max_stack_size: 512 * 1024, + gc_threshold_mb: { + const DEFAULT_GC_THRESHOLD_MB: usize = 20; +@@ -82,40 +84,48 @@ impl Vm { + http::init()?; + security::init()?; + +- let mut file_resolver = FileResolver::default(); +- let mut paths: Vec<&str> = Vec::with_capacity(10); +- +- paths.push("."); +- +- let task_root = env::var(Self::ENV_LAMBDA_TASK_ROOT).unwrap_or_else(|_| String::from("")); +- let task_root = task_root.as_str(); +- if cfg!(debug_assertions) { +- paths.push("bundle"); +- } else { +- paths.push("/opt"); +- } +- +- if !task_root.is_empty() { +- paths.push(task_root); +- } +- +- for path in paths.iter() { +- file_resolver.add_path(*path); +- } +- + let (module_resolver, module_loader, global_attachment) = vm_options.module_builder.build(); +- let resolver = ( +- module_resolver, +- EmbeddedResolver, +- PackageResolver, +- file_resolver, +- ); +- let loader = (module_loader, EmbeddedLoader, PackageLoader); + + let runtime = AsyncRuntime::new()?; + runtime.set_max_stack_size(vm_options.max_stack_size).await; + runtime.set_gc_threshold(vm_options.gc_threshold_mb).await; +- runtime.set_loader(resolver, loader).await; ++ if vm_options.allow_module_loading { ++ let mut file_resolver = FileResolver::default(); ++ let mut paths: Vec<&str> = Vec::with_capacity(10); ++ ++ paths.push("."); ++ ++ let task_root = ++ env::var(Self::ENV_LAMBDA_TASK_ROOT).unwrap_or_else(|_| String::from("")); ++ let task_root = task_root.as_str(); ++ if cfg!(debug_assertions) { ++ paths.push("bundle"); ++ } else { ++ paths.push("/opt"); ++ } ++ ++ if !task_root.is_empty() { ++ paths.push(task_root); ++ } ++ ++ for path in paths.iter() { ++ file_resolver.add_path(*path); ++ } ++ ++ runtime ++ .set_loader( ++ ( ++ module_resolver, ++ EmbeddedResolver, ++ PackageResolver, ++ file_resolver, ++ ), ++ (module_loader, EmbeddedLoader, PackageLoader), ++ ) ++ .await; ++ } else { ++ runtime.set_loader(module_resolver, module_loader).await; ++ } + + let ctx = AsyncContext::full(&runtime).await?; + ctx.with(|ctx| { diff --git a/packages/llrt/scripts/prepare-llrt-source.mjs b/packages/llrt/scripts/prepare-llrt-source.mjs index 00593eb..7a5e78d 100644 --- a/packages/llrt/scripts/prepare-llrt-source.mjs +++ b/packages/llrt/scripts/prepare-llrt-source.mjs @@ -10,9 +10,11 @@ const LLRT_REVISION = "80c113ddee03ff1926068193f50fe35f41ca2105"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const vendorRoot = resolve(packageRoot, "vendor"); const llrtRoot = resolve(vendorRoot, "llrt"); +const patchesRoot = resolve(packageRoot, "patches"); await mkdir(vendorRoot, { recursive: true }); await ensureCheckout(); +await applyLocalPatches(); await ensureGeneratedBundle(); async function ensureCheckout() { @@ -34,6 +36,31 @@ async function ensureCheckout() { run("git", ["checkout", "--detach", LLRT_REVISION], llrtRoot); } +async function applyLocalPatches() { + applyPatchOnce(resolve(patchesRoot, "disable-default-module-loading.patch")); +} + +function applyPatchOnce(patchPath) { + const checkResult = spawnSync("git", ["apply", "--check", patchPath], { + cwd: llrtRoot, + stdio: "ignore", + }); + if (checkResult.status === 0) { + run("git", ["apply", patchPath], llrtRoot); + return; + } + + const reverseCheckResult = spawnSync("git", ["apply", "--reverse", "--check", patchPath], { + cwd: llrtRoot, + stdio: "ignore", + }); + if (reverseCheckResult.status === 0) { + return; + } + + throw new Error(`Unable to apply LLRT patch: ${patchPath}`); +} + async function ensureGeneratedBundle() { if (existsSync(resolve(llrtRoot, "bundle/js/@llrt/std.js"))) { return; diff --git a/packages/llrt/scripts/verify-native-artifacts.mjs b/packages/llrt/scripts/verify-native-artifacts.mjs index 45381b1..577208c 100644 --- a/packages/llrt/scripts/verify-native-artifacts.mjs +++ b/packages/llrt/scripts/verify-native-artifacts.mjs @@ -90,6 +90,21 @@ async function verifyNativePackage( expectArrayEqual(packageJson.os, [expectedPackage.os], `${relativePackageDir} os`); expectArrayEqual(packageJson.cpu, [expectedPackage.cpu], `${relativePackageDir} cpu`); expectEqual(packageJson.license, rootPackageJson.license, `${relativePackageDir} license`); + expectEqual( + packageJson.repository?.type, + rootPackageJson.repository?.type, + `${relativePackageDir} repository.type`, + ); + expectEqual( + packageJson.repository?.url, + rootPackageJson.repository?.url, + `${relativePackageDir} repository.url`, + ); + expectEqual( + packageJson.repository?.directory, + rootPackageJson.repository?.directory, + `${relativePackageDir} repository.directory`, + ); expectEqual( packageJson.publishConfig?.access, rootPackageJson.publishConfig?.access, diff --git a/packages/llrt/test/call-json.test.ts b/packages/llrt/test/call-json.test.ts index 1a9c555..d5673e8 100644 --- a/packages/llrt/test/call-json.test.ts +++ b/packages/llrt/test/call-json.test.ts @@ -94,6 +94,36 @@ describe("LlrtRuntime.callJson native execution", () => { expect(second).toMatchObject({ ok: true, value: "clean" }); }); + it("denies dynamic imports of host capability modules", async () => { + const runtime = new LlrtRuntime({ wallTimeMs: 1000, memoryMB: 8 }); + + const result = await runtime.callJson< + Record, + { blocked: true } | { leaked: string } + >( + `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: untrusted execution cannot resolve host modules. + } + } + return { blocked: true }; + }`, + {}, + ); + + expect(result).toMatchObject({ ok: true, value: { blocked: true } }); + }); + it("returns a typed timeout when guest code exceeds the wall-time limit", async () => { const runtime = new LlrtRuntime({ wallTimeMs: 1, memoryMB: 8 });