Skip to content

Commit 188c6b5

Browse files
authored
fix(security): hosted multi-tenancy hardening — session REST owner-scoping + TOCTOU path revalidation (#704) (#707)
* fix(security): owner-scope session REST endpoints + revalidate workspace path at WS connect (#704) Hosted multi-tenancy hardening (follow-up to #655): 1. Owner-scope session REST endpoints. list/get/delete/messages queried without an owner filter, so an authed tenant could enumerate/read/modify/ end another tenant's sessions. Thread require_auth through all five handlers; scope repo.list() by user_id and gate per-id handlers via _get_owned_session (404 on missing-or-not-yours so IDs can't be probed). No-auth mode (user_id=None) skips scoping, unchanged. 2. TOCTOU symlink revalidation. The stored workspace_path cleared the allowlist at create time but terminal_ws/session_chat_ws used the raw string later as cwd/tool scope. A tenant could swap a dir for a symlink outside its root in between. Re-resolve + re-check (revalidate_workspace_path) at WS connect; close on reject; use the freshly resolved path. Known limitation: a sub-ms race remains between revalidation and shell spawn; true TOCTOU-proof isolation needs per-tenant container/chroot or openat2(RESOLVE_NO_SYMLINKS) — infra-level, deferred. * refactor: address review feedback on #704 - Reword 'ponytail:' docstring label to 'Note:' in revalidate_workspace_path - Hoist revalidate_workspace_path imports to module top (no circular import) - Strengthen terminal symlink test to assert close code 4008
1 parent 4ac42b6 commit 188c6b5

8 files changed

Lines changed: 315 additions & 15 deletions

File tree

codeframe/platform_store/repositories/interactive_sessions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,15 @@ def list(
5151
workspace_path: Optional[str] = None,
5252
state: Optional[str] = None,
5353
limit: int = 50,
54+
user_id: Optional[int] = None,
5455
) -> list[dict]:
5556
query = "SELECT * FROM interactive_sessions WHERE 1=1"
5657
params: list = []
58+
# Owner-scoping (#704): filter only when a user_id is supplied. None means
59+
# no-auth mode (CODEFRAME_AUTH_REQUIRED=false) → return all, unchanged.
60+
if user_id is not None:
61+
query += " AND user_id = ?"
62+
params.append(user_id)
5763
if workspace_path is not None:
5864
query += " AND workspace_path = ?"
5965
params.append(workspace_path)

codeframe/ui/dependencies.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,26 @@ def enforce_workspace_allowlist(path: Path, user_id: Optional[int]) -> Path:
7979
return path
8080

8181

82+
def revalidate_workspace_path(workspace_path: str, user_id: Optional[int]) -> Optional[Path]:
83+
"""Re-check a stored session workspace path against the allowlist at use time (#704).
84+
85+
``create_session`` validates the path once, but the terminal/chat WebSockets
86+
open later — a tenant could swap a dir (or ancestor) for a symlink pointing
87+
outside its allowed root in between (TOCTOU). ``enforce_workspace_allowlist``
88+
calls ``.resolve()``, which follows symlinks, so a swapped-in escape is caught
89+
here. Returns the freshly resolved path, or ``None`` if it no longer passes
90+
(the WS caller closes the socket instead of raising HTTP).
91+
92+
Note: this closes the practical window; a sub-millisecond race remains between
93+
this check and the shell spawn. True TOCTOU-proof isolation needs a per-tenant
94+
container/chroot or openat2(RESOLVE_NO_SYMLINKS) — infra-level, deferred.
95+
"""
96+
try:
97+
return enforce_workspace_allowlist(Path(workspace_path), user_id)
98+
except HTTPException:
99+
return None
100+
101+
82102
def get_workspace_manager(request: Request) -> WorkspaceManager:
83103
"""Get workspace manager from application state.
84104

codeframe/ui/routers/interactive_sessions_v2.py

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,22 @@ def _get_repo(request: Request):
115115
return db.interactive_sessions
116116

117117

118+
def _get_owned_session(repo, session_id: str, auth: Dict[str, Any]) -> dict:
119+
"""Fetch a session, enforcing owner-scoping (#704).
120+
121+
Returns the row, or raises 404 if it is missing OR owned by another user.
122+
404 (not 403) so a tenant can't probe which session IDs exist. In no-auth
123+
mode ``user_id`` is None → ownership is not enforced (matches REST/#655).
124+
"""
125+
session = repo.get(session_id)
126+
user_id = auth.get("user_id")
127+
if session is None or (
128+
user_id is not None and session.get("user_id") != user_id
129+
):
130+
raise HTTPException(status_code=404, detail="Session not found")
131+
return session
132+
133+
118134
def _session_to_response(row: dict) -> SessionResponse:
119135
"""Convert a DB row dict to a SessionResponse."""
120136
return SessionResponse(
@@ -172,6 +188,7 @@ def list_sessions(
172188
workspace_path: Optional[str] = Query(None),
173189
state: Optional[str] = Query(None),
174190
limit: int = Query(50, ge=1, le=200),
191+
auth: Dict[str, Any] = Depends(require_auth),
175192
):
176193
"""List sessions with optional filters by workspace_path and state."""
177194
if state is not None and state not in VALID_STATES:
@@ -180,39 +197,55 @@ def list_sessions(
180197
detail=f"Invalid state '{state}'. Must be one of {sorted(VALID_STATES)}",
181198
)
182199
repo = _get_repo(request)
183-
sessions = repo.list(workspace_path=workspace_path, state=state, limit=limit)
200+
sessions = repo.list(
201+
workspace_path=workspace_path,
202+
state=state,
203+
limit=limit,
204+
user_id=auth.get("user_id"),
205+
)
184206
return [_session_to_response(s) for s in sessions]
185207

186208

187209
@router.get("/{session_id}", response_model=SessionResponse)
188210
@rate_limit_standard()
189-
def get_session(session_id: str, request: Request):
211+
def get_session(
212+
session_id: str,
213+
request: Request,
214+
auth: Dict[str, Any] = Depends(require_auth),
215+
):
190216
"""Get a session by ID."""
191217
repo = _get_repo(request)
192-
session = repo.get(session_id)
193-
if session is None:
194-
raise HTTPException(status_code=404, detail="Session not found")
195-
return _session_to_response(session)
218+
return _session_to_response(_get_owned_session(repo, session_id, auth))
196219

197220

198221
@router.delete("/{session_id}", response_model=SessionResponse)
199222
@rate_limit_standard()
200-
def end_session(session_id: str, request: Request):
223+
def end_session(
224+
session_id: str,
225+
request: Request,
226+
auth: Dict[str, Any] = Depends(require_auth),
227+
):
201228
"""End a session (sets state to 'ended' and records ended_at)."""
202229
repo = _get_repo(request)
230+
_get_owned_session(repo, session_id, auth)
203231
updated = repo.end(session_id)
204232
if updated is None:
233+
# Vanished between the ownership check and end() (concurrent delete).
205234
raise HTTPException(status_code=404, detail="Session not found")
206235
return _session_to_response(updated)
207236

208237

209238
@router.post("/{session_id}/messages", status_code=201, response_model=MessageResponse)
210239
@rate_limit_standard()
211-
def add_message(session_id: str, body: MessageCreate, request: Request):
240+
def add_message(
241+
session_id: str,
242+
body: MessageCreate,
243+
request: Request,
244+
auth: Dict[str, Any] = Depends(require_auth),
245+
):
212246
"""Add a message to a session's history."""
213247
repo = _get_repo(request)
214-
if repo.get(session_id) is None:
215-
raise HTTPException(status_code=404, detail="Session not found")
248+
_get_owned_session(repo, session_id, auth)
216249
message = repo.add_message(
217250
session_id=session_id,
218251
role=body.role,
@@ -236,11 +269,11 @@ def get_messages(
236269
request: Request,
237270
limit: int = Query(100, ge=1, le=500),
238271
offset: int = Query(0, ge=0),
272+
auth: Dict[str, Any] = Depends(require_auth),
239273
):
240274
"""Get paginated message history for a session."""
241275
repo = _get_repo(request)
242-
if repo.get(session_id) is None:
243-
raise HTTPException(status_code=404, detail="Session not found")
276+
_get_owned_session(repo, session_id, auth)
244277
messages = repo.get_messages(session_id, limit=limit, offset=offset)
245278
return [
246279
MessageResponse(

codeframe/ui/routers/session_chat_ws.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040

4141
from codeframe.auth.dependencies import authenticate_websocket
4242
from codeframe.core.adapters.streaming_chat import StreamingChatAdapter
43+
from codeframe.ui.dependencies import revalidate_workspace_path
4344
from codeframe.ui.shared import session_chat_manager
4445

4546
logger = logging.getLogger(__name__)
@@ -136,6 +137,21 @@ async def session_chat_ws(session_id: str, websocket: WebSocket) -> None:
136137
await websocket.close(code=1008, reason="Forbidden: session belongs to another user")
137138
return
138139

140+
# --- Revalidate the stored path against the allowlist (TOCTOU, #704) ---
141+
# The chat agent runs file-system tools scoped to this path. It cleared the
142+
# allowlist at create time, but a tenant could have swapped a dir for a
143+
# symlink pointing outside its root before connecting. Re-resolve and
144+
# re-check now; use the freshly resolved path for the rest of the session.
145+
raw_workspace = session.get("workspace_path")
146+
validated_workspace: Optional[Path] = None
147+
if raw_workspace:
148+
validated_workspace = await asyncio.to_thread(
149+
revalidate_workspace_path, raw_workspace, user_id
150+
)
151+
if validated_workspace is None:
152+
await websocket.close(code=1008, reason="Workspace path no longer permitted")
153+
return
154+
139155
# --- Accept connection; everything after this point must run inside the
140156
# try/finally so unregister() and close() always execute even if
141157
# update_state, register, or get_token_queue raises. ---
@@ -282,14 +298,13 @@ async def _receive() -> None:
282298
break
283299

284300
interrupt_event = await session_chat_manager.get_interrupt_event(session_id)
285-
raw_workspace = session.get("workspace_path")
286-
if not raw_workspace:
301+
if validated_workspace is None:
287302
logger.warning(
288303
"session_id=%s has no workspace_path set; "
289304
"file tools will scope to server CWD",
290305
session_id,
291306
)
292-
workspace_path = Path(raw_workspace or ".")
307+
workspace_path = validated_workspace or Path(".")
293308
adapter_task[0] = asyncio.create_task(
294309
_run_streaming_adapter(
295310
session_id,

codeframe/ui/routers/terminal_ws.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
2626

2727
from codeframe.auth.dependencies import authenticate_websocket
28+
from codeframe.ui.dependencies import revalidate_workspace_path
2829

2930
logger = logging.getLogger(__name__)
3031

@@ -92,6 +93,22 @@ async def session_terminal_ws(session_id: str, websocket: WebSocket) -> None:
9293
await websocket.close(code=4008, reason="Session has no workspace configured")
9394
return
9495

96+
# --- Revalidate the stored path against the allowlist (TOCTOU, #704) ---
97+
# The path cleared the allowlist at create time, but a tenant could have
98+
# swapped a dir for a symlink pointing outside its root before connecting.
99+
# Re-resolve and re-check now; spawn with the freshly resolved path.
100+
revalidated = await asyncio.to_thread(
101+
revalidate_workspace_path, workspace_path, user_id
102+
)
103+
if revalidated is None:
104+
logger.error(
105+
"session_id=%s workspace_path no longer within allowlist; refusing terminal spawn",
106+
session_id,
107+
)
108+
await websocket.close(code=4008, reason="Workspace path no longer permitted")
109+
return
110+
workspace_path = str(revalidated)
111+
95112
# --- Per-user connection cap ---
96113
current = _user_terminal_counts.get(user_id, 0)
97114
if current >= _MAX_TERMINALS_PER_USER:

tests/api/test_session_chat_ws.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,45 @@ def test_chat_ws_ownership_mismatch_closes():
7777
assert exc.value.code == 1008
7878

7979

80+
def test_chat_ws_symlink_escape_rejected(tmp_path, monkeypatch):
81+
"""TOCTOU: stored path swapped to a symlink outside the root → WS closes (#704)."""
82+
from unittest.mock import AsyncMock, MagicMock, patch
83+
84+
from fastapi import FastAPI
85+
from fastapi.testclient import TestClient
86+
87+
from codeframe.ui.routers.session_chat_ws import router
88+
89+
base = tmp_path / "allowed"
90+
base.mkdir()
91+
outside = tmp_path / "outside"
92+
outside.mkdir()
93+
swapped = base / "proj"
94+
swapped.symlink_to(outside)
95+
monkeypatch.setenv("WORKSPACE_ROOT", str(base))
96+
monkeypatch.setenv("CODEFRAME_DEPLOYMENT_MODE", "self_hosted")
97+
98+
app = FastAPI()
99+
app.include_router(router)
100+
fake_db = MagicMock()
101+
fake_db.interactive_sessions.get.return_value = {
102+
"state": "active",
103+
"workspace_path": str(swapped),
104+
"user_id": 1,
105+
}
106+
app.state.db = fake_db
107+
client = TestClient(app)
108+
109+
with patch(
110+
"codeframe.ui.routers.session_chat_ws._authenticate_websocket",
111+
new=AsyncMock(return_value=(True, 1)),
112+
):
113+
with pytest.raises(WebSocketDisconnect) as exc:
114+
with client.websocket_connect("/ws/sessions/s1/chat?token=x"):
115+
pass
116+
assert exc.value.code == 1008
117+
118+
80119
# ---------------------------------------------------------------------------
81120
# Auth tests
82121
# ---------------------------------------------------------------------------
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Session REST endpoints must be scoped by owner (issue #704).
2+
3+
`POST /api/v2/sessions` persists ``user_id`` and the terminal/chat WebSockets
4+
enforce ownership, but the REST list/get/delete/messages endpoints queried
5+
without an owner filter — in an authenticated multi-user deployment one tenant
6+
could enumerate/read/modify/end another tenant's sessions. These tests pin the
7+
owner-scoping (and the no-auth passthrough).
8+
"""
9+
10+
import pytest
11+
from fastapi import FastAPI
12+
from fastapi.testclient import TestClient
13+
14+
from codeframe.auth.dependencies import require_auth
15+
from codeframe.platform_store.database import Database
16+
from codeframe.ui.routers.interactive_sessions_v2 import router
17+
18+
pytestmark = pytest.mark.v2
19+
20+
21+
@pytest.fixture
22+
def app(monkeypatch):
23+
# Owner-scoping is independent of the workspace allowlist; keep it off.
24+
monkeypatch.delenv("WORKSPACE_ROOT", raising=False)
25+
monkeypatch.setenv("CODEFRAME_DEPLOYMENT_MODE", "self_hosted")
26+
application = FastAPI()
27+
application.include_router(router)
28+
db = Database(":memory:")
29+
db.initialize()
30+
# Seed the two users that sessions are owned by (FK: sessions.user_id → users.id).
31+
for uid in (1, 2):
32+
db.conn.execute(
33+
"INSERT OR IGNORE INTO users (id, email, hashed_password) VALUES (?, ?, 'x')",
34+
(uid, f"u{uid}@example.com"),
35+
)
36+
db.conn.commit()
37+
application.state.db = db
38+
return application
39+
40+
41+
def _as_user(app, user_id):
42+
"""Override auth so requests act as ``user_id`` (None = no-auth mode)."""
43+
app.dependency_overrides[require_auth] = lambda: {"user_id": user_id}
44+
return TestClient(app, raise_server_exceptions=True)
45+
46+
47+
def _create(client, path="/tmp/ws"):
48+
resp = client.post("/api/v2/sessions", json={"workspace_path": path})
49+
assert resp.status_code == 201, resp.text
50+
return resp.json()["id"]
51+
52+
53+
class TestOwnerScoping:
54+
def test_list_only_returns_own_sessions(self, app):
55+
s1 = _create(_as_user(app, 1), "/tmp/a")
56+
_create(_as_user(app, 2), "/tmp/b")
57+
58+
ids = [s["id"] for s in _as_user(app, 1).get("/api/v2/sessions").json()]
59+
assert ids == [s1]
60+
61+
def test_get_other_users_session_404(self, app):
62+
other = _create(_as_user(app, 2))
63+
assert _as_user(app, 1).get(f"/api/v2/sessions/{other}").status_code == 404
64+
65+
def test_get_own_session_200(self, app):
66+
mine = _create(_as_user(app, 1))
67+
assert _as_user(app, 1).get(f"/api/v2/sessions/{mine}").status_code == 200
68+
69+
def test_delete_other_users_session_404(self, app):
70+
other = _create(_as_user(app, 2))
71+
client = _as_user(app, 1)
72+
assert client.delete(f"/api/v2/sessions/{other}").status_code == 404
73+
# And it is still active for its real owner.
74+
assert _as_user(app, 2).get(f"/api/v2/sessions/{other}").json()["state"] == "active"
75+
76+
def test_post_message_to_other_users_session_404(self, app):
77+
other = _create(_as_user(app, 2))
78+
resp = _as_user(app, 1).post(
79+
f"/api/v2/sessions/{other}/messages",
80+
json={"role": "user", "content": "hi"},
81+
)
82+
assert resp.status_code == 404
83+
84+
def test_get_messages_of_other_users_session_404(self, app):
85+
other = _create(_as_user(app, 2))
86+
assert (
87+
_as_user(app, 1).get(f"/api/v2/sessions/{other}/messages").status_code == 404
88+
)
89+
90+
91+
class TestNoAuthPassthrough:
92+
"""With auth off (user_id is None) ownership is not enforced — unchanged."""
93+
94+
def test_list_returns_all(self, app):
95+
_create(_as_user(app, None), "/tmp/a")
96+
_create(_as_user(app, None), "/tmp/b")
97+
assert len(_as_user(app, None).get("/api/v2/sessions").json()) == 2
98+
99+
def test_get_any_session(self, app):
100+
sid = _create(_as_user(app, None))
101+
assert _as_user(app, None).get(f"/api/v2/sessions/{sid}").status_code == 200

0 commit comments

Comments
 (0)