iT邦幫忙

2025 iThome 鐵人賽

DAY 12
0
生成式 AI

別讓駭客拿走你的AI控制權:MCP x n8n 防禦實戰全攻略系列 第 12

# 防禦策略 #4:白名單目標 — 只允許內網掃描

  • 分享至 

  • xImage
  •  

在前面完成 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 的輸出格式讓使用者可以更好閱讀輸出

https://ithelp.ithome.com.tw/upload/images/20250926/20168687NJCRUvWGF9.png

在整理輸出的 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;

https://ithelp.ithome.com.tw/upload/images/20250926/20168687nW7sKgNRrT.png

再次呼叫

➜  ~ 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 不屬於白名單,所以輸出 "工具無法使用,請洽管理員"

白名單內 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。


上一篇
# 模擬攻擊 — MCP 惡意工具讓 n8n 幫忙掃 Port
系列文
別讓駭客拿走你的AI控制權:MCP x n8n 防禦實戰全攻略12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言