Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 50 additions & 29 deletions backend/src/api/proxy/controllers/proxy.js
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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 = {
Expand Down
26 changes: 11 additions & 15 deletions backend/src/api/proxy/routes/proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
],
};