odysseus-dev/odysseus · error · HTTPException
Access denied
Error message
Access denied
What it means
HTTP 403 from _resolve_upload_path when os.path.join(upload_root, file_id) exists (os.path.lexists is true, so symlinks count) but os.path.realpath escapes the upload root — _path_inside_upload_dir compares commonpath against _upload_root(). This is the path-traversal guard: a file_id like '../secrets.pem' or a symlink pointing outside uploads is rejected.
Source
Thrown at routes/upload_routes.py:166
"""Setup upload routes with the provided handler"""
def _upload_root() -> str:
from src.constants import UPLOAD_DIR
return os.path.realpath(getattr(upload_handler, "upload_dir", UPLOAD_DIR))
def _path_inside_upload_dir(path: str) -> bool:
try:
return os.path.commonpath([_upload_root(), os.path.realpath(path)]) == _upload_root()
except Exception:
return False
def _resolve_upload_path(file_id: str) -> str:
from src.constants import UPLOAD_DIR
upload_root = getattr(upload_handler, "upload_dir", UPLOAD_DIR)
direct = os.path.join(upload_root, file_id)
if os.path.lexists(direct):
if not _path_inside_upload_dir(direct):
raise HTTPException(403, "Access denied")
if os.path.isfile(direct):
return direct
raise HTTPException(404, "File not found")
for root, _dirs, files in os.walk(upload_root, followlinks=False):
if file_id not in files:
continue
path = os.path.join(root, file_id)
if not _path_inside_upload_dir(path):
raise HTTPException(403, "Access denied")
if os.path.isfile(path):
return path
raise HTTPException(404, "File not found")
raise HTTPException(404, "File not found")
def _valid_session_id_for_owner(db, session_id: str | None, owner: str | None) -> str | None:
if not session_id:View on GitHub (pinned to f9235ebbf1)
Solutions
- Only send the opaque id returned by POST /api/uploads (meta['id']), never a path or original filename
- If storage moved and symlinks were used, replace them with real files or reconfigure upload_dir/UPLOAD_DIR to the new root
- Sanitize file_id client-side: reject '/', '..', and backslashes before calling download endpoints
Example fix
// before
fetch(`/api/uploads/${encodeURIComponent(filePath)}`) // user-supplied path -> 403
// after
fetch(`/api/uploads/${upload.id}`) // opaque server-generated id Defensive patterns
Strategy: validation
Validate before calling
const SAFE_ID = /^[A-Za-z0-9_-]+$/;
if (!SAFE_ID.test(fileId) || fileId.includes('..')) throw new Error('invalid file id'); Type guard
function isUploadId(id: unknown): id is string {
return typeof id === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(id);
} Try / catch
if (resp.status === 403) { auditLog('path traversal blocked', fileId); neverRetrySameId(fileId); } Prevention
- Only use server-issued opaque ids, never paths or original filenames
- Reject ids containing '/', '\\', or '..' before the request
- Never follow user-supplied relative paths in download URLs
When it happens
Trigger: Download/attach request with file_id containing traversal ('../..'), an absolute path fragment, or naming a symlink inside uploads whose target lives outside the uploads tree.
Common situations: Clients passing a server-side relative path or the original filename instead of the generated upload id; attackers probing with ../ sequences (the 403 is the guard working); a symlink inside uploads legitimately pointing elsewhere after a storage move.
Understand the failure class
- Authentication and authorization failures — expired tokens, bad credentials, and missing scopes.
Related errors
- Invalid OAuth {field_name}: path must stay under {base}
- File not found
- No files uploaded
- unsafe path: {rel!r}
- data?.error || data?.detail || `HTTP ${res.status}`
AI-assisted analysis of odysseus-dev/odysseus@f9235ebbf1 (2026-08-14).
Data as JSON: /api/errors/7d50d4bf9f748ec2.
Report an issue: GitHub.