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,22 +142,47 @@ class SessionManager: ...@@ -142,22 +142,47 @@ 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:
with os.fdopen(fd, 'w') as f:
json.dump(auth_data, f, indent=2)
os.replace(tmp, str(auth_path))
except Exception:
try: try:
with os.fdopen(fd, 'w') as f: os.unlink(tmp)
json.dump(auth_data, f, indent=2) except OSError:
os.replace(tmp, str(auth_path)) pass
except Exception: raise
try:
os.unlink(tmp) def _save_auth_data(self, auth_data: Dict[str, Any]):
except OSError: """Atomically persist a full auth_data snapshot (acquires the lock).
pass
raise 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.
...@@ -167,19 +192,17 @@ class SessionManager: ...@@ -167,19 +192,17 @@ 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[session_id] = {
sessions = auth_data.get("sessions", {}) "username": username,
sessions[session_id] = { "created_at": utc_now().isoformat(),
"username": username, "expires_at": expires_at.isoformat(),
"created_at": utc_now().isoformat(), }
"expires_at": expires_at.isoformat() return True, None
} self.update_auth_data(_mut)
auth_data["sessions"] = sessions
self._save_auth_data(auth_data)
# Create signed cookie value: session_id.signature # Create signed cookie value: session_id.signature
message = session_id.encode() message = session_id.encode()
signature = hmac.new(self.secret, message, hashlib.sha256).hexdigest() signature = hmac.new(self.secret, message, hashlib.sha256).hexdigest()
...@@ -204,31 +227,22 @@ class SessionManager: ...@@ -204,31 +227,22 @@ class SessionManager:
expected_sig = hmac.new(self.secret, message, hashlib.sha256).hexdigest() expected_sig = hmac.new(self.secret, message, hashlib.sha256).hexdigest()
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.
sessions = auth_data.get("sessions", {}) def _mut(auth_data):
session = sessions.get(session_id) sessions = auth_data.get("sessions", {})
session = sessions.get(session_id)
if not session: if not session:
return None return False, None
expires_at = parse_session_timestamp(session["expires_at"])
# Check expiration if utc_now() > expires_at:
expires_at = parse_session_timestamp(session["expires_at"]) del sessions[session_id] # clean up expired session
if utc_now() > expires_at: return True, None
# Clean up expired session # Extend session (sliding expiration)
del sessions[session_id] session["expires_at"] = (utc_now() + self.session_timeout).isoformat()
auth_data["sessions"] = sessions return True, session["username"]
self._save_auth_data(auth_data) return self.update_auth_data(_mut)
return None
# Extend session (sliding expiration)
new_expires = utc_now() + self.session_timeout
session["expires_at"] = new_expires.isoformat()
auth_data["sessions"] = sessions
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."""
...@@ -239,13 +253,14 @@ class SessionManager: ...@@ -239,13 +253,14 @@ class SessionManager:
session_id, _ = cookie_value.rsplit('.', 1) session_id, _ = cookie_value.rsplit('.', 1)
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,37 +288,32 @@ class SessionManager: ...@@ -273,37 +288,32 @@ 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, False
return False user["password_hash"] = hash_password(new_password)
user["must_change_password"] = False
user["password_hash"] = hash_password(new_password) return True, True
user["must_change_password"] = False return False, False
self._save_auth_data(auth_data) return self.update_auth_data(_mut)
return True
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).
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() return True, True
self._save_auth_data(auth_data) return False, False
return True 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,28 +347,24 @@ class SessionManager: ...@@ -337,28 +347,24 @@ 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, False
return False # Find next available ID
max_id = max([u["id"] for u in auth_data.get("users", [])], default=0)
# Find next available ID new_user = {
max_id = max([u["id"] for u in auth_data.get("users", [])], default=0) "id": max_id + 1,
"username": username,
new_user = { "password_hash": hash_password(password),
"id": max_id + 1, "role": role,
"username": username, "created_at": utc_now().isoformat(),
"password_hash": hash_password(password), "must_change_password": False
"role": role, }
"created_at": utc_now().isoformat(), auth_data.setdefault("users", []).append(new_user)
"must_change_password": False return True, True
} return self.update_auth_data(_mut)
auth_data["users"].append(new_user)
self._save_auth_data(auth_data)
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:
return False, False
if not user: if user.get("role") == "admin" and admin_count <= 1:
return False return False, False # Can't delete last admin
# Remove user
if user.get("role") == "admin" and admin_count <= 1: auth_data["users"] = [u for u in users if u["username"] != username]
return False # Can't delete last admin # Remove user's sessions
sessions = auth_data.get("sessions", {})
# Remove user auth_data["sessions"] = {
auth_data["users"] = [u for u in users if u["username"] != username] k: v for k, v in sessions.items()
if v["username"] != username
# Remove user's sessions }
sessions = auth_data.get("sessions", {}) return True, True
auth_data["sessions"] = { return self.update_auth_data(_mut)
k: v for k, v in sessions.items()
if v["username"] != username
}
self._save_auth_data(auth_data)
return True
...@@ -701,24 +701,27 @@ async def api_create_token(request: Request, username: str = Depends(require_adm ...@@ -701,24 +701,27 @@ 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
new_token = { from datetime import datetime, timezone
"id": token_id,
"name": name, def _mut(auth_data):
"token": f"sk-coderai-{secrets.token_hex(32)}", tokens = auth_data.setdefault("tokens", [])
"provider": provider, # max+1 (not len+1) so IDs aren't reused after a deletion.
"created_at": datetime.utcnow().isoformat() + "Z", token_id = max([t.get("id", 0) for t in tokens], default=0) + 1
"last_used": None new_token = {
} "id": token_id,
"name": name,
auth_data.setdefault("tokens", []).append(new_token) "token": f"sk-coderai-{secrets.token_hex(32)}",
session_manager._save_auth_data(auth_data) "provider": provider,
"created_at": datetime.now(timezone.utc).isoformat(),
"last_used": None,
}
tokens.append(new_token)
return True, new_token
new_token = session_manager.update_auth_data(_mut)
return { return {
"token": new_token["token"], "token": new_token["token"],
"id": new_token["id"], "id": new_token["id"],
...@@ -730,16 +733,17 @@ async def api_create_token(request: Request, username: str = Depends(require_adm ...@@ -730,16 +733,17 @@ 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): return False, False # not found → don't write
auth_data["tokens"] = new_tokens
return True, True
if not session_manager.update_auth_data(_mut):
raise HTTPException(status_code=404, detail="Token not found") raise HTTPException(status_code=404, detail="Token not found")
auth_data["tokens"] = new_tokens
session_manager._save_auth_data(auth_data)
return {"success": True} return {"success": True}
...@@ -1296,13 +1300,22 @@ async def api_model_upload(request: Request, username: str = Depends(require_adm ...@@ -1296,13 +1300,22 @@ async def api_model_upload(request: Request, username: str = Depends(require_adm
filename = form.get("filename", "model.gguf") filename = form.get("filename", "model.gguf")
chunk_index = int(form.get("chunk_index", 0)) chunk_index = int(form.get("chunk_index", 0))
total_chunks = int(form.get("total_chunks", 1)) total_chunks = int(form.get("total_chunks", 1))
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