diff --git a/backend/src/api/proxy/controllers/proxy.js b/backend/src/api/proxy/controllers/proxy.js index cf7da45..2edd3f8 100644 --- a/backend/src/api/proxy/controllers/proxy.js +++ b/backend/src/api/proxy/controllers/proxy.js @@ -1,45 +1,56 @@ "use strict"; const axios = require("axios"); +// Allowed base URL for proxy requests — only the GovTool API +const GOVTOOL_BASE_URL = process.env.GOVTOOL_API_BASE_URL; + +/** + * Validate that the resolved URL targets the configured GovTool API host. + * Rejects any URL that does not match the expected host to prevent SSRF. + */ +function validateGovtoolUrl(fullUrl) { + if (!GOVTOOL_BASE_URL) { + throw new Error("GOVTOOL_API_BASE_URL is not configured"); + } + const allowedOrigin = new URL(GOVTOOL_BASE_URL).origin; + const targetOrigin = new URL(fullUrl).origin; + if (targetOrigin !== allowedOrigin) { + throw new Error("Request URL does not match the allowed GovTool API host"); + } +} + module.exports = { - // POST /proxy - async forward(ctx) { - try { - const { - url, - method = "GET", - data = {}, - params = {}, - headers = {}, - } = ctx.request.body; - const response = await axios({ url, method, data, params, headers }); - ctx.send({ status: response.status, data: response.data }); - } catch (error) { - strapi.log.error("Proxy error:", error); - ctx.status = error.response?.status || 500; - ctx.body = { - error: error.message, - details: error.response?.data || null, - }; - } - }, + // NOTE: The previous unrestricted POST /proxy endpoint has been removed. + // It allowed unauthenticated SSRF to any URL and was a dev convenience + // that should never have shipped to production. See issue #4168. // GET /proxy/govtool/:endpoint* async getGovtoolData(ctx) { try { const endpoint = ctx.params.endpoint; if (!endpoint) return ctx.badRequest("Endpoint is required"); - const baseUrl = process.env.GOVTOOL_API_BASE_URL; - const fullUrl = `${baseUrl.replace(/\/$/, "")}/${endpoint}`; + + if (!GOVTOOL_BASE_URL) { + return ctx.internalServerError("GOVTOOL_API_BASE_URL is not configured"); + } + + const fullUrl = `${GOVTOOL_BASE_URL.replace(/\/$/, "")}/${endpoint}`; + + // SSRF guard: only allow requests to the configured GovTool API + validateGovtoolUrl(fullUrl); + const response = await axios.get(fullUrl, { params: ctx.query, - headers: { - // Authorization: `Bearer ${process.env.GOVTOOL_API_TOKEN}`, - }, + headers: {}, }); ctx.send({ status: response.status, data: response.data }); } catch (error) { + if (error.message?.includes("does not match") || error.code === "ERR_INVALID_URL") { + ctx.status = 400; + ctx.body = { error: "Invalid request URL" }; + return; + } strapi.log.error("GovTool GET error:", error); ctx.status = error.response?.status || 500; ctx.body = { @@ -54,18 +65,28 @@ module.exports = { const endpoint = ctx.params.endpoint; if (!endpoint) return ctx.badRequest("Endpoint is required"); - const baseUrl = process.env.GOVTOOL_API_BASE_URL; - const fullUrl = `${baseUrl.replace(/\/$/, "")}/${endpoint}`; + if (!GOVTOOL_BASE_URL) { + return ctx.internalServerError("GOVTOOL_API_BASE_URL is not configured"); + } + + const fullUrl = `${GOVTOOL_BASE_URL.replace(/\/$/, "")}/${endpoint}`; + + // SSRF guard: only allow requests to the configured GovTool API + validateGovtoolUrl(fullUrl); const response = await axios.post(fullUrl, ctx.request.body, { headers: { "Content-Type": "application/json", - // Authorization: `Bearer ${process.env.GOVTOOL_API_TOKEN}`, }, }); ctx.send({ status: response.status, data: response.data }); } catch (error) { + if (error.message?.includes("does not match") || error.code === "ERR_INVALID_URL") { + ctx.status = 400; + ctx.body = { error: "Invalid request URL" }; + return; + } strapi.log.error("GovTool POST error:", error); ctx.status = error.response?.status || 500; ctx.body = { diff --git a/backend/src/api/proxy/routes/proxy.js b/backend/src/api/proxy/routes/proxy.js index adc9570..7687895 100644 --- a/backend/src/api/proxy/routes/proxy.js +++ b/backend/src/api/proxy/routes/proxy.js @@ -2,32 +2,28 @@ module.exports = { routes: [ - { - method: 'POST', - path: '/proxy', - handler: 'proxy.forward', - config: { - roles: ['authenticated', 'public'], - auth: false - }, - }, + // NOTE: The unrestricted POST /proxy route has been removed. + // It allowed unauthenticated SSRF to any arbitrary URL (issue #4168). + // Only the GovTool-specific proxy routes below are retained, and they + // now validate that the target URL matches GOVTOOL_API_BASE_URL. + { method: 'GET', path: '/proxy/govtool/:endpoint*', handler: 'proxy.getGovtoolData', config: { - roles: ['authenticated', 'public'], - auth: false - }, + roles: ['authenticated', 'public'], + auth: false, + }, }, { method: 'POST', path: '/proxy/govtool/:endpoint*', handler: 'proxy.postGovtoolData', config: { - roles: ['authenticated', 'public'], - auth: false - }, + roles: ['authenticated', 'public'], + auth: false, + }, }, ], };