consolidate cross-origin proxy and add ssrf guard

This commit is contained in:
Nystik
2026-06-05 23:56:59 +02:00
parent b88f9fdc0e
commit 44bb01f162
5 changed files with 199 additions and 94 deletions

View File

@@ -1,9 +1,97 @@
const express = require("express");
const dns = require("dns").promises;
const net = require("net");
const router = express.Router();
// POST /api/proxy - forward a request to an external URL to bypass CORS
// Used by the requestUrl shim for plugin installation, etc.
const MAX_RESPONSE_BYTES = 50 * 1024 * 1024;
function isPrivateIp(ip) {
const type = net.isIP(ip);
if (type === 4) {
const o = ip.split(".").map(Number);
return (
o[0] === 0 ||
o[0] === 10 ||
o[0] === 127 ||
(o[0] === 169 && o[1] === 254) ||
(o[0] === 172 && o[1] >= 16 && o[1] <= 31) ||
(o[0] === 192 && o[1] === 168) ||
(o[0] === 100 && o[1] >= 64 && o[1] <= 127)
);
}
if (type === 6) {
const a = ip.toLowerCase();
if (a === "::1" || a === "::") {
return true;
}
if (/^fe[89ab]/.test(a) || a.startsWith("fc") || a.startsWith("fd")) {
return true;
}
const mapped = a.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
if (mapped) {
return isPrivateIp(mapped[1]);
}
return false;
}
return false;
}
function httpError(status, message) {
const e = new Error(message);
e.statusCode = status;
return e;
}
// Reject non-http(s) schemes and hosts that resolve to a private or link-local address.
async function assertPublicUrl(urlStr) {
let parsed;
try {
parsed = new URL(urlStr);
} catch {
throw httpError(400, "Invalid URL");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw httpError(400, "Only http and https URLs are allowed");
}
const host = parsed.hostname;
if (net.isIP(host)) {
if (isPrivateIp(host)) {
throw httpError(403, "Host not allowed");
}
return;
}
let addrs;
try {
addrs = await dns.lookup(host, { all: true });
} catch {
throw httpError(502, "DNS resolution failed");
}
for (const a of addrs) {
if (isPrivateIp(a.address)) {
throw httpError(403, "Host resolves to a private address");
}
}
}
// POST /api/proxy - forward a request to an external URL to bypass CORS.
router.post("/", async (req, res) => {
const { url, method, headers, body, binary } = req.body;
@@ -12,6 +100,13 @@ router.post("/", async (req, res) => {
}
try {
await assertPublicUrl(url);
} catch (e) {
return res.status(e.statusCode || 400).json({ error: e.message });
}
try {
// Forward the caller's headers as-is.
const fetchOpts = {
method: method || "GET",
headers: headers || {},
@@ -26,10 +121,25 @@ router.post("/", async (req, res) => {
}
const upstream = await fetch(url, fetchOpts);
const respBody = Buffer.from(await upstream.arrayBuffer());
// Forward response headers, stripping hop-by-hop / encoding headers
// since the body is already decompressed by Node's fetch
const declaredLength = Number(upstream.headers.get("content-length"));
if (
Number.isFinite(declaredLength) &&
declaredLength > MAX_RESPONSE_BYTES
) {
return res.status(413).json({ error: "Upstream response too large" });
}
const respArrayBuf = await upstream.arrayBuffer();
if (respArrayBuf.byteLength > MAX_RESPONSE_BYTES) {
return res.status(413).json({ error: "Upstream response too large" });
}
const respBody = Buffer.from(respArrayBuf);
// Strip hop-by-hop / encoding headers since the body is already decompressed.
const skipHeaders = new Set([
"content-encoding",
"transfer-encoding",
@@ -37,6 +147,7 @@ router.post("/", async (req, res) => {
"connection",
]);
const respHeaders = {};
upstream.headers.forEach((val, key) => {
if (!skipHeaders.has(key)) {
respHeaders[key] = val;