From c4c4379e31d4be69ccd1cd3069d3fb47b2e35946 Mon Sep 17 00:00:00 2001 From: rajashidattapy Date: Mon, 20 Jul 2026 11:49:19 +0530 Subject: [PATCH] [rajashidattapy] fix: apply os.sep boundary to serve/seed path guards (#136) serve_workspace_file and the workspace seed writer checked containment with a bare startswith(folder), so a sibling workspace whose id is a string prefix of the target (abc vs abc-evil) passed the guard. Match the stronger form already used by write_workspace_file/delete_workspace_file: startswith(folder_norm + os.sep) with the folder-itself equality escape. Co-Authored-By: Claude Opus 4.8 --- backend/apps/outputs/outputs.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index 62dfd17c4..836e6e0b7 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -77,8 +77,10 @@ async def outputs_lifespan(): async def serve_workspace_file(workspace_id: str, filepath: str, p_d: str = ""): """Serve a file from a workspace folder. For index.html, inject OUTPUT data.""" folder = os.path.join(WORKSPACE_DIR, workspace_id) + folder_norm = os.path.normpath(folder) full_path = os.path.normpath(os.path.join(folder, filepath)) - if not full_path.startswith(os.path.normpath(folder)): + # `+ os.sep` (matching write/delete) so workspace `abc` can't serve out of a sibling `abc-evil` by prefix-string collision rather than path-component containment. + if full_path != folder_norm and not full_path.startswith(folder_norm + os.sep): raise HTTPException(status_code=403, detail="Path traversal not allowed") if not os.path.isfile(full_path): raise HTTPException(status_code=404, detail="File not found") @@ -363,9 +365,11 @@ async def seed_workspace(body: WorkspaceSeedRequest): # Legacy flat path. Seed only fills in MISSING files; it never overwrites what's already on disk. A reopen re-sends the inline output.files snapshot, which lags behind whatever the agent just wrote to the workspace; writing it back reverted every edited file (new files survived, edited ones snapped to the snapshot). Disk wins once an app exists. if body.files: + folder_norm = os.path.normpath(folder) for rel_path, content in body.files.items(): full_path = os.path.normpath(os.path.join(folder, rel_path)) - if not full_path.startswith(os.path.normpath(folder)): + # `+ os.sep` (matching write/delete) so a sibling `abc-evil` can't be seeded via workspace `abc` by prefix-string collision. + if full_path != folder_norm and not full_path.startswith(folder_norm + os.sep): continue if os.path.exists(full_path): continue