From 4f65482d3dd4b1c8bcfa44ec211e25d363e20dae Mon Sep 17 00:00:00 2001 From: Jason Wright Date: Tue, 14 Jul 2026 13:37:52 -0600 Subject: [PATCH] fix: harden /api/redux proxy against SSRF (code-scanning #40) Resolve the proxy target with the WHATWG URL parser and require it to stay on the configured backend's origin (and an http/https scheme) before calling fetch. Also switch to `redirect: 'manual'` so a misbehaving backend can't 3xx the server toward an internal address; the redirect is forwarded to the client instead. This addresses the js/request-forgery alert at pages/api/redux/[...path].js:34. In practice the previous code was not exploitable for host injection: the user-controlled suffix is appended after "/", so the WHATWG parser keeps the authority pinned to the backend host. CodeQL flags it anyway because it can't prove that invariant via taint tracking. The explicit origin check makes the guarantee machine-verifiable and closes the additional redirect vector. Co-Authored-By: Claude Opus 4.8 --- pages/api/redux/[...path].js | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/pages/api/redux/[...path].js b/pages/api/redux/[...path].js index 2f612ad..1c7c40e 100644 --- a/pages/api/redux/[...path].js +++ b/pages/api/redux/[...path].js @@ -12,6 +12,26 @@ export default async function handler(req, res) { const suffix = req.url.replace(/^\/api\/redux\/?/, ''); const targetUrl = `${baseUrl.replace(/\/$/, '')}/${suffix}`; + // Guard against SSRF: the user-controlled suffix must not be able to steer the + // request to a host/scheme other than the configured backend. Resolve the URL + // with the WHATWG parser and require it to stay on the backend's origin. + let target; + let base; + try { + target = new URL(targetUrl); + base = new URL(baseUrl); + } catch { + res.status(400).json({ error: 'Invalid request path' }); + return; + } + + const isSameOrigin = target.origin === base.origin; + const isAllowedScheme = target.protocol === 'http:' || target.protocol === 'https:'; + if (!isSameOrigin || !isAllowedScheme) { + res.status(400).json({ error: 'Refusing to proxy request outside the configured backend' }); + return; + } + const headers = {}; for (const [key, value] of Object.entries(req.headers)) { if (!['host', 'connection', 'transfer-encoding'].includes(key.toLowerCase())) { @@ -19,7 +39,10 @@ export default async function handler(req, res) { } } - const fetchOptions = { method: req.method, headers }; + // Do not follow redirects server-side: a compromised or misbehaving backend + // could 3xx us toward an internal address (another SSRF path). Forward the + // redirect response to the client and let the browser decide what to do. + const fetchOptions = { method: req.method, headers, redirect: 'manual' }; if (!['GET', 'HEAD'].includes(req.method)) { const chunks = []; @@ -31,7 +54,7 @@ export default async function handler(req, res) { let upstream; try { - upstream = await fetch(targetUrl, fetchOptions); + upstream = await fetch(target, fetchOptions); } catch (err) { res.status(502).json({ error: `Upstream unreachable: ${err.message}` }); return;