Files
ObsiGate/backend/services/paths.py
T
bruno 162a5b4acc
CI / lint (push) Successful in 1m20s
CI / security (push) Successful in 47s
CI / test (push) Successful in 2m21s
CI / build (push) Successful in 43s
CI / e2e (push) Successful in 10m48s
fix(security): consolidation & securite phase 1 (#84, BUG-021 a BUG-034)
- sanitizer XSS serveur (markdown + page de partage) [BUG-021/022]
- rate-limit/lockout MFA [BUG-023]
- isolation vaults par segments [BUG-024]
- caps regex ReDoS [BUG-025]
- SSRF webhooks + secrets externalises [BUG-026]
- rotation/revocation des jetons [BUG-027]
- politique de mot de passe + invalidation sessions [BUG-028]
- verrous users.json [BUG-029]
- IP reelle dans les audits [BUG-030]
- rate-limit par compte [BUG-031]
- symlinks hors vault ignores [BUG-032]
- recherche simple via inverted index [BUG-033]
- token en memoire + cookie HttpOnly, CSP durcie [BUG-034]

Tests: pytest 961 passed / 6 skipped, ruff 0, mypy 0, frontend vert.
2026-09-13 10:51:42 -04:00

63 lines
2.2 KiB
Python

"""Vault path resolution shared by the REST routes and the AI tool layer.
This is the single implementation of the anti path-traversal check. Routes map
:class:`ServiceError` to ``HTTPException`` and tools map it to
:class:`backend.tools.context.ToolError`.
"""
from __future__ import annotations
import logging
from pathlib import Path
from backend.services.errors import ServiceError
logger = logging.getLogger("obsigate.services.paths")
def _is_within(resolved: Path, root: Path) -> bool:
"""Return True when *resolved* is *root* or lives below it.
The comparison is segment-aware so that a sibling directory whose name
merely shares a prefix (``vault`` vs ``vault-evil``) is rejected. A
case-insensitive fallback preserves the Windows / Docker behaviour where
the resolved casing can differ from the configured root.
"""
try:
resolved.relative_to(root)
return True
except ValueError:
pass
try:
resolved_parts = tuple(part.lower() for part in resolved.parts)
root_parts = tuple(part.lower() for part in root.parts)
except Exception:
return False
return resolved_parts[: len(root_parts)] == root_parts
def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
"""Resolve a vault-relative path, rejecting traversal outside the vault.
Raises:
ServiceError: ``path_error`` (500) when the path cannot be resolved,
``path_outside_vault`` (403) when it escapes the vault root.
"""
full_path = vault_root / (relative_path or "")
try:
resolved = full_path.resolve(strict=False)
root = vault_root.resolve(strict=False)
except Exception as e:
logger.error(f"Path resolution error - vault_root: {vault_root}, relative_path: {relative_path}, error: {e}")
raise ServiceError(f"Path resolution error: {e!s}", code="path_error", status=500) from e
if not _is_within(resolved, root):
logger.warning(f"Path outside vault - vault: {root}, requested: {relative_path}, resolved: {resolved}")
raise ServiceError(
"Access denied: path outside vault",
code="path_outside_vault",
status=403,
details={"path": relative_path},
)
return resolved