Skip to content

Commit 7c33779

Browse files
committed
Security fix
1 parent 5f1646a commit 7c33779

4 files changed

Lines changed: 79 additions & 38 deletions

File tree

claude_code_api/api/projects.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""Projects API endpoint - Extension to OpenAI API."""
22

33
import math
4-
import os
54
import uuid
65

76
import structlog
@@ -15,7 +14,7 @@
1514
)
1615
from claude_code_api.core.config import settings
1716
from claude_code_api.core.database import db_manager
18-
from claude_code_api.core.security import validate_path
17+
from claude_code_api.core.security import ensure_directory_within_base
1918
from claude_code_api.models.openai import (
2019
CreateProjectRequest,
2120
PaginatedResponse,
@@ -74,10 +73,9 @@ async def create_project(
7473

7574
# Create project directory
7675
if project_request.path:
77-
# Validate path
78-
project_path = validate_path(project_request.path, settings.project_root)
79-
80-
os.makedirs(project_path, exist_ok=True)
76+
project_path = ensure_directory_within_base(
77+
project_request.path, settings.project_root
78+
)
8179
else:
8280
project_path = create_project_directory(project_id)
8381

claude_code_api/core/claude_manager.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from claude_code_api.models.claude import get_default_model
1212

1313
from .config import settings
14+
from .security import ensure_directory_within_base
1415

1516
logger = structlog.get_logger()
1617

@@ -396,9 +397,7 @@ def _cleanup_process(self, process: ClaudeProcess):
396397
# Utility functions for project management
397398
def create_project_directory(project_id: str) -> str:
398399
"""Create project directory."""
399-
project_path = os.path.join(settings.project_root, project_id)
400-
os.makedirs(project_path, exist_ok=True)
401-
return project_path
400+
return ensure_directory_within_base(project_id, settings.project_root)
402401

403402

404403
def cleanup_project_directory(project_path: str):

claude_code_api/core/security.py

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
11
"""Security utilities."""
22

33
import os
4+
from pathlib import Path
45

56
import structlog
67
from fastapi import HTTPException, status
78

89
logger = structlog.get_logger()
910

1011

11-
def validate_path(path: str, base_path: str) -> str:
12+
def resolve_path_within_base(path: str, base_path: str) -> str:
1213
"""
13-
Validate that a path is safe and within the base path.
14-
Prevents directory traversal attacks.
14+
Resolve a user-provided path within a base directory.
15+
Prevents directory traversal and symlink escapes.
1516
1617
Args:
17-
path: The path to validate (can be absolute or relative)
18+
path: The path to resolve (can be absolute or relative)
1819
base_path: The allowed base directory
1920
2021
Returns:
@@ -24,36 +25,71 @@ def validate_path(path: str, base_path: str) -> str:
2425
HTTPException: If path is invalid or outside base_path
2526
"""
2627
try:
27-
# Normalize base path to absolute path
28-
abs_base_path = os.path.abspath(base_path)
29-
30-
# Handle relative paths by joining with base_path
31-
if not os.path.isabs(path):
32-
abs_path = os.path.abspath(os.path.join(abs_base_path, path))
33-
else:
34-
abs_path = os.path.abspath(path)
35-
36-
# Check if path is within base_path
37-
# os.path.commonpath returns the longest common sub-path
38-
# If valid, commonpath should be equal to base_path
39-
if os.path.commonpath([abs_base_path, abs_path]) != abs_base_path:
28+
if path is None or not str(path).strip():
29+
raise HTTPException(
30+
status_code=status.HTTP_400_BAD_REQUEST,
31+
detail="Invalid path: Path is required",
32+
)
33+
if "\x00" in str(path):
34+
raise HTTPException(
35+
status_code=status.HTTP_400_BAD_REQUEST,
36+
detail="Invalid path: Null byte detected",
37+
)
38+
39+
abs_base_path = Path(base_path).resolve()
40+
candidate_path = Path(path)
41+
if not candidate_path.is_absolute():
42+
candidate_path = abs_base_path / candidate_path
43+
44+
resolved_path = candidate_path.resolve(strict=False)
45+
46+
if not resolved_path.is_relative_to(abs_base_path):
4047
logger.warning(
4148
"Path traversal attempt detected",
4249
path=path,
43-
resolved_path=abs_path,
44-
base_path=abs_base_path,
50+
resolved_path=str(resolved_path),
51+
base_path=str(abs_base_path),
4552
)
4653
raise HTTPException(
4754
status_code=status.HTTP_400_BAD_REQUEST,
4855
detail="Invalid path: Path traversal detected",
4956
)
5057

51-
return abs_path
58+
return str(resolved_path)
5259

5360
except HTTPException:
5461
raise
5562
except Exception as e:
5663
logger.error("Path validation error", error=str(e), path=path)
5764
raise HTTPException(
58-
status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid path: {str(e)}"
65+
status_code=status.HTTP_400_BAD_REQUEST,
66+
detail="Invalid path: Path validation failed",
5967
)
68+
69+
70+
def ensure_directory_within_base(
71+
path: str, base_path: str, *, allow_subpaths: bool = True
72+
) -> str:
73+
"""Validate a path within base_path and create the directory."""
74+
path_value = os.fspath(path)
75+
if not allow_subpaths:
76+
if os.path.isabs(path_value):
77+
raise HTTPException(
78+
status_code=status.HTTP_400_BAD_REQUEST,
79+
detail="Invalid path: Absolute paths are not allowed",
80+
)
81+
for sep in (os.path.sep, os.path.altsep):
82+
if sep and sep in path_value:
83+
raise HTTPException(
84+
status_code=status.HTTP_400_BAD_REQUEST,
85+
detail="Invalid path: Path separators are not allowed",
86+
)
87+
88+
resolved_path = resolve_path_within_base(path_value, base_path)
89+
os.makedirs(resolved_path, exist_ok=True)
90+
return resolved_path
91+
92+
93+
def validate_path(path: str, base_path: str) -> str:
94+
"""Backward-compatible wrapper for path resolution."""
95+
return resolve_path_within_base(path, base_path)

claude_code_api/utils/streaming.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,8 @@ async def convert_stream(
150150
yield SSEFormatter.format_completion("")
151151

152152
except Exception as e:
153-
logger.error("Error in stream conversion", error=str(e))
154-
yield SSEFormatter.format_error(f"Stream error: {str(e)}")
153+
logger.error("Error in stream conversion", error=str(e), exc_info=True)
154+
yield SSEFormatter.format_error("Stream error")
155155

156156
def get_final_response(self) -> Dict[str, Any]:
157157
"""Get complete response in OpenAI format."""
@@ -198,8 +198,10 @@ async def create_stream(
198198
heartbeat_task.cancel()
199199

200200
except Exception as e:
201-
logger.error("Streaming error", session_id=session_id, error=str(e))
202-
yield SSEFormatter.format_error(f"Streaming failed: {str(e)}")
201+
logger.error(
202+
"Streaming error", session_id=session_id, error=str(e), exc_info=True
203+
)
204+
yield SSEFormatter.format_error("Streaming failed")
203205
finally:
204206
# Cleanup
205207
if session_id in self.active_streams:
@@ -305,10 +307,16 @@ async def create_sse_response(
305307
session_id: str, model: str, claude_process: ClaudeProcess
306308
) -> AsyncGenerator[str, None]:
307309
"""Create SSE response for Claude Code output."""
308-
async for chunk in streaming_manager.create_stream(
309-
session_id, model, claude_process
310-
):
311-
yield chunk
310+
try:
311+
async for chunk in streaming_manager.create_stream(
312+
session_id, model, claude_process
313+
):
314+
yield chunk
315+
except Exception as e:
316+
logger.error(
317+
"SSE response error", session_id=session_id, error=str(e), exc_info=True
318+
)
319+
yield SSEFormatter.format_error("Stream error")
312320

313321

314322
def _extract_assistant_payload(

0 commit comments

Comments
 (0)