From 719828d8f720672f606bf7cb06812d32879ad8d1 Mon Sep 17 00:00:00 2001 From: aglichandrap Date: Wed, 27 May 2026 22:39:12 +0700 Subject: [PATCH] fix(security): remove open SSRF proxy and add endpoint validation Fixes IntersectMBO/govtool#4168 Changes: - Remove the unrestricted forward() handler (POST /api/proxy) that allowed any unauthenticated user to make the server fetch arbitrary URLs - Remove the corresponding POST /proxy route - Add validateEndpoint() helper that rejects: absolute URLs, protocol-relative URLs, path traversal sequences (..), null bytes, and backslashes - Apply endpoint validation to getGovtoolData and postGovtoolData handlers - Add GOVTOOL_API_BASE_URL null check with proper error response - Enable Authorization header forwarding when GOVTOOL_API_TOKEN is configured - Fix govtool-proxy controller with same SSRF protections - Remove console.log(leaked URL) from govtool-proxy controller - Translate Serbian comments to English in govtool-proxy The generic POST /api/proxy endpoint was dead code - the frontend only uses /api/proxy/govtool/... routes. Removing it eliminates the critical SSRF vulnerability (CVSS 9.1) that allowed cloud credential theft, internal network traversal, and arbitrary request forgery. --- .../controllers/govtool-proxy.js | 54 +++++++--- backend/src/api/proxy/controllers/proxy.js | 101 ++++++++++++------ backend/src/api/proxy/routes/proxy.js | 21 ++-- 3 files changed, 116 insertions(+), 60 deletions(-) diff --git a/backend/src/api/govtool-proxy/controllers/govtool-proxy.js b/backend/src/api/govtool-proxy/controllers/govtool-proxy.js index 26181c0..9803005 100644 --- a/backend/src/api/govtool-proxy/controllers/govtool-proxy.js +++ b/backend/src/api/govtool-proxy/controllers/govtool-proxy.js @@ -5,30 +5,56 @@ module.exports = { try { const baseUrl = process.env.GOVTOOL_API_BASE_URL; - // Uzmite endpoint iz query parametara + if (!baseUrl) { + return ctx.throw(500, 'GOVTOOL_API_BASE_URL is not configured'); + } + const { endpoint } = ctx.request.query; - if (!endpoint) { - return ctx.throw(400, 'Endpoint parametar je obavezan'); + if (!endpoint || typeof endpoint !== 'string' || endpoint.trim() === '') { + return ctx.throw(400, 'Endpoint parameter is required'); + } + + // Reject absolute URLs to prevent SSRF + if (/^https?:\/\//i.test(endpoint)) { + return ctx.throw(400, 'Absolute URLs are not allowed'); } - // Spojite base URL i endpoint - const fullUrl = `${baseUrl}/${endpoint}`; -console.log(fullUrl); - // Provera da li je URL validan (opciono) + // Reject protocol-relative URLs + if (/^\/\//.test(endpoint)) { + return ctx.throw(400, 'Protocol-relative URLs are not allowed'); + } + + // Reject path traversal sequences + if (endpoint.includes('..')) { + return ctx.throw(400, 'Path traversal is not allowed'); + } + + // Reject null bytes and backslashes + if (endpoint.includes('\0') || endpoint.includes('\\')) { + return ctx.throw(400, 'Invalid characters in endpoint'); + } + + const fullUrl = `${baseUrl.replace(/\/$/, '')}/${endpoint}`; + try { - new URL(fullUrl); // Provera da li je URL validan + new URL(fullUrl); } catch (error) { - return ctx.throw(400, 'Nevalidan URL'); + return ctx.throw(400, 'Invalid URL'); + } + + const headers = {}; + if (process.env.GOVTOOL_API_TOKEN) { + headers.Authorization = `Bearer ${process.env.GOVTOOL_API_TOKEN}`; } - // Pozovite eksterni API - const response = await axios.get(fullUrl); + const response = await axios.get(fullUrl, { headers }); - // Vratite podatke klijentu ctx.send(response.data); } catch (error) { - ctx.throw(500, 'Greška pri preuzimanju podataka sa eksternog API-ja', { error }); + if (error.status) throw error; + strapi.log.error('GovTool proxy error:', error); + ctx.throw(500, 'Error fetching data from external API', { error }); } }, -}; \ No newline at end of file +}; diff --git a/backend/src/api/proxy/controllers/proxy.js b/backend/src/api/proxy/controllers/proxy.js index cf7da45..1a1778c 100644 --- a/backend/src/api/proxy/controllers/proxy.js +++ b/backend/src/api/proxy/controllers/proxy.js @@ -1,41 +1,69 @@ "use strict"; const axios = require("axios"); -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, - }; - } - }, +/** + * Validates that an endpoint path is safe and cannot be used for SSRF or path traversal. + * @param {string} endpoint - The endpoint path to validate + * @returns {{ valid: boolean, error?: string }} + */ +function validateEndpoint(endpoint) { + if (!endpoint || typeof endpoint !== "string" || endpoint.trim() === "") { + return { valid: false, error: "Endpoint is required" }; + } + + // Reject absolute URLs to prevent SSRF + if (/^https?:\/\//i.test(endpoint)) { + return { valid: false, error: "Absolute URLs are not allowed" }; + } + + // Reject protocol-relative URLs + if (/^\/\//.test(endpoint)) { + return { valid: false, error: "Protocol-relative URLs are not allowed" }; + } + // Reject path traversal sequences + if (endpoint.includes("..")) { + return { valid: false, error: "Path traversal is not allowed" }; + } + + // Reject null bytes + if (endpoint.includes("\0")) { + return { valid: false, error: "Null bytes are not allowed" }; + } + + // Reject backslashes (Windows path traversal) + if (endpoint.includes("\\")) { + return { valid: false, error: "Backslashes are not allowed" }; + } + + return { valid: true }; +} + +module.exports = { // GET /proxy/govtool/:endpoint* async getGovtoolData(ctx) { try { const endpoint = ctx.params.endpoint; - if (!endpoint) return ctx.badRequest("Endpoint is required"); + const validation = validateEndpoint(endpoint); + if (!validation.valid) { + return ctx.badRequest(validation.error); + } + const baseUrl = process.env.GOVTOOL_API_BASE_URL; + if (!baseUrl) { + return ctx.internalServerError("GOVTOOL_API_BASE_URL is not configured"); + } + const fullUrl = `${baseUrl.replace(/\/$/, "")}/${endpoint}`; + + const headers = {}; + if (process.env.GOVTOOL_API_TOKEN) { + headers.Authorization = `Bearer ${process.env.GOVTOOL_API_TOKEN}`; + } + 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 }); @@ -52,16 +80,27 @@ module.exports = { async postGovtoolData(ctx) { try { const endpoint = ctx.params.endpoint; - if (!endpoint) return ctx.badRequest("Endpoint is required"); + const validation = validateEndpoint(endpoint); + if (!validation.valid) { + return ctx.badRequest(validation.error); + } const baseUrl = process.env.GOVTOOL_API_BASE_URL; + if (!baseUrl) { + return ctx.internalServerError("GOVTOOL_API_BASE_URL is not configured"); + } + const fullUrl = `${baseUrl.replace(/\/$/, "")}/${endpoint}`; + const headers = { + "Content-Type": "application/json", + }; + if (process.env.GOVTOOL_API_TOKEN) { + headers.Authorization = `Bearer ${process.env.GOVTOOL_API_TOKEN}`; + } + const response = await axios.post(fullUrl, ctx.request.body, { - headers: { - "Content-Type": "application/json", - // Authorization: `Bearer ${process.env.GOVTOOL_API_TOKEN}`, - }, + headers, }); ctx.send({ status: response.status, data: response.data }); diff --git a/backend/src/api/proxy/routes/proxy.js b/backend/src/api/proxy/routes/proxy.js index adc9570..7d76d46 100644 --- a/backend/src/api/proxy/routes/proxy.js +++ b/backend/src/api/proxy/routes/proxy.js @@ -2,32 +2,23 @@ module.exports = { routes: [ - { - method: 'POST', - path: '/proxy', - handler: 'proxy.forward', - config: { - roles: ['authenticated', 'public'], - auth: false - }, - }, { 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 + }, }, ], };