admin: sanitize model-upload paths + atomic auth.json read-modify-write

The model-upload endpoint joined a client-supplied filename straight onto
the cache dir, so an admin-authenticated request with a traversal filename
(or upload_id) could write outside it. Reduce both to a safe basename,
reject separators/.., and add a commonpath containment check before
committing the upload.

SessionManager only locked the write half of each load->mutate->save, so
concurrent writers could clobber each other's changes (lost sessions or
tokens). Add update_auth_data(mutator), which holds the lock across the
whole read-modify-write and persists only when the mutator asks to; route
every mutating method (and the token create/delete endpoints) through it.
Read-only callers keep the lock-free load since writes are atomic via
os.replace. While migrating the token endpoints, switch IDs to max+1 (no
reuse after deletion) and to timezone-aware timestamps.
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent 4754beff
...@@ -142,10 +142,9 @@ class SessionManager: ...@@ -142,10 +142,9 @@ class SessionManager:
pass pass
return {"users": [], "tokens": [], "sessions": {}} return {"users": [], "tokens": [], "sessions": {}}
def _save_auth_data(self, auth_data: Dict[str, Any]): def _write_auth_data(self, auth_data: Dict[str, Any]):
"""Save auth.json data atomically.""" """Write auth.json to disk atomically. Caller MUST hold ``self._lock``."""
auth_path = self.config_dir / "auth.json" auth_path = self.config_dir / "auth.json"
with self._lock:
import os, tempfile import os, tempfile
fd, tmp = tempfile.mkstemp(dir=str(self.config_dir), suffix='.tmp') fd, tmp = tempfile.mkstemp(dir=str(self.config_dir), suffix='.tmp')
try: try:
...@@ -159,6 +158,32 @@ class SessionManager: ...@@ -159,6 +158,32 @@ class SessionManager:
pass pass
raise raise
def _save_auth_data(self, auth_data: Dict[str, Any]):
"""Atomically persist a full auth_data snapshot (acquires the lock).
Prefer :meth:`update_auth_data` for read-modify-write sequences: a bare
load + mutate + save is not atomic and concurrent writers can clobber
each other's changes.
"""
with self._lock:
self._write_auth_data(auth_data)
def update_auth_data(self, mutator):
"""Atomic read-modify-write of auth.json under the instance lock.
``mutator(auth_data)`` receives the freshly loaded dict, mutates it in
place, and returns ``(should_save, result)``. The file is rewritten only
when ``should_save`` is truthy; ``result`` is returned to the caller.
Holding the lock across the whole load → mutate → write cycle serializes
concurrent writers so updates can't lose each other's changes.
"""
with self._lock:
auth_data = self._load_auth_data()
should_save, result = mutator(auth_data)
if should_save:
self._write_auth_data(auth_data)
return result
def create_session(self, username: str) -> str: def create_session(self, username: str) -> str:
"""Create a new session for a user. """Create a new session for a user.
...@@ -168,17 +193,15 @@ class SessionManager: ...@@ -168,17 +193,15 @@ class SessionManager:
session_id = secrets.token_urlsafe(32) session_id = secrets.token_urlsafe(32)
expires_at = utc_now() + self.session_timeout expires_at = utc_now() + self.session_timeout
auth_data = self._load_auth_data() def _mut(auth_data):
sessions = auth_data.setdefault("sessions", {})
# Update sessions dict
sessions = auth_data.get("sessions", {})
sessions[session_id] = { sessions[session_id] = {
"username": username, "username": username,
"created_at": utc_now().isoformat(), "created_at": utc_now().isoformat(),
"expires_at": expires_at.isoformat() "expires_at": expires_at.isoformat(),
} }
auth_data["sessions"] = sessions return True, None
self._save_auth_data(auth_data) self.update_auth_data(_mut)
# Create signed cookie value: session_id.signature # Create signed cookie value: session_id.signature
message = session_id.encode() message = session_id.encode()
...@@ -205,30 +228,21 @@ class SessionManager: ...@@ -205,30 +228,21 @@ class SessionManager:
if not hmac.compare_digest(signature, expected_sig): if not hmac.compare_digest(signature, expected_sig):
return None return None
# Check session in storage # Look up, expire-check, and extend the session atomically so a
auth_data = self._load_auth_data() # concurrent login or token write can't clobber the sessions map.
def _mut(auth_data):
sessions = auth_data.get("sessions", {}) sessions = auth_data.get("sessions", {})
session = sessions.get(session_id) session = sessions.get(session_id)
if not session: if not session:
return None return False, None
# Check expiration
expires_at = parse_session_timestamp(session["expires_at"]) expires_at = parse_session_timestamp(session["expires_at"])
if utc_now() > expires_at: if utc_now() > expires_at:
# Clean up expired session del sessions[session_id] # clean up expired session
del sessions[session_id] return True, None
auth_data["sessions"] = sessions
self._save_auth_data(auth_data)
return None
# Extend session (sliding expiration) # Extend session (sliding expiration)
new_expires = utc_now() + self.session_timeout session["expires_at"] = (utc_now() + self.session_timeout).isoformat()
session["expires_at"] = new_expires.isoformat() return True, session["username"]
auth_data["sessions"] = sessions return self.update_auth_data(_mut)
self._save_auth_data(auth_data)
return session["username"]
def destroy_session(self, cookie_value: Optional[str]): def destroy_session(self, cookie_value: Optional[str]):
"""Destroy a session.""" """Destroy a session."""
...@@ -240,12 +254,13 @@ class SessionManager: ...@@ -240,12 +254,13 @@ class SessionManager:
except ValueError: except ValueError:
return return
auth_data = self._load_auth_data() def _mut(auth_data):
sessions = auth_data.get("sessions", {}) sessions = auth_data.get("sessions", {})
if session_id in sessions: if session_id in sessions:
del sessions[session_id] del sessions[session_id]
auth_data["sessions"] = sessions return True, None
self._save_auth_data(auth_data) return False, None
self.update_auth_data(_mut)
def authenticate(self, username: str, password: str) -> Optional[str]: def authenticate(self, username: str, password: str) -> Optional[str]:
"""Authenticate a user and create a session. """Authenticate a user and create a session.
...@@ -273,19 +288,16 @@ class SessionManager: ...@@ -273,19 +288,16 @@ class SessionManager:
Returns: Returns:
True if successful, False otherwise True if successful, False otherwise
""" """
auth_data = self._load_auth_data() def _mut(auth_data):
for user in auth_data.get("users", []): for user in auth_data.get("users", []):
if user["username"] == username: if user["username"] == username:
if not verify_password(old_password, user["password_hash"]): if not verify_password(old_password, user["password_hash"]):
return False return False, False
user["password_hash"] = hash_password(new_password) user["password_hash"] = hash_password(new_password)
user["must_change_password"] = False user["must_change_password"] = False
self._save_auth_data(auth_data) return True, True
return True return False, False
return self.update_auth_data(_mut)
return False
def force_password_change(self, username: str, new_password: str) -> bool: def force_password_change(self, username: str, new_password: str) -> bool:
"""Force a user to change password (e.g., on first login). """Force a user to change password (e.g., on first login).
...@@ -293,17 +305,15 @@ class SessionManager: ...@@ -293,17 +305,15 @@ class SessionManager:
Returns: Returns:
True if successful, False otherwise True if successful, False otherwise
""" """
auth_data = self._load_auth_data() def _mut(auth_data):
for user in auth_data.get("users", []): for user in auth_data.get("users", []):
if user["username"] == username: if user["username"] == username:
user["password_hash"] = hash_password(new_password) user["password_hash"] = hash_password(new_password)
user["must_change_password"] = False user["must_change_password"] = False
user["last_changed_at"] = utc_now().isoformat() user["last_changed_at"] = utc_now().isoformat()
self._save_auth_data(auth_data) return True, True
return True return False, False
return self.update_auth_data(_mut)
return False
def get_user(self, username: str) -> Optional[Dict[str, Any]]: def get_user(self, username: str) -> Optional[Dict[str, Any]]:
"""Get user details.""" """Get user details."""
...@@ -337,16 +347,13 @@ class SessionManager: ...@@ -337,16 +347,13 @@ class SessionManager:
Returns: Returns:
True if successful, False if user already exists True if successful, False if user already exists
""" """
auth_data = self._load_auth_data() def _mut(auth_data):
# Check if user already exists # Check if user already exists
for user in auth_data.get("users", []): for user in auth_data.get("users", []):
if user["username"] == username: if user["username"] == username:
return False return False, False
# Find next available ID # Find next available ID
max_id = max([u["id"] for u in auth_data.get("users", [])], default=0) max_id = max([u["id"] for u in auth_data.get("users", [])], default=0)
new_user = { new_user = {
"id": max_id + 1, "id": max_id + 1,
"username": username, "username": username,
...@@ -355,10 +362,9 @@ class SessionManager: ...@@ -355,10 +362,9 @@ class SessionManager:
"created_at": utc_now().isoformat(), "created_at": utc_now().isoformat(),
"must_change_password": False "must_change_password": False
} }
auth_data.setdefault("users", []).append(new_user)
auth_data["users"].append(new_user) return True, True
self._save_auth_data(auth_data) return self.update_auth_data(_mut)
return True
def verify_token(self, token: str) -> bool: def verify_token(self, token: str) -> bool:
"""Verify an API bearer token.""" """Verify an API bearer token."""
...@@ -377,28 +383,22 @@ class SessionManager: ...@@ -377,28 +383,22 @@ class SessionManager:
Returns: Returns:
True if successful, False if user doesn't exist or is last admin True if successful, False if user doesn't exist or is last admin
""" """
auth_data = self._load_auth_data() def _mut(auth_data):
users = auth_data.get("users", []) users = auth_data.get("users", [])
# Check if last admin # Check if last admin
admin_count = sum(1 for u in users if u.get("role") == "admin") admin_count = sum(1 for u in users if u.get("role") == "admin")
user = next((u for u in users if u["username"] == username), None) user = next((u for u in users if u["username"] == username), None)
if not user: if not user:
return False return False, False
if user.get("role") == "admin" and admin_count <= 1: if user.get("role") == "admin" and admin_count <= 1:
return False # Can't delete last admin return False, False # Can't delete last admin
# Remove user # Remove user
auth_data["users"] = [u for u in users if u["username"] != username] auth_data["users"] = [u for u in users if u["username"] != username]
# Remove user's sessions # Remove user's sessions
sessions = auth_data.get("sessions", {}) sessions = auth_data.get("sessions", {})
auth_data["sessions"] = { auth_data["sessions"] = {
k: v for k, v in sessions.items() k: v for k, v in sessions.items()
if v["username"] != username if v["username"] != username
} }
return True, True
self._save_auth_data(auth_data) return self.update_auth_data(_mut)
return True
...@@ -702,22 +702,25 @@ async def api_create_token(request: Request, username: str = Depends(require_adm ...@@ -702,22 +702,25 @@ async def api_create_token(request: Request, username: str = Depends(require_adm
if not name: if not name:
raise HTTPException(status_code=400, detail="Token name is required") raise HTTPException(status_code=400, detail="Token name is required")
auth_data = session_manager._load_auth_data()
# Generate token
token_id = len(auth_data.get("tokens", [])) + 1
import secrets import secrets
from datetime import datetime, timezone
def _mut(auth_data):
tokens = auth_data.setdefault("tokens", [])
# max+1 (not len+1) so IDs aren't reused after a deletion.
token_id = max([t.get("id", 0) for t in tokens], default=0) + 1
new_token = { new_token = {
"id": token_id, "id": token_id,
"name": name, "name": name,
"token": f"sk-coderai-{secrets.token_hex(32)}", "token": f"sk-coderai-{secrets.token_hex(32)}",
"provider": provider, "provider": provider,
"created_at": datetime.utcnow().isoformat() + "Z", "created_at": datetime.now(timezone.utc).isoformat(),
"last_used": None "last_used": None,
} }
tokens.append(new_token)
return True, new_token
auth_data.setdefault("tokens", []).append(new_token) new_token = session_manager.update_auth_data(_mut)
session_manager._save_auth_data(auth_data)
return { return {
"token": new_token["token"], "token": new_token["token"],
...@@ -730,15 +733,16 @@ async def api_create_token(request: Request, username: str = Depends(require_adm ...@@ -730,15 +733,16 @@ async def api_create_token(request: Request, username: str = Depends(require_adm
@router.delete("/admin/api/tokens/{token_id}", summary="Revoke an API token") @router.delete("/admin/api/tokens/{token_id}", summary="Revoke an API token")
async def api_delete_token(token_id: int, username: str = Depends(require_admin)): async def api_delete_token(token_id: int, username: str = Depends(require_admin)):
"""Delete an API token.""" """Delete an API token."""
auth_data = session_manager._load_auth_data() def _mut(auth_data):
tokens = auth_data.get("tokens", []) tokens = auth_data.get("tokens", [])
new_tokens = [t for t in tokens if t["id"] != token_id] new_tokens = [t for t in tokens if t["id"] != token_id]
if len(new_tokens) == len(tokens): if len(new_tokens) == len(tokens):
raise HTTPException(status_code=404, detail="Token not found") return False, False # not found → don't write
auth_data["tokens"] = new_tokens auth_data["tokens"] = new_tokens
session_manager._save_auth_data(auth_data) return True, True
if not session_manager.update_auth_data(_mut):
raise HTTPException(status_code=404, detail="Token not found")
return {"success": True} return {"success": True}
...@@ -1300,9 +1304,18 @@ async def api_model_upload(request: Request, username: str = Depends(require_adm ...@@ -1300,9 +1304,18 @@ async def api_model_upload(request: Request, username: str = Depends(require_adm
if not chunk or not hasattr(chunk, "read"): if not chunk or not hasattr(chunk, "read"):
raise HTTPException(status_code=400, detail="No file chunk provided") raise HTTPException(status_code=400, detail="No file chunk provided")
# Reject path-traversal in the client-supplied filename: it must be a bare
# name that stays inside the model cache directory.
filename = os.path.basename(str(filename))
if not filename or filename in (".", "..") or "/" in filename or "\\" in filename:
raise HTTPException(status_code=400, detail="Invalid filename")
cache_dir = get_model_cache_dir() cache_dir = get_model_cache_dir()
temp_dir = tempfile.gettempdir() temp_dir = tempfile.gettempdir()
upload_id = form.get("upload_id", filename) # upload_id only ever names a temp scratch file; derive a safe slug from it
# so it can't escape temp_dir or collide via traversal either.
upload_id = os.path.basename(str(form.get("upload_id", filename))) or filename
upload_id = re.sub(r"[^A-Za-z0-9._-]", "_", upload_id)
temp_path = os.path.join(temp_dir, f"upload_{upload_id}.part") temp_path = os.path.join(temp_dir, f"upload_{upload_id}.part")
# Append chunk # Append chunk
...@@ -1313,6 +1326,15 @@ async def api_model_upload(request: Request, username: str = Depends(require_adm ...@@ -1313,6 +1326,15 @@ async def api_model_upload(request: Request, username: str = Depends(require_adm
# If last chunk, move to final location # If last chunk, move to final location
if chunk_index == total_chunks - 1: if chunk_index == total_chunks - 1:
final_path = os.path.join(cache_dir, filename) final_path = os.path.join(cache_dir, filename)
# Belt-and-suspenders: ensure the resolved destination is still inside
# the cache directory before committing the upload.
if os.path.commonpath([os.path.realpath(final_path),
os.path.realpath(cache_dir)]) != os.path.realpath(cache_dir):
try:
os.remove(temp_path)
except OSError:
pass
raise HTTPException(status_code=400, detail="Invalid destination path")
os.replace(temp_path, final_path) os.replace(temp_path, final_path)
return {"success": True, "complete": True, "path": final_path} return {"success": True, "complete": True, "path": final_path}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment