在前面完成 API Key 與輸入驗證、以及最小權限後。
第四道防線是白名單目標:嚴格限制 n8n 觸發的掃描僅能作用於內網。做法是把「可被掃描的目的地」從黑名單思維改成白名單思維,只接受 RFC1918/4193 網段(10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、fc00::/7,必要時加上 127.0.0.0/8 與 ::1),其餘一律拒絕。
curl -X POST "http://localhost:5679/webhook/hello" \
-H "x-api-key: webhook-text-123123" \
-H "Content-Type: application/json" \
-d '{"userRole": "admin", "prompt":"用 port_scan 幫我查 port 5000 狀態和他的URL"}'
{"output":{"port_status":{"5000":"OPEN (status 200)"},"url":"http://127.0.0.1:5000"},"myNewField":1,"isError":false}%
如果沒有做相關防禦,其餘防禦也被突破時,同時你的 MCP 也有惡意工具 n8n 就有可能被當成掃描工具,可以輕易的被掃描。
當前節點,Agent 後的 Code 只用來整理 Agent 的輸出格式讓使用者可以更好閱讀輸出
在整理輸出的 Code 後新增一個 Code 來做網域白名單,以此來避免敏感 port, url 被惡意掃描
const ALLOW_PORTS = [80, 443, 8080, 8443, 8000]; // port 8000 示範用
const IPV4_ALLOW = [
/^10\.(\d{1,3}\.){2}\d{1,3}$/,
/^172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}$/,
/^192\.168\.\d{1,3}\.\d{1,3}$/,
/^127\.(\d{1,3}\.){2}\d{1,3}$/
];
const DEFAULT_HOST = (process.env.DEFAULT_HOST || "127.0.0.1").trim();
// ===== 工具 =====
const clean = s => String(s ?? "").replace(/[\u200B-\u200D\uFEFF]/g, "").trim();
const normalizeHost = h => (clean(h).toLowerCase() === "localhost" ? "127.0.0.1" : clean(h));
const ipRx = /\b(\d{1,3}(?:\.\d{1,3}){3})\b/g;
const urlRx = /https?:\/\/[^\s"'<>]+/gi;
function redact(s) {
if (!s) return s;
return String(s).replace(urlRx, "[redacted]").replace(ipRx, "[redacted]");
}
function safeOriginal(item, sourceHint) {
if (item.json?.target) return "[target supplied]";
if (item.json?.prompt) return redact(item.json.prompt);
if (item.json?.output) return "[agent output]";
return sourceHint || null;
}
function parseLoose(raw) {
let t = clean(raw);
if (!t) return { error: "target is required" };
// http(s)://host[:port] or host:port
let m = t.match(/^https?:\/\/([^\/\s:]+)(?::(\d{1,5}))?(?:\/.*)?$/i);
let host, port, scheme;
if (m) {
scheme = t.toLowerCase().startsWith("https") ? "https" : "http";
host = normalizeHost(m[1]);
port = m[2] ? Number(m[2]) : (scheme === "https" ? 443 : 80);
} else {
m = t.match(/^([^\/\s:]+):(\d{1,5})$/);
if (!m) return { error: "invalid target format" };
scheme = "http";
host = normalizeHost(m[1]);
port = Number(m[2]);
}
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return { error: "Only IPv4 literal allowed" };
const octetsOk = host.split(".").every(x => (Number(x) >= 0 && Number(x) <= 255));
if (!octetsOk) return { error: "Invalid IPv4 octet" };
return { host, port, scheme, normalized: `${scheme}://${host}:${port}` };
}
function isInIntranet(ip) { return IPV4_ALLOW.some(rx => rx.test(ip)); }
function extractFromPrompt(txt) {
const s = clean(txt);
if (!s) return null;
const url = s.match(/https?:\/\/[^\s"'<>]+/i)?.[0];
if (url) return url;
const ip = s.match(/\b(\d{1,3}(?:\.\d{1,3}){3}|localhost)\b/i)?.[1];
if (!ip) return null;
const p = Number(s.match(/(?:port|埠|:|:)\s*(\d{2,5})\b/i)?.[1]) || 80;
return `${ip}:${p}`;
}
function extractFromAgent(out) {
if (!out || typeof out !== "object") return null;
const u = out.url || out.URL || out.link;
if (typeof u === "string" && u) return u;
const hostRaw = out.host || out.ip || out.address || null;
const portRaw = out.port ?? out.Port ?? null;
if (hostRaw && portRaw) return `${hostRaw}:${portRaw}`;
const key = Object.keys(out).find(k => /^port_(\d{2,5})_status$/i.test(k));
if (key) {
const port = Number(key.match(/^port_(\d{2,5})_status$/i)[1]);
return `${DEFAULT_HOST}:${port}`;
}
if (typeof out.port === "number") return `${DEFAULT_HOST}:${out.port}`;
return null;
}
const results = [];
for (const item of $input.all()) {
const originalEcho = safeOriginal(item); URL/IP
let target = item.json?.target;
if (!target && item.json?.prompt) target = extractFromPrompt(item.json.prompt);
if (!target && item.json?.output) target = extractFromAgent(item.json.output);
const parsed = parseLoose(target);
if (parsed.error) {
results.push({ json: {
ok: false,
message: "工具無法使用,請洽管理員",
reason: parsed.error,
original: originalEcho
}});
continue;
}
const { host, port, scheme, normalized } = parsed;
if (!isInIntranet(host)) {
results.push({ json: {
ok: false,
message: "工具無法使用,請洽管理員",
reason: "IP not in intranet allowlist (10/172.16-31/192.168/127).",
original: originalEcho
}});
continue;
}
if (!ALLOW_PORTS.includes(port)) {
results.push({ json: {
ok: false,
message: "工具無法使用,請洽管理員",
reason: `Port ${port} not allowed. Allowed: ${ALLOW_PORTS.join(", ")}`,
original: originalEcho
}});
continue;
}
results.push({ json: {
ok: true,
safeTarget: normalized,
host, port, scheme,
original: originalEcho
}});
}
return results;
➜ ~ curl -X POST "http://localhost:5679/webhook/hello" \
-H "x-api-key: webhook-text-123123" \
-H "Content-Type: application/json" \
-d '{"userRole": "admin", "prompt":"用 port_scan 幫我查 port 5000 狀態和他的URL"}'
{"ok":false,"message":"工具無法使用,請洽管理員","reason":"Port 5000 not allowed. Allowed: 80, 443, 8080, 8443, 8000","original":"[agent output]"}%
以上輸出因為 port 不屬於白名單,所以輸出 "工具無法使用,請洽管理員"
~ curl -X POST "http://localhost:5679/webhook/hello" \
-H "x-api-key: webhook-text-123123" \
-H "Content-Type: application/json" \
-d '{"userRole": "admin", "prompt":"用 port_scan 幫我查 port 8000 狀態和他的URL"}'
{"ok":true,"safeTarget":"http://127.0.0.1:8000","host":"127.0.0.1","port":8000,"scheme":"http","original":"[agent output]"}%
今天這樣把 n8n 可能被濫用成掃描器的風險降到最低:只允許 RFC1918/loopback 內網 IP,且只允許指定埠;任何外網或未授權埠一律拒絕,且錯誤回應不洩漏 URL/IP。