Skip to content

Commit 371489b

Browse files
authored
fix(security): workspace path allowlist + session ownership (#655) (#705)
* fix(security): workspace path allowlist + session ownership (#655) Prevent authenticated cross-tenant RCE by constraining every client-supplied workspace path to a configurable allowlist (WORKSPACE_ROOT), and binding interactive sessions to their creating user. - enforce_workspace_allowlist() (ui/dependencies.py): resolved path must be within a WORKSPACE_ROOT entry; '..' escapes collapse via resolve(). In HOSTED mode the allowlist is mandatory (fail closed) and each user is confined to <root>/<user_id>. No-op when WORKSPACE_ROOT is unset (single-operator default). - Applied at all client-supplied-path funnels: get_v2_workspace (REST), POST /api/v2/workspaces (init), and POST /api/v2/sessions (create). - interactive_sessions gains a user_id column (+ idempotent ALTER migration); create_session persists the authed user. terminal_ws/session_chat_ws now fail closed: when auth is enabled an ownerless or mismatched session is rejected. Closes the RCE foundation; remaining hosted multi-tenancy hardening (session REST owner-scoping, TOCTOU symlink isolation) tracked as follow-up. * test(security): assert WebSocketDisconnect(1008) on chat ownership mismatch (#655) CodeRabbit: tighten the broad pytest.raises(Exception) to the specific WebSocketDisconnect with the policy-violation close code. * docs: document WORKSPACE_ROOT allowlist + deployment mode env vars (#655)
1 parent 45c51f0 commit 371489b

10 files changed

Lines changed: 388 additions & 20 deletions

File tree

CLAUDE.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,16 @@ ANTHROPIC_API_KEY=sk-ant-... # Required for Anthropic provider (default
267267
E2B_API_KEY=e2b_... # Required for --engine cloud
268268
DATABASE_PATH=./codeframe.db # Optional
269269

270+
# Server deployment + workspace allowlist (#655)
271+
CODEFRAME_DEPLOYMENT_MODE=self_hosted # self_hosted (default) or hosted (multi-tenant)
272+
WORKSPACE_ROOT=/srv/workspaces # os.pathsep-separated allowlist of permitted
273+
# workspace roots. Any client-supplied workspace
274+
# path (REST, workspace init, session create) must
275+
# resolve inside a root, else 403. Unset = no
276+
# allowlist (single-operator self-hosted default).
277+
# In hosted mode it is MANDATORY (fail closed) and
278+
# each user is confined to <root>/<user_id>.
279+
270280
# LLM Provider selection (multi-provider support)
271281
# Priority: CLI flag > env var > .codeframe/config.yaml > default (anthropic)
272282
CODEFRAME_LLM_PROVIDER=anthropic # Provider: anthropic (default), openai, ollama, vllm, compatible

codeframe/platform_store/repositories/interactive_sessions.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,19 @@ def create(
2323
task_id: Optional[str] = None,
2424
agent_type: str = "claude",
2525
model: Optional[str] = None,
26+
user_id: Optional[int] = None,
2627
) -> dict:
2728
now = datetime.now(UTC).isoformat()
2829
session_id = str(uuid.uuid4())
2930
self._execute(
3031
"""
3132
INSERT INTO interactive_sessions
3233
(id, workspace_path, task_id, state, agent_type, model,
33-
cost_usd, input_tokens, output_tokens, created_at, updated_at, ended_at)
34-
VALUES (?, ?, ?, 'active', ?, ?, 0.0, 0, 0, ?, ?, NULL)
34+
cost_usd, input_tokens, output_tokens, created_at, updated_at,
35+
ended_at, user_id)
36+
VALUES (?, ?, ?, 'active', ?, ?, 0.0, 0, 0, ?, ?, NULL, ?)
3537
""",
36-
(session_id, workspace_path, task_id, agent_type, model, now, now),
38+
(session_id, workspace_path, task_id, agent_type, model, now, now, user_id),
3739
)
3840
self._commit()
3941
return self.get(session_id)

codeframe/platform_store/schema_manager.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,10 +179,23 @@ def _create_interactive_session_tables(self, cursor: sqlite3.Cursor) -> None:
179179
output_tokens INTEGER DEFAULT 0,
180180
created_at TEXT NOT NULL,
181181
updated_at TEXT NOT NULL,
182-
ended_at TEXT
182+
ended_at TEXT,
183+
-- Owning user; the terminal/chat WebSockets reject a session
184+
-- whose owner != the authenticated user (issue #655). NULL in
185+
-- no-auth mode, where ownership is intentionally not enforced.
186+
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL
183187
)
184188
"""
185189
)
190+
# Migrate pre-#655 databases that created the table without user_id.
191+
existing_cols = {
192+
row[1] for row in cursor.execute("PRAGMA table_info(interactive_sessions)")
193+
}
194+
if "user_id" not in existing_cols:
195+
cursor.execute(
196+
"ALTER TABLE interactive_sessions ADD COLUMN user_id INTEGER "
197+
"REFERENCES users(id) ON DELETE SET NULL"
198+
)
186199

187200
cursor.execute(
188201
"""

codeframe/ui/dependencies.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,79 @@
66
v2-only: All dependencies use codeframe.core modules.
77
"""
88

9+
import os
910
from pathlib import Path
10-
from typing import Optional
11+
from typing import Any, Dict, Optional
1112

12-
from fastapi import HTTPException, Query, Request
13+
from fastapi import Depends, HTTPException, Query, Request
1314

15+
from codeframe.auth.dependencies import require_auth
1416
from codeframe.workspace import WorkspaceManager
1517

1618
# v2 imports
1719
from codeframe.core.workspace import Workspace, get_workspace, workspace_exists
1820

1921

22+
def _allowed_workspace_roots() -> list[Path]:
23+
"""Permitted workspace roots from ``WORKSPACE_ROOT`` (os.pathsep-separated).
24+
25+
Empty when unset — meaning "no allowlist" (single-operator self-hosted, the
26+
default). Each root is resolved so containment checks defeat ``..`` escapes.
27+
"""
28+
raw = os.getenv("WORKSPACE_ROOT", "").strip()
29+
if not raw:
30+
return []
31+
return [
32+
Path(p).expanduser().resolve()
33+
for p in raw.split(os.pathsep)
34+
if p.strip()
35+
]
36+
37+
38+
def _within_any_root(path: Path, roots: list[Path]) -> bool:
39+
return any(path == r or path.is_relative_to(r) for r in roots)
40+
41+
42+
def enforce_workspace_allowlist(path: Path, user_id: Optional[int]) -> Path:
43+
"""Validate a resolved workspace path against the allowlist (issue #655).
44+
45+
Shared by every entry point that resolves a client-supplied workspace path:
46+
``get_v2_workspace`` (REST) and interactive session creation (whose stored
47+
path later becomes a terminal shell's ``cwd``). Without it, an authenticated
48+
user can point operations at any host directory — authenticated
49+
cross-tenant RCE once the server serves >1 user.
50+
51+
Returns the (resolved) path on success; raises ``HTTPException`` otherwise.
52+
"""
53+
# Local import avoids a circular import (server -> routers -> dependencies).
54+
from codeframe.ui.server import is_hosted_mode
55+
56+
path = path.resolve()
57+
roots = _allowed_workspace_roots()
58+
if is_hosted_mode():
59+
# Hosted/multi-tenant: the allowlist is mandatory (fail closed) and each
60+
# user is confined to <root>/<user_id> so one tenant can't reach
61+
# another's subtree.
62+
# ponytail: path-namespace binding, not a DB ownership table. Upgrade to
63+
# registry-backed owner_user_id checks if workspaces ever live outside a
64+
# per-user root.
65+
if not roots:
66+
raise HTTPException(
67+
status_code=500,
68+
detail="Server misconfigured: WORKSPACE_ROOT must be set in hosted mode.",
69+
)
70+
if user_id is None:
71+
raise HTTPException(status_code=403, detail="Authenticated user required.")
72+
roots = [r / str(user_id) for r in roots]
73+
74+
if roots and not _within_any_root(path, roots):
75+
raise HTTPException(
76+
status_code=403,
77+
detail="Workspace path is outside the permitted workspace roots.",
78+
)
79+
return path
80+
81+
2082
def get_workspace_manager(request: Request) -> WorkspaceManager:
2183
"""Get workspace manager from application state.
2284
@@ -41,6 +103,7 @@ def get_v2_workspace(
41103
description="Path to workspace directory (defaults to server's working directory)",
42104
),
43105
request: Request = None,
106+
auth: Dict[str, Any] = Depends(require_auth),
44107
) -> Workspace:
45108
"""Get v2 Workspace from path or server default.
46109
@@ -76,6 +139,9 @@ async def endpoint(workspace: Workspace = Depends(get_v2_workspace)):
76139
# Fall back to current working directory
77140
path = Path.cwd()
78141

142+
# Enforce the workspace allowlist (issue #655).
143+
path = enforce_workspace_allowlist(path, auth.get("user_id"))
144+
79145
# Validate workspace exists
80146
# Note: Avoid exposing full filesystem paths in error messages for hosted deployments
81147
if not workspace_exists(path):
@@ -100,4 +166,5 @@ async def endpoint(workspace: Workspace = Depends(get_v2_workspace)):
100166
__all__ = [
101167
"get_workspace_manager",
102168
"get_v2_workspace",
169+
"enforce_workspace_allowlist",
103170
]

codeframe/ui/routers/interactive_sessions_v2.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,21 @@
1010
POST /api/v2/sessions/{id}/messages - Add message to session
1111
GET /api/v2/sessions/{id}/messages - Get message history (?limit=&offset=)
1212
13-
Auth note: auth is a project-wide gap tracked in #336. All other v2 routers also
14-
lack auth — adding it only here would be inconsistent with the rest of the API.
13+
Auth: enforced project-wide via router-level ``require_auth`` (#336). Session
14+
creation additionally validates ``workspace_path`` against the workspace
15+
allowlist (#655) since the stored path becomes a terminal shell ``cwd``.
1516
"""
1617

1718
import logging
18-
from typing import Optional
19+
from pathlib import Path
20+
from typing import Any, Dict, Optional
1921

20-
from fastapi import APIRouter, HTTPException, Query, Request
22+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
2123
from pydantic import BaseModel, field_validator
2224

25+
from codeframe.auth.dependencies import require_auth
2326
from codeframe.lib.rate_limiter import rate_limit_standard
27+
from codeframe.ui.dependencies import enforce_workspace_allowlist
2428

2529
logger = logging.getLogger(__name__)
2630

@@ -136,14 +140,27 @@ def _session_to_response(row: dict) -> SessionResponse:
136140

137141
@router.post("", status_code=201, response_model=SessionResponse)
138142
@rate_limit_standard()
139-
def create_session(body: SessionCreate, request: Request):
140-
"""Create a new interactive agent session."""
143+
def create_session(
144+
body: SessionCreate,
145+
request: Request,
146+
auth: Dict[str, Any] = Depends(require_auth),
147+
):
148+
"""Create a new interactive agent session.
149+
150+
The stored ``workspace_path`` later becomes a terminal shell's ``cwd``
151+
(terminal_ws / session_chat_ws), so it must clear the same allowlist as
152+
REST workspace access (issue #655) — validated and resolved here.
153+
"""
141154
repo = _get_repo(request)
155+
workspace_path = enforce_workspace_allowlist(
156+
Path(body.workspace_path), auth.get("user_id")
157+
)
142158
session = repo.create(
143-
workspace_path=body.workspace_path,
159+
workspace_path=str(workspace_path),
144160
task_id=body.task_id,
145161
agent_type=body.agent_type,
146162
model=body.model,
163+
user_id=auth.get("user_id"),
147164
)
148165
return _session_to_response(session)
149166

codeframe/ui/routers/session_chat_ws.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ async def _run_streaming_adapter(
108108
async def session_chat_ws(session_id: str, websocket: WebSocket) -> None:
109109
"""Bidirectional WebSocket for streaming agent chat on an interactive session."""
110110
# --- Auth ---
111-
authenticated, _user_id = await _authenticate_websocket(websocket)
111+
authenticated, user_id = await _authenticate_websocket(websocket)
112112
if not authenticated:
113113
return
114114

@@ -123,6 +123,19 @@ async def session_chat_ws(session_id: str, websocket: WebSocket) -> None:
123123
await websocket.close(code=4008, reason="Session not found or ended")
124124
return
125125

126+
# --- Ownership check (issue #655) ---
127+
# The chat agent runs file-system tools scoped to the session's workspace, so
128+
# one authenticated user must not drive another user's session. Skipped in
129+
# no-auth mode (user_id is None), matching terminal_ws / REST behavior.
130+
# Fail closed: when auth is enabled (user_id is not None), an ownerless
131+
# (NULL, e.g. pre-#655 migrated) or mismatched session is rejected.
132+
session_user_id = session.get("user_id")
133+
if user_id is not None and (
134+
session_user_id is None or int(session_user_id) != user_id
135+
):
136+
await websocket.close(code=1008, reason="Forbidden: session belongs to another user")
137+
return
138+
126139
# --- Accept connection; everything after this point must run inside the
127140
# try/finally so unregister() and close() always execute even if
128141
# update_state, register, or get_token_queue raises. ---

codeframe/ui/routers/terminal_ws.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,13 @@ async def session_terminal_ws(session_id: str, websocket: WebSocket) -> None:
7676

7777
# --- Ownership check ---
7878
# Skipped in no-auth mode (user_id is None): there is no identity to enforce,
79-
# matching REST behavior under CODEFRAME_AUTH_REQUIRED=false.
79+
# matching REST behavior under CODEFRAME_AUTH_REQUIRED=false. When auth IS
80+
# enabled, fail closed — an ownerless (NULL, e.g. pre-#655 migrated) or
81+
# mismatched session is rejected.
8082
session_user_id = session.get("user_id")
81-
if user_id is not None and session_user_id is not None and int(session_user_id) != user_id:
83+
if user_id is not None and (
84+
session_user_id is None or int(session_user_id) != user_id
85+
):
8286
await websocket.close(code=4003, reason="Forbidden: session belongs to another user")
8387
return
8488

codeframe/ui/routers/workspace_v2.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919

2020
from codeframe.core import workspace as ws
2121
from codeframe.lib.rate_limiter import rate_limit_standard
22-
from codeframe.ui.dependencies import get_v2_workspace
22+
from codeframe.auth.dependencies import require_auth
23+
from codeframe.ui.dependencies import enforce_workspace_allowlist, get_v2_workspace
2324
from codeframe.core.workspace import WORKSPACE_CONFIG_FILENAME, Workspace
2425
from codeframe.ui.response_models import api_error, ErrorCodes
2526
from codeframe.ui.routers._helpers import atomic_write_json
@@ -225,6 +226,7 @@ async def list_workspaces(request: Request) -> WorkspaceListResponse:
225226
async def init_workspace(
226227
request: Request,
227228
body: InitWorkspaceRequest,
229+
auth: dict = Depends(require_auth),
228230
) -> WorkspaceResponse:
229231
"""Initialize a new workspace for a repository.
230232
@@ -244,7 +246,13 @@ async def init_workspace(
244246
- 404: Repository path not found
245247
"""
246248
try:
247-
repo_path = Path(body.repo_path).resolve()
249+
# Enforce the workspace allowlist before touching disk (issue #655):
250+
# initializing a workspace writes a .codeframe/ marker, so an arbitrary
251+
# repo_path would let an authenticated user seed workspaces anywhere on
252+
# the host.
253+
repo_path = enforce_workspace_allowlist(
254+
Path(body.repo_path), auth.get("user_id")
255+
)
248256

249257
# Validate path exists
250258
if not repo_path.exists():

tests/api/test_session_chat_ws.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
and explicit teardown within tests where needed.
1010
"""
1111

12+
import os
13+
1214
import pytest
1315
from unittest.mock import patch
1416
from fastapi.testclient import TestClient
@@ -25,8 +27,14 @@
2527
# ---------------------------------------------------------------------------
2628

2729

28-
def _create_session(client: TestClient, workspace_path: str = "/tmp/ws-test") -> str:
29-
"""Create an interactive session and return its ID."""
30+
def _create_session(client: TestClient, workspace_path: str | None = None) -> str:
31+
"""Create an interactive session and return its ID.
32+
33+
Defaults to a path under the test ``WORKSPACE_ROOT`` (set by the api
34+
conftest) so it clears the workspace allowlist (#655).
35+
"""
36+
if workspace_path is None:
37+
workspace_path = os.path.join(os.environ.get("WORKSPACE_ROOT", "/tmp"), "ws-test")
3038
resp = client.post(
3139
"/api/v2/sessions",
3240
json={"workspace_path": workspace_path, "agent_type": "claude"},
@@ -39,6 +47,36 @@ def _ws_url(session_id: str, token: str) -> str:
3947
return f"/ws/sessions/{session_id}/chat?token={token}"
4048

4149

50+
def test_chat_ws_ownership_mismatch_closes():
51+
"""A session owned by another user is refused (issue #655)."""
52+
from unittest.mock import AsyncMock, MagicMock, patch
53+
54+
from fastapi import FastAPI
55+
from fastapi.testclient import TestClient
56+
57+
from codeframe.ui.routers.session_chat_ws import router
58+
59+
app = FastAPI()
60+
app.include_router(router)
61+
fake_db = MagicMock()
62+
fake_db.interactive_sessions.get.return_value = {
63+
"state": "active",
64+
"workspace_path": "/tmp",
65+
"user_id": 999,
66+
}
67+
app.state.db = fake_db
68+
client = TestClient(app)
69+
70+
with patch(
71+
"codeframe.ui.routers.session_chat_ws._authenticate_websocket",
72+
new=AsyncMock(return_value=(True, 1)),
73+
):
74+
with pytest.raises(WebSocketDisconnect) as exc:
75+
with client.websocket_connect("/ws/sessions/s1/chat?token=x"):
76+
pass
77+
assert exc.value.code == 1008
78+
79+
4280
# ---------------------------------------------------------------------------
4381
# Auth tests
4482
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)