feat(sandbox): expose screened $axios in script context

Add $axios to the VM sandbox with a request interceptor that blocks
non-http(s) URLs and common SSRF targets (localhost, private IPs,
link-local, and cloud metadata hosts). require('axios') and import
axios resolve to the same screened instance so scripts cannot bypass
URL screening.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
Cursor Agent
2026-08-14 04:56:25 +00:00
co-authored by nsrb
parent 5dc992a517
commit 8c9673444f
2 changed files with 90 additions and 5 deletions
+88 -2
View File
@@ -2,6 +2,7 @@ import fs from "fs";
import vm from "node:vm"; import vm from "node:vm";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import axios from "axios";
const hostRequire = createRequire(import.meta.url); const hostRequire = createRequire(import.meta.url);
@@ -185,11 +186,94 @@ function createConsole(logger) {
}; };
} }
function createRestrictedRequire() { function isBlockedHostname(hostname) {
const host = hostname.toLowerCase().replace(/\.$/, "");
if (
host === "localhost" ||
host === "0.0.0.0" ||
host === "[::]" ||
host === "[::1]" ||
host.endsWith(".localhost")
) {
return true;
}
if (host === "metadata.google.internal" || host === "metadata.goog") {
return true;
}
const ipv4Match = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (!ipv4Match) {
return false;
}
const octets = ipv4Match.slice(1).map(Number);
if (octets.some((octet) => octet > 255)) {
return true;
}
const [a, b] = octets;
if (a === 10) return true;
if (a === 127) return true;
if (a === 0) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 100 && b >= 64 && b <= 127) return true;
return false;
}
function resolveRequestUrl(config) {
const target = config.url;
if (typeof target !== "string" || target.length === 0) {
throw new Error("axios request URL is required");
}
if (/^https?:\/\//i.test(target)) {
return new URL(target);
}
const base = config.baseURL;
if (typeof base !== "string" || base.length === 0) {
throw new Error(`axios request URL must be absolute: ${target}`);
}
return new URL(target, base);
}
function screenRequestUrl(url, log) {
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`Request blocked: unsupported protocol ${url.protocol}`);
}
if (isBlockedHostname(url.hostname)) {
const message = `Request blocked: ${url.href}`;
log.warn({ url: url.href, hostname: url.hostname }, message);
throw new Error(message);
}
}
/**
* @param {import("pino").Logger} log
*/
function createScreenedAxios(log) {
const instance = axios.create();
instance.interceptors.request.use((config) => {
const url = resolveRequestUrl(config);
screenRequestUrl(url, log);
return config;
});
return instance;
}
function createRestrictedRequire(screenedAxios) {
return function restrictedRequire(id) { return function restrictedRequire(id) {
if (typeof id !== "string" || !ALLOWED_MODULES.has(id)) { if (typeof id !== "string" || !ALLOWED_MODULES.has(id)) {
throw new Error(`require(${JSON.stringify(id)}) is not allowed`); throw new Error(`require(${JSON.stringify(id)}) is not allowed`);
} }
if (id === "axios") {
return screenedAxios;
}
return hostRequire(id); return hostRequire(id);
}; };
} }
@@ -199,11 +283,13 @@ function createRestrictedRequire() {
*/ */
function createScriptSandbox({ log, script, workflowName }) { function createScriptSandbox({ log, script, workflowName }) {
const scriptLog = log.child({ workflow: workflowName, script }); const scriptLog = log.child({ workflow: workflowName, script });
const $axios = createScreenedAxios(scriptLog);
const sandbox = { const sandbox = {
...pickBuiltins(), ...pickBuiltins(),
log: scriptLog, log: scriptLog,
console: createConsole(scriptLog), console: createConsole(scriptLog),
require: createRestrictedRequire(), $axios,
require: createRestrictedRequire($axios),
}; };
vm.createContext(sandbox, { vm.createContext(sandbox, {
+2 -3
View File
@@ -1,5 +1,4 @@
import axios from "axios"; export default async function ntfy(ctx) {
export default function ntfy(ctx) {
log.info({ ctx }, "ntfy"); log.info({ ctx }, "ntfy");
const headers = {} const headers = {}
@@ -7,7 +6,7 @@ export default function ntfy(ctx) {
headers.Title = ctx.data.title headers.Title = ctx.data.title
} }
axios.post(ctx.config?.url || "https://ntfy.sh/scrunner", ctx.data?.message || "Hello from scrunner", { await $axios.post(ctx.config?.url || "https://ntfy.sh/scrunner", ctx.data?.message || "Hello from scrunner", {
headers: headers headers: headers
}) })
return {sent: "true"} return {sent: "true"}