{"record":{"id":"18a8fa2c300cdb30","repo":"abi/screenshot-to-code","slug":"invalid-asset-path","errorCode":null,"errorMessage":"Invalid asset path","messagePattern":"Invalid asset path","errorType":"http","errorClass":"HTTPException","httpStatus":400,"severity":"error","filePath":"backend/routes/agent_runs.py","lineNumber":222,"sourceCode":"    _fetch_run(run_id)\n    run_dir = os.path.join(get_agent_runs_directory(), run_id)\n    for candidate in (\"final_selfcontained.html\", \"final.html\"):\n        path = os.path.join(run_dir, candidate)\n        if os.path.isfile(path):\n            with open(path, \"r\", encoding=\"utf-8\") as f:\n                return HTMLResponse(content=f.read())\n    raise HTTPException(status_code=404, detail=\"Run has no captured output\")\n\n\n@router.get(\"/agent-runs/{run_id}/assets/{filename}\")\nasync def get_agent_run_asset(run_id: str, filename: str) -> FileResponse:\n    _fetch_run(run_id)\n    assets_dir = os.path.join(get_agent_runs_directory(), run_id, \"assets\")\n    # basename() strips any traversal components before the containment check.\n    safe_name = os.path.basename(filename)\n    path = os.path.realpath(os.path.join(assets_dir, safe_name))\n    if not path.startswith(os.path.realpath(assets_dir) + os.sep):\n        raise HTTPException(status_code=400, detail=\"Invalid asset path\")\n    if not os.path.isfile(path):\n        raise HTTPException(status_code=404, detail=\"Asset not found\")\n    return FileResponse(path)\n\n\n@router.post(\"/agent-runs/prune\", response_model=PruneAgentRunsResponse)\nasync def prune_agent_runs(request: PruneAgentRunsRequest) -> PruneAgentRunsResponse:\n    if request.max_age_days < 1:\n        raise HTTPException(status_code=400, detail=\"max_age_days must be >= 1\")\n\n    runs_directory = get_agent_runs_directory()\n    if not os.path.isdir(runs_directory):\n        return PruneAgentRunsResponse(deleted_count=0, freed_bytes=0)\n\n    cutoff_timestamp = (\n        datetime.now() - timedelta(days=request.max_age_days)\n    ).timestamp()\n","sourceCodeStart":204,"sourceCodeEnd":240,"githubUrl":"https://github.com/abi/screenshot-to-code/blob/d026163f586dfa8c5c10d28c36edd59a9d3b0e88/backend/routes/agent_runs.py#L204-L240","documentation":"Raised by GET /agent-runs/{run_id}/assets/{filename} (400) when the resolved, realpath'd candidate does not stay inside the run's assets directory. Because os.path.basename() already strips any '..'/slash components from filename, plain traversal strings never reach this branch in practice; it fires when realpath escapes the assets dir — i.e. a symlink inside assets/ pointing outside it. It is a defense-in-depth containment check.","triggerScenarios":"Requesting an asset whose name matches a symlink stored inside assets/ that resolves to a file outside the assets directory. Path strings like ../../etc/passwd are neutralized by basename() and instead yield 'Asset not found' (404).","commonSituations":"A generation step or user created symlinks in the run's assets folder (e.g. to save disk space); compromised or hand-crafted assets directories; test suites probing traversal behavior with symlinks.","solutions":["Inspect assets/ under the run directory for symlinks (find <run_dir>/assets -type l) and replace them with regular files.","Request only plain file names produced by the run's asset manifest.","If you control asset writing, ensure it copies files rather than symlinking.","Treat occurrences as a security signal — audit how the symlink got there."],"exampleFix":"# before: assets/logo.png -> symlink to /etc/hostname\nGET /agent-runs/run_.../assets/logo.png  # 400 'Invalid asset path'\n\n# after: replace the symlink with a real file\nrm <runs_dir>/<run_id>/assets/logo.png\ncp /real/path/logo.png <runs_dir>/<run_id>/assets/logo.png\nGET /agent-runs/run_.../assets/logo.png  # 200","handlingStrategy":"validation","validationCode":"import os\n\ndef is_plain_asset_filename(filename: str) -> bool:\n    \"\"\"Bare name, no separators — keeps you on the basename() happy path.\"\"\"\n    return filename == os.path.basename(filename) and filename not in (\"\", \".\", \"..\")","typeGuard":"def is_safe_asset_name(filename: str) -> bool:\n    return (\n        isinstance(filename, str)\n        and filename == os.path.basename(filename)\n        and not filename.startswith(\".\")\n    )","tryCatchPattern":"try:\n    resp = client.get(f\"/agent-runs/{run_id}/assets/{filename}\")\n    resp.raise_for_status()\nexcept httpx.HTTPStatusError as e:\n    if e.response.status_code == 400 and \"Invalid asset path\" in e.response.text:\n        raise RuntimeError(f\"symlink escape in assets dir for {filename!r}\") from e\n    raise","preventionTips":["Never create symlinks inside a run's assets/ directory.","Send bare filenames only — no directories, no '..'.","Audit assets dirs for symlinks (find -type l) if this error appears."],"tags":["fastapi","http-400","path-traversal","security","symlink","agent-runs"],"backgroundTag":null,"analyzedSha":"d026163f586dfa8c5c10d28c36edd59a9d3b0e88","analyzedAt":"2026-08-14T22:02:06.951Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}