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:
+88
-2
@@ -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
@@ -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"}
|
||||||
|
|||||||
Reference in New Issue
Block a user