How Server-Side Request Forgery happens in Node.js httpProxy.js and how to fix it
The Vulnerability at a Glance
| Field | Detail |
|---|---|
| Vulnerability | Server-Side Request Forgery (SSRF) |
| CWE | CWE-918 |
| Language | JavaScript (Node.js) |
| Risk | Attacker can probe internal infrastructure, steal cloud credentials, or pivot to internal services |
| Root Cause | User-controlled testUrl passed directly to axios.get() without validation |
| Fix | isAllowedUrl() function blocking private IPs and non-HTTP(S) protocols |
Introduction
The hubcmdui/routes/httpProxy.js file handles proxy configuration and connection testing for the application. But a flaw in its /proxy/test route handler — specifically, the way it consumed the testUrl parameter from the request body — created a textbook Server-Side Request Forgery (SSRF) vulnerability. An authenticated attacker could supply any URL they wanted, and the server would dutifully fetch it, potentially exposing your entire internal network to enumeration and attack.
This kind of vulnerability is deceptively simple: one missing validation check on a single variable turns a helpful "test my proxy connection" feature into a weapon for internal network reconnaissance.
The Vulnerability Explained
What Was Happening
At line 130 of hubcmdui/routes/httpProxy.js, the /proxy/test POST endpoint extracted a testUrl parameter directly from the request body and passed it to axios.get() with zero validation:
// VULNERABLE CODE (before fix)
router.post('/proxy/test', requireLogin, async (req, res) => {
try {
const { testUrl = 'http://httpbin.org/ip' } = req.body;
const axios = require('axios');
const status = httpProxyService.getStatus();
// axios.get(testUrl) called with no URL validation
The default value of 'http://httpbin.org/ip' gives a false sense of safety — it implies a known-good URL will be used. But because testUrl comes from req.body, any authenticated user can override it with any URL they choose.
How an Attacker Would Exploit This
The attack is straightforward. An authenticated user sends a POST request to /api/proxy/test with a malicious testUrl:
Scenario 1 — AWS Cloud Metadata Theft:
POST /api/proxy/test HTTP/1.1
Content-Type: application/json
{
"testUrl": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
}
The server responds with the instance's IAM role name. A follow-up request to http://169.254.169.254/latest/meta-data/iam/security-credentials/my-role returns temporary AWS access keys — full credential compromise from a single HTTP request.
Scenario 2 — Internal Service Enumeration:
POST /api/proxy/test HTTP/1.1
Content-Type: application/json
{
"testUrl": "http://192.168.1.1/"
}
The server probes the internal router admin panel. The attacker can iterate through 192.168.1.x addresses to map the internal network, or target http://localhost:6379/ to interact with an unauthenticated Redis instance.
Scenario 3 — Non-HTTP Protocol Abuse:
Without protocol validation, an attacker might attempt file:///etc/passwd or ftp://internal-server/ depending on how axios handles non-HTTP schemes.
Why requireLogin Isn't Enough
Notice that the route already uses requireLogin middleware. This is a common misconception: authentication does not prevent SSRF. Any legitimate authenticated user — or an attacker who has compromised any account — can trigger this. In multi-tenant or SaaS environments, this is especially dangerous.
Real-World Impact for This Application
This proxy testing feature exists to let users verify their proxy configuration. But the server making that request sits inside the network perimeter. It can reach services that are firewalled from the outside world. The attacker doesn't need to bypass the firewall — they use the application server itself as a proxy.
The Fix
What Changed
The fix introduces a dedicated isAllowedUrl() validation function and calls it before axios.get() is ever invoked. Here's the complete before/after comparison:
Before (vulnerable):
router.post('/proxy/test', requireLogin, async (req, res) => {
try {
const { testUrl = 'http://httpbin.org/ip' } = req.body;
const axios = require('axios');
const status = httpProxyService.getStatus();
// No validation — axios.get(testUrl) proceeds with any URL
After (fixed):
// 验证URL是否为允许的外部地址(防止SSRF)
function isAllowedUrl(urlString) {
try {
const parsed = new URL(urlString);
// Only allow HTTP and HTTPS
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
const hostname = parsed.hostname.toLowerCase();
// Block loopback addresses
if (/^(localhost|127\.|0\.0\.0\.0|::1$)/.test(hostname)) return false;
// Block RFC-1918 Class A private range
if (/^10\./.test(hostname)) return false;
// Block RFC-1918 Class B private range (172.16.0.0 – 172.31.255.255)
if (/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)) return false;
// Block RFC-1918 Class C private range
if (/^192\.168\./.test(hostname)) return false;
return true;
} catch (e) {
return false;
}
}
router.post('/proxy/test', requireLogin, async (req, res) => {
try {
const { testUrl = 'http://httpbin.org/ip' } = req.body;
if (!isAllowedUrl(testUrl)) {
return res.status(400).json({ error: '无效或不允许的测试URL' });
}
const axios = require('axios');
const status = httpProxyService.getStatus();
Breaking Down the Fix
1. Protocol enforcement — !['http:', 'https:'].includes(parsed.protocol) ensures only legitimate web protocols are accepted. This blocks file://, ftp://, gopher://, and other protocol schemes that could be abused.
2. Loopback blocking — The regex /^(localhost|127\.|0\.0\.0\.0|::1$)/ catches all common loopback representations: the hostname localhost, any 127.x.x.x address, the unspecified address 0.0.0.0, and the IPv6 loopback ::1.
3. RFC-1918 private range blocking — Three separate regex patterns cover all three private IP ranges defined in RFC 1918:
- 10.0.0.0/8 → blocked by /^10\./
- 172.16.0.0/12 → blocked by /^172\.(1[6-9]|2\d|3[01])\./
- 192.168.0.0/16 → blocked by /^192\.168\./
4. Fail-safe error handling — The try/catch block means a malformed URL that throws in new URL() returns false, causing the request to be rejected rather than crashing or proceeding.
5. Early return with clear error — return res.status(400).json({ error: '无效或不允许的测试URL' }) stops execution immediately and gives the client a meaningful error without leaking internal details.
Prevention & Best Practices
Defense in Depth for SSRF
Validate at the earliest possible point. The fix correctly places isAllowedUrl() check before require('axios') is even called. Don't let tainted data travel further than necessary.
Consider an allowlist over a blocklist. The current fix uses a blocklist of private ranges, which is good. An even stronger approach is an allowlist: if you only need to test connectivity to a known set of proxy servers, enumerate those explicitly. Blocklists can be bypassed by creative encoding (e.g., decimal IP notation 2130706433 for 127.0.0.1) — though the new URL() parser mitigates many of these.
Beware DNS rebinding. A sophisticated attacker could register a domain that resolves to a public IP at validation time but a private IP when the actual request is made. For high-security environments, consider resolving the hostname to an IP address at validation time and checking that IP.
Use a dedicated HTTP client with SSRF protection. Libraries like ssrf-req-filter for Node.js wrap axios/node-fetch with built-in SSRF protection.
Network-level controls. Complement application-level validation with egress firewall rules that prevent the application server from reaching internal metadata endpoints and private ranges.
Detecting SSRF in Your Codebase
Look for these patterns in Node.js code:
// Dangerous patterns — user input flowing into HTTP client calls
axios.get(req.body.url)
axios.get(req.query.target)
fetch(req.params.endpoint)
http.get(userInput)
Use Semgrep to find these automatically:
rules:
- id: ssrf-axios-user-input
patterns:
- pattern: axios.get($URL, ...)
- pattern-not: axios.get("...", ...)
message: Potential SSRF — validate $URL before use
languages: [javascript]
severity: ERROR
Relevant Standards
- OWASP Top 10 2021: A10 — Server-Side Request Forgery — SSRF earned its own category in the 2021 update, reflecting its growing prevalence and severity.
- CWE-918: Server-Side Request Forgery — The canonical weakness classification for this vulnerability type.
- OWASP SSRF Prevention Cheat Sheet — Comprehensive guidance on SSRF mitigations.
Key Takeaways
- The
/proxy/testendpoint'stestUrlparameter was a direct SSRF entry point — any field that accepts a URL and triggers a server-side fetch must be validated, regardless of authentication guards. requireLogindoes not protect against SSRF — authentication controls who can reach an endpoint, not what they can do with it once authenticated.- All three RFC-1918 private IP ranges must be blocked explicitly — blocking only
192.168.x.xwhile forgetting10.x.x.xor172.16-31.x.xleaves significant attack surface open. - The
new URL()parser is your friend for SSRF validation — it normalizes URLs and handles encoding edge cases better than manual string parsing or regex alone. - Default parameter values don't prevent injection —
testUrl = 'http://httpbin.org/ip'only applies when the field is absent; a supplied value always wins.
How Orbis AppSec Detected This
- Source: The
testUrlparameter extracted fromreq.bodyin thePOST /proxy/testroute handler inhubcmdui/routes/httpProxy.js:130 - Sink:
axios.get(testUrl)— the HTTP client call that executes the server-side request with the unvalidated user-supplied URL - Missing control: No URL validation, protocol restriction, or IP range filtering was present between the source and sink; the
requireLoginmiddleware was the only gate - CWE: CWE-918 — Server-Side Request Forgery (SSRF)
- Fix: Added
isAllowedUrl()function that enforces HTTP/HTTPS-only protocols and rejects all RFC-1918 private IP ranges and loopback addresses before any HTTP request is made
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
SSRF vulnerabilities are particularly dangerous because they weaponize the server's privileged network position. The /proxy/test endpoint in httpProxy.js is a perfect example: a feature designed to help users test connectivity became a mechanism for internal network reconnaissance and potential credential theft from cloud metadata services.
The fix is elegant in its simplicity — a single isAllowedUrl() function with clear, maintainable regex patterns covering every private IP range. But the broader lesson is architectural: any endpoint that accepts a URL and makes a server-side request is a potential SSRF vector. Treat URL parameters with the same skepticism you'd give to SQL query parameters or shell command arguments.
When building similar proxy-testing or URL-fetching features, make URL validation the first line of code in your handler — not an afterthought.