From 8c9673444f94ff884520ddebe0d733bfa2aba0f5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 04:56:25 +0000 Subject: [PATCH 1/2] 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 --- script-sandbox.js | 90 +++++++++++++++++++++++++++++++++++++++++++++-- scripts/ntfy.js | 5 ++- 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/script-sandbox.js b/script-sandbox.js index ddb2d21..06b9646 100644 --- a/script-sandbox.js +++ b/script-sandbox.js @@ -2,6 +2,7 @@ import fs from "fs"; import vm from "node:vm"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; +import axios from "axios"; 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) { if (typeof id !== "string" || !ALLOWED_MODULES.has(id)) { throw new Error(`require(${JSON.stringify(id)}) is not allowed`); } + if (id === "axios") { + return screenedAxios; + } return hostRequire(id); }; } @@ -199,11 +283,13 @@ function createRestrictedRequire() { */ function createScriptSandbox({ log, script, workflowName }) { const scriptLog = log.child({ workflow: workflowName, script }); + const $axios = createScreenedAxios(scriptLog); const sandbox = { ...pickBuiltins(), log: scriptLog, console: createConsole(scriptLog), - require: createRestrictedRequire(), + $axios, + require: createRestrictedRequire($axios), }; vm.createContext(sandbox, { diff --git a/scripts/ntfy.js b/scripts/ntfy.js index 9f581ec..5e50d57 100644 --- a/scripts/ntfy.js +++ b/scripts/ntfy.js @@ -1,5 +1,4 @@ -import axios from "axios"; -export default function ntfy(ctx) { +export default async function ntfy(ctx) { log.info({ ctx }, "ntfy"); const headers = {} @@ -7,7 +6,7 @@ export default function ntfy(ctx) { 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 }) return {sent: "true"} From b181bd3414df7bc6a316ec29da160e8dfd2bcd1f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 14 Aug 2026 05:01:45 +0000 Subject: [PATCH 2/2] fix(sandbox): allow localhost in axios URL screening Self-hosted automation targets often run on localhost or loopback addresses, so keep blocking private/metadata hosts but stop blocking localhost, 127.x.x.x, and .localhost names. Co-authored-by: Nasyarobby Putra --- script-sandbox.js | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/script-sandbox.js b/script-sandbox.js index 06b9646..e565967 100644 --- a/script-sandbox.js +++ b/script-sandbox.js @@ -188,15 +188,6 @@ function createConsole(logger) { 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; @@ -214,7 +205,6 @@ function isBlockedHostname(hostname) { 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;