-
-
Notifications
You must be signed in to change notification settings - Fork 38
[lib-audit] S2-12 /data/workspace StaticFiles mount is authenticated but not user-scoped #2813
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| ### Security | ||
|
|
||
| - `/data/workspace` agent paths are now ownership-checked: only the agent owner or an admin may read files; unauthenticated requests still return 401 and path traversal is rejected. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| """RED test for S2-12: /data/workspace must be user-scoped. | ||
|
|
||
| Before the fix, any authenticated user can read any agent's workspace files | ||
| because the StaticFiles mount at /data/workspace has no ownership check. | ||
|
|
||
| After the fix, only the agent owner or an admin may read; unauthenticated | ||
| requests still return 401; traversal '../' is rejected. | ||
| """ | ||
| from __future__ import annotations | ||
|
|
||
| import pytest | ||
|
|
||
| from httpx import ASGITransport, AsyncClient | ||
|
|
||
|
|
||
| def _add_user(app, username: str, password: str) -> str: | ||
| auth = app.state.auth | ||
| invite_code = auth.add_user_invite(username, invited_by_username="admin") | ||
| auth.complete_invite( | ||
| username=username, | ||
| invite_code=invite_code, | ||
| full_name=username.title(), | ||
| email=f"{username}@test.local", | ||
| password=password, | ||
| ) | ||
| record = auth.find_user(username) | ||
| return record["id"] | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_workspace_files_scoped_to_owner(app, tmp_path): | ||
| from taos_test_csrf import csrf_event_hooks | ||
|
|
||
| registry_store = app.state.agent_registry | ||
| if registry_store._db is None: | ||
| await registry_store.init() | ||
|
|
||
| for attr in ("metrics", "notifications", "qmd_client"): | ||
| store = getattr(app.state, attr, None) | ||
| if store is not None and getattr(store, "_db", None) is None: | ||
| if hasattr(store, "init"): | ||
| await store.init() | ||
|
|
||
| if not app.state.auth.is_configured(): | ||
| app.state.auth.setup_user("admin", "Test Admin", "", "testpass") | ||
| admin_record = app.state.auth.find_user("admin") | ||
| admin_uid = admin_record["id"] if admin_record else "" | ||
|
|
||
| bob_uid = _add_user(app, "bob", "bobpass1") | ||
| alice_uid = _add_user(app, "alice", "alicepass1") | ||
|
|
||
| agent_name = "bob-agent" | ||
| rec = await registry_store.register( | ||
| framework="openclaw", | ||
| display_name=agent_name, | ||
| user_id=bob_uid, | ||
| ) | ||
| canonical_id = rec["canonical_id"] | ||
|
|
||
| agent_workspaces_dir = app.state.agent_workspaces_dir | ||
| agent_dir = agent_workspaces_dir / agent_name | ||
| agent_dir.mkdir(parents=True, exist_ok=True) | ||
| secret_file = agent_dir / "secret.txt" | ||
| secret_file.write_text("bob-secret-data") | ||
|
|
||
| app.state._startup_complete = True | ||
|
|
||
| admin_token = app.state.auth.create_session(user_id=admin_uid, long_lived=True) | ||
| bob_token = app.state.auth.create_session(user_id=bob_uid, long_lived=True) | ||
| alice_token = app.state.auth.create_session(user_id=alice_uid, long_lived=True) | ||
|
|
||
| transport = ASGITransport(app=app) | ||
| async with AsyncClient( | ||
| transport=transport, | ||
| base_url="http://test", | ||
| cookies={"taos_session": admin_token}, | ||
| event_hooks=csrf_event_hooks(), | ||
| ) as admin_c, AsyncClient( | ||
| transport=transport, | ||
| base_url="http://test", | ||
| cookies={"taos_session": bob_token}, | ||
| event_hooks=csrf_event_hooks(), | ||
| ) as bob_c, AsyncClient( | ||
| transport=transport, | ||
| base_url="http://test", | ||
| cookies={"taos_session": alice_token}, | ||
| event_hooks=csrf_event_hooks(), | ||
| ) as alice_c: | ||
| file_url = f"/data/workspace/{agent_name}/secret.txt" | ||
|
|
||
| admin_resp = await admin_c.get(file_url) | ||
| assert admin_resp.status_code == 200, f"admin expected 200, got {admin_resp.status_code}" | ||
| assert admin_resp.text == "bob-secret-data" | ||
|
|
||
| bob_resp = await bob_c.get(file_url) | ||
| assert bob_resp.status_code == 200, f"owner expected 200, got {bob_resp.status_code}" | ||
| assert bob_resp.text == "bob-secret-data" | ||
|
|
||
| alice_resp = await alice_c.get(file_url) | ||
| assert alice_resp.status_code in (403, 404), f"stranger expected 403/404, got {alice_resp.status_code}" | ||
|
|
||
| admin_c.cookies.clear() | ||
| unauth_resp = await admin_c.get(file_url) | ||
| assert unauth_resp.status_code == 401, f"unauthenticated expected 401, got {unauth_resp.status_code}" | ||
|
|
||
| traversal_resp = await alice_c.get(f"/data/workspace/{agent_name}/../secret.txt") | ||
| assert traversal_resp.status_code in (403, 404, 307), f"traversal expected 403/404/307, got {traversal_resp.status_code}" | ||
|
|
||
| traversal_resp2 = await alice_c.get(f"/data/workspace/{agent_name}/../../etc/passwd") | ||
| assert traversal_resp2.status_code in (403, 404, 307), f"traversal2 expected 403/404/307, got {traversal_resp2.status_code}" | ||
|
|
||
| await registry_store.close() | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -10,8 +10,9 @@ | |||||||||
| import yaml | ||||||||||
|
|
||||||||||
| logger = logging.getLogger(__name__) | ||||||||||
| from fastapi import FastAPI | ||||||||||
| from fastapi import FastAPI, HTTPException, Request | ||||||||||
| from fastapi.middleware.gzip import GZipMiddleware | ||||||||||
| from fastapi.responses import FileResponse | ||||||||||
| from fastapi.staticfiles import StaticFiles | ||||||||||
|
|
||||||||||
|
|
||||||||||
|
|
@@ -1850,10 +1851,54 @@ async def dispatch(self, request, call_next): | |||||||||
| if static_dir.exists(): | ||||||||||
| app.mount("/static", _CacheAwareStaticFiles(directory=str(static_dir)), name="static") | ||||||||||
|
|
||||||||||
| # Mount workspace for serving generated images and other workspace files | ||||||||||
| # Workspace files: agent paths are ownership-checked, everything else falls | ||||||||||
| # back to the plain workspace directory (generated images, music, etc.). | ||||||||||
| workspace_dir = data_dir / "workspace" | ||||||||||
| workspace_dir.mkdir(parents=True, exist_ok=True) | ||||||||||
| app.mount("/data/workspace", StaticFiles(directory=str(workspace_dir)), name="workspace") | ||||||||||
|
|
||||||||||
| @app.get("/data/workspace/{first_segment}/{rest:path}") | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -euo pipefail
python - <<'PY'
from fastapi import FastAPI
from starlette.routing import Match
app = FastAPI()
`@app.get`("/data/workspace/{first_segment}/{rest:path}")
async def route(first_segment: str, rest: str = ""):
return None
route_obj = next(
r for r in app.router.routes
if getattr(r, "path", None) == "/data/workspace/{first_segment}/{rest:path}"
)
scope = {
"type": "http",
"method": "GET",
"path": "/data/workspace/logo.png",
"root_path": "",
"headers": [],
"query_string": b"",
}
assert route_obj.matches(scope)[0] is Match.NONE
PYRepository: jaylfc/taOS Length of output: 269 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- repository knowledge ---'
trueRepository: jaylfc/taOS Length of output: 178 🤖 get_repo_knowledge executed:
Length of output: 5023 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- changed route ---'
sed -n '1815,1925p' tinyagentos/app.py
printf '%s\n' '--- workspace route and mount references ---'
rg -n -C 3 'data/workspace|workspace.*Static|StaticFiles|_serve_workspace_file' tinyagentos tests changelog.d 2>/dev/null | head -240
printf '%s\n' '--- relevant diff summary ---'
git diff --stat -- tinyagentos/app.py changelog.dRepository: jaylfc/taOS Length of output: 11162 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- dependency contract ---'
rg -n -C 2 'fastapi|starlette' pyproject.toml requirements*.txt poetry.lock uv.lock 2>/dev/null | head -120
printf '%s\n' '--- parent implementation around workspace serving ---'
if git rev-parse --verify HEAD^ >/dev/null 2>&1; then
git show HEAD^:tinyagentos/app.py 2>/dev/null | sed -n '1825,1895p'
fi
printf '%s\n' '--- current workspace-file tests ---'
sed -n '1,150p' tests/test_workspace_files_auth.py
printf '%s\n' '--- route syntax references ---'
rg -n '\{[^}]+:path\}|`@app`\.get\(' tinyagentos tests -g '*.py' | head -160Repository: jaylfc/taOS Length of output: 7069 🏁 Script executed: #!/bin/bash
set -u
printf '%s\n' '--- current workspace-file tests ---'
if [ -f tests/test_workspace_files_auth.py ]; then
sed -n '1,150p' tests/test_workspace_files_auth.py
else
printf '%s\n' 'tests/test_workspace_files_auth.py is unavailable'
fi
printf '%s\n' '--- parent route or mount ---'
if git rev-parse --verify HEAD^ >/dev/null 2>&1; then
git show HEAD^:tinyagentos/app.py 2>/dev/null | rg -n -C 8 'data/workspace|workspace_dir|StaticFiles' || true
else
printf '%s\n' 'parent commit is unavailable'
fi
printf '%s\n' '--- current route contract context ---'
sed -n '1,32p' tinyagentos/app.py
sed -n '1848,1905p' tinyagentos/app.pyRepository: jaylfc/taOS Length of output: 9720 🌐 Web query:
💡 Result: In Starlette, the path converter is a built-in routing mechanism used to capture parts of a URL path, allowing for type conversion and specific matching behaviors [1][2]. The Citations:
Restore single-segment workspace file requests. The FastAPI route Proposed fix+ `@app.get`("/data/workspace/{first_segment}")
`@app.get`("/data/workspace/{first_segment}/{rest:path}")
async def _serve_workspace_file(request: Request, first_segment: str, rest: str = ""):📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| async def _serve_workspace_file(request: Request, first_segment: str, rest: str = ""): | ||||||||||
| from tinyagentos.auth_context import ( | ||||||||||
| current_user, | ||||||||||
| require_owner_or_admin, | ||||||||||
| resolve_agent_owner, | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| user = current_user(request) | ||||||||||
|
|
||||||||||
| registry = getattr(request.app.state, "agent_registry", None) | ||||||||||
| owner = None | ||||||||||
| if registry is not None: | ||||||||||
| try: | ||||||||||
| rec = await registry.get(first_segment) | ||||||||||
| if rec is None: | ||||||||||
| rec = await registry.get_by_slug(first_segment) | ||||||||||
| if rec is None: | ||||||||||
| rec = await registry.get_by_handle(first_segment) | ||||||||||
| except RuntimeError: | ||||||||||
| rec = None | ||||||||||
| if rec is not None: | ||||||||||
| owner = rec.get("user_id") or None | ||||||||||
|
|
||||||||||
| if owner is not None: | ||||||||||
| require_owner_or_admin(user, owner) | ||||||||||
| root = Path(request.app.state.agent_workspaces_dir).resolve() | ||||||||||
| agent_dir = (root / first_segment).resolve() | ||||||||||
| if not agent_dir.is_relative_to(root): | ||||||||||
| raise HTTPException(status_code=404) | ||||||||||
| target = (agent_dir / rest).resolve() if rest else agent_dir | ||||||||||
| if not target.is_relative_to(agent_dir): | ||||||||||
| raise HTTPException(status_code=404) | ||||||||||
| else: | ||||||||||
| root = (Path(request.app.state.data_dir) / "workspace").resolve() | ||||||||||
| target = (root / first_segment / rest).resolve() if rest else (root / first_segment).resolve() | ||||||||||
| if not target.is_relative_to(root): | ||||||||||
| raise HTTPException(status_code=404) | ||||||||||
|
|
||||||||||
| if not target.is_file(): | ||||||||||
| raise HTTPException(status_code=404) | ||||||||||
|
|
||||||||||
| return FileResponse(target) | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- route and nearby response handling ---'
sed -n '1825,1910p' tinyagentos/app.py
printf '%s\n' '--- cache-related middleware and headers ---'
rg -n -i 'SecurityHeadersMiddleware|Cache-Control|cache-control|no-store|FileResponse' tinyagentos desktop/src changelog.d --glob '*.py' --glob '*.md' | head -200
printf '%s\n' '--- relevant middleware definitions ---'
rg -n 'class .*Middleware|SecurityHeadersMiddleware|add_middleware' tinyagentos --glob '*.py' | head -120Repository: jaylfc/taOS Length of output: 12548 🏁 Script executed: #!/bin/bash
set -eu
rg -n -i 'Cache-Control|cache_control|no-store|SecurityHeadersMiddleware|FileResponse' tinyagentos --glob '*.py'Repository: jaylfc/taOS Length of output: 6866 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- security headers middleware ---'
sed -n '70,105p' tinyagentos/middleware/security_headers.py
printf '%s\n' '--- middleware registration ---'
sed -n '1635,1660p' tinyagentos/app.pyRepository: jaylfc/taOS Length of output: 2716 🏁 Script executed: #!/bin/bash
set -eu
sed -n '1,35p' tinyagentos/middleware/security_headers.pyRepository: jaylfc/taOS Length of output: 1941 🏁 Script executed: #!/bin/bash
set -eu
sed -n '35,65p' tinyagentos/middleware/security_headers.pyRepository: jaylfc/taOS Length of output: 1506 Sensitive Data Exposure (CWE-524) Reachability: External · Exploitability: Moderate Disable cache storage for authorization-varying files.
Proposed fix- return FileResponse(target)
+ return FileResponse(target, headers={"Cache-Control": "no-store"})📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
|
|
||||||||||
| # Desktop SPA assets are served by the desktop route handler (routes/desktop.py) | ||||||||||
|
|
||||||||||
|
|
||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: jaylfc/taOS
Length of output: 149
🏁 Script executed:
Repository: jaylfc/taOS
Length of output: 28906
🤖 get_repo_knowledge executed:
get_repo_knowledge jaylfc/taOS /tmp/coderabbit-repo-knowledge/jaylfc-taos-a13cd95b/learningsLength of output: 4340
🏁 Script executed:
Repository: jaylfc/taOS
Length of output: 16342
🏁 Script executed:
Repository: jaylfc/taOS
Length of output: 45081
Exercise traversal protection with an authorized client.
alice_creceives403fromrequire_owner_or_admin()before the handler checkstarget.is_relative_to(agent_dir). The accepted403result therefore does not test traversal protection.Use
bob_coradmin_c, send percent-encoded dot segments, and require404.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents