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

  1. Only send the opaque id returned by POST /api/uploads (meta['id']), never a path or original filename
  2. If storage moved and symlinks were used, replace them with real files or reconfigure upload_dir/UPLOAD_DIR to the new root
  3. 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

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

Related errors


AI-assisted analysis of odysseus-dev/odysseus@f9235ebbf1 (2026-08-14). Data as JSON: /api/errors/7d50d4bf9f748ec2. Report an issue: GitHub.