flawopen.com/ssrf/Javascript

● CWE-918 · 高危
安全研究 · FlawOpen

漏洞深度剖析:Server-Side Request Forgery in JavaScript

How axios and fetch allow internal network scanning and cloud metadata theft, and how to implement safe lookup agents in Node.js.

💡 通俗易懂的原理解析 (ELI5)

想象一下,你派办公室助理去公共快递站取包裹,却故意给他留了总经理办公室带锁保险箱的内部地址。因为助理佩戴着公司内部通行工牌,保安直接放行,他便打开保险箱将公司核心机密交给了你。

核心概念与专有名词

Web Application Security
技术概念 (Web Application Security):Core architecture component affected by CWE-918.
CWE-918
技术概念 (CWE-918):Standard Common Weakness Enumeration classification for ssrf-javascript.
Defense-in-Depth
技术概念 (Defense-in-Depth):Multi-layered engineering verification and runtime boundary isolation.

攻击执行流程分解

Step 1

接收不可信远程 URL

应用程序接受用户提交的外部 URL 用于拉取头像、Webhook 推送或生成网页预览。

Step 2

指向内部专用网络与元数据服务

攻击者输入指向内网回环地址或云厂商元数据接口(如 http://169.254.169.254/latest/meta-data/)的地址。

Step 3

未受限制的内部网络套接字请求

服务器内部 HTTP 客户端直接从受信任的私有 VPC 发起网络请求,未做 IP 白名单与私有地址段校验。

Step 4

云身份凭证与敏感数据外泄

内部元数据服务信任来自同主机的请求,回传临时 IAM 访问凭据、Kubernetes Token 或管理后台内容。

源代码对比:漏洞与安全实现

✕ 存在漏洞的实现
// VULNERABLE: Naively fetching user URL with axios
const express = require('express');
const axios = require('axios');

const app = express();

app.get('/proxy-image', async (req, res) => {
  const imageUrl = req.query.url;
  // Attacker input: http://169.254.169.254/latest/meta-data/
  try {
    const response = await axios.get(imageUrl);
    res.send(response.data);
  } catch (err) {
    res.status(500).send('Fetch error');
  }
});
✓ 加固后的安全修复
// HARDENED: Custom DNS lookup agent blocking private & metadata IP ranges
const express = require('express');
const http = require('http');
const https = require('https');
const dns = require('dns');
const ipaddr = require('ipaddr.js'); // npm i ipaddr.js
const axios = require('axios');

const app = express();

function safeLookup(hostname, options, callback) {
  dns.lookup(hostname, options, (err, address, family) => {
    if (err) return callback(err);

    try {
      const addr = ipaddr.parse(address);
      const range = addr.range();

      // Deny private, loopback, link-local, and reserved ranges
      const blockedRanges = ['loopback', 'private', 'linkLocal', 'carrierGradeNat', 'reserved'];
      if (blockedRanges.includes(range)) {
        return callback(new Error(`SSRF Blocked: Destination IP ${address} is in blocked range (${range})`));
      }

      callback(null, address, family);
    } catch (parseErr) {
      callback(new Error('Invalid IP address resolved'));
    }
  });
}

const safeHttpAgent = new http.Agent({ lookup: safeLookup });
const safeHttpsAgent = new https.Agent({ lookup: safeLookup });

app.get('/proxy-image', async (req, res) => {
  const imageUrl = req.query.url;
  try {
    const parsed = new URL(imageUrl);
    if (!['http:', 'https:'].includes(parsed.protocol)) {
      return res.status(400).send('Invalid protocol');
    }

    const response = await axios.get(imageUrl, {
      httpAgent: safeHttpAgent,
      httpsAgent: safeHttpsAgent,
      maxRedirects: 0, // Prevent redirect bypasses
      timeout: 3000
    });

    res.send(response.data);
  } catch (err) {
    res.status(403).send('Request blocked: ' + err.message);
  }
});

工程与系统安全加固清单

References