fix: corrige 6 bugs mineurs (BUG-035 a BUG-040)
CI / lint (push) Successful in 1m36s
CI / security (push) Successful in 1m4s
CI / test (push) Successful in 3m41s
CI / build (push) Successful in 59s
CI / e2e (push) Successful in 11m8s

This commit is contained in:
2026-09-17 20:05:08 -04:00
parent 2c460022f8
commit 2e2a33cef3
25 changed files with 595 additions and 60 deletions
+32
View File
@@ -4,6 +4,7 @@
import logging
import os
import sys
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -17,6 +18,9 @@ logger = logging.getLogger("obsigate.auth.middleware")
security = HTTPBearer(auto_error=False)
#: Hosts considered safe to bind without authentication (loopback only).
_LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost", "0:0:0:0:0:0:0:1"}
def is_auth_enabled() -> bool:
"""Check if authentication is enabled via environment variable.
@@ -26,6 +30,34 @@ def is_auth_enabled() -> bool:
return os.environ.get("OBSIGATE_AUTH_ENABLED", "true").lower() != "false"
def is_insecure_mode_allowed() -> bool:
"""True when the operator explicitly accepts running without auth (BUG-037)."""
return os.environ.get("OBSIGATE_ALLOW_INSECURE", "false").lower() in ("1", "true", "yes", "on")
def bind_host_from_argv(argv: list[str] | None = None) -> str | None:
"""Extract the ``--host`` value from the process arguments (uvicorn), if any.
Returns ``None`` when no explicit host is passed (uvicorn then defaults to
loopback ``127.0.0.1``).
"""
args = sys.argv if argv is None else argv
for i, arg in enumerate(args):
if arg == "--host" and i + 1 < len(args):
return args[i + 1]
if arg.startswith("--host="):
return arg.split("=", 1)[1]
return None
def is_loopback_host(host: str | None) -> bool:
"""True when *host* is a loopback address (or unset → uvicorn default)."""
if not host:
return True
normalized = host.strip().strip("[]").lower()
return normalized in _LOOPBACK_HOSTS
def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
+11 -4
View File
@@ -1,14 +1,21 @@
# backend/auth/password.py
# Argon2id password hashing — OWASP 2024 recommended algorithm.
# Parameters: time_cost=2, memory_cost=64MB, parallelism=2
# Parameters (BUG-038): time_cost=2, memory_cost=19 MiB, parallelism=1
# (OWASP current recommendation for Argon2id). The previous 64 MiB setting
# allowed memory exhaustion under concurrent login attempts.
from argon2 import PasswordHasher
from argon2.exceptions import VerificationError, VerifyMismatchError
#: Argon2id cost parameters (OWASP 2024: m=19456 KiB, t=2, p=1).
ARGON2_TIME_COST = 2
ARGON2_MEMORY_COST_KIB = 19456 # 19 MiB
ARGON2_PARALLELISM = 1
ph = PasswordHasher(
time_cost=2,
memory_cost=65536, # 64 MB
parallelism=2,
time_cost=ARGON2_TIME_COST,
memory_cost=ARGON2_MEMORY_COST_KIB,
parallelism=ARGON2_PARALLELISM,
hash_len=32,
salt_len=16,
)
+18 -17
View File
@@ -124,31 +124,32 @@ async def auth_status():
async def login(body: LoginRequest, response: Response, request: Request):
"""Authenticate a user. Returns access token and sets refresh cookie.
Implements timing-safe responses to prevent user enumeration:
a failed login with an unknown user takes the same time as one
with a known user (dummy hash is computed).
Implements timing-safe responses to prevent user enumeration: a failed
login with an unknown user takes the same time as one with a known user
(dummy hash is computed). BUG-039: unknown, inactive, locked and
per-account rate-limited accounts all answer the same ``401`` so the HTTP
status can never reveal whether an account exists.
"""
client_ip = get_client_ip(request)
# IP-based rate limiting (10 failures / 15 min per IP). It is not
# account-specific, so a 429 here cannot be used to enumerate accounts.
if is_rate_limited(client_ip):
raise HTTPException(429, "Trop de tentatives depuis cette adresse IP (15min)")
user = get_user(body.username)
if not user:
# BUG-039: uniform 401 + equivalent timing for every account-state outcome.
if not user or not user.get("active"):
# Timing-safe: simulate hash computation to prevent user enumeration
hash_password("dummy_timing_protection")
raise HTTPException(401, "Identifiants invalides")
if not user.get("active"):
raise HTTPException(403, "Compte désactivé")
# IP-based rate limiting (10 failures / 15 min per IP)
client_ip = get_client_ip(request)
if is_rate_limited(client_ip):
raise HTTPException(429, "Trop de tentatives depuis cette adresse IP (15min)")
# BUG-031: per-account budget still applies when the attacker rotates IPs.
if is_account_rate_limited(body.username):
raise HTTPException(429, "Trop de tentatives sur ce compte (15min)")
if is_locked(body.username):
raise HTTPException(429, "Compte temporairement verrouillé (15min)")
# Kept indistinguishable from a wrong password (BUG-039).
if is_account_rate_limited(body.username) or is_locked(body.username):
hash_password("dummy_timing_protection")
raise HTTPException(401, "Identifiants invalides")
if not verify_password(body.password, user["password_hash"]):
attempts = record_login_failure(body.username)
+14 -4
View File
@@ -44,6 +44,9 @@ MAX_UPDATE_BYTES = 8 * 1024 * 1024
#: Taille maximale d'un snapshot texte (protection anti-abus).
MAX_TEXT_CHARS = 8 * 1024 * 1024
#: Taille maximale d'un message brut reçu (protection anti-abus, BUG-036).
MAX_MESSAGE_CHARS = 16 * 1024 * 1024
#: Palette de couleurs attribuées aux utilisateurs (curseurs + avatars).
PEER_COLORS = [
"#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4",
@@ -68,9 +71,13 @@ def authenticate_websocket(websocket: WebSocket) -> dict[str, Any] | None:
"""Authenticate a WebSocket connection.
Mirrors :func:`backend.auth.middleware.get_current_user` but works on the
WebSocket scope: the JWT is read from the ``access_token`` cookie (sent
automatically by same-origin browsers during the handshake) or, as a
fallback, from the ``token`` query parameter.
WebSocket scope: the JWT is read from the ``access_token`` cookie, which
same-origin browsers send automatically during the handshake.
BUG-036: the token is **never** accepted from the query string anymore —
URLs end up in access logs, proxies and browser history. Browsers cannot
set custom headers on a WebSocket handshake, so the HttpOnly cookie set at
login is the only supported transport.
Returns the user dict, or ``None`` if authentication fails.
"""
@@ -88,7 +95,7 @@ def authenticate_websocket(websocket: WebSocket) -> dict[str, Any] | None:
"_token_vaults": ["*"],
}
token = websocket.query_params.get("token") or websocket.cookies.get("access_token")
token = websocket.cookies.get("access_token")
if not token:
return None
@@ -274,6 +281,9 @@ class CollabManager:
# -- message handling ---------------------------------------------------
async def _on_message(self, room: CollabRoom, client: CollabClient, raw: str) -> None:
# BUG-036: drop oversized frames before parsing them.
if not isinstance(raw, str) or len(raw) > MAX_MESSAGE_CHARS:
return
try:
message = json.loads(raw)
except (ValueError, TypeError):
+74 -6
View File
@@ -481,12 +481,18 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
# PDF handling — special path (binary, uses pdf_reader)
tags: list[str] = []
pdf_text_pending = False
if ext == ".pdf":
from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text
raw = extract_pdf_text(fpath, max_chars=100000)
from backend.pdf_reader import extract_pdf_metadata
# BUG-040: only the (cheap) metadata is read during the
# scan. Full-text extraction is deferred to a background
# pass (``enrich_pdf_texts``) so a vault with many/large
# PDFs no longer blocks startup and index rebuilds.
pdf_meta = extract_pdf_metadata(fpath)
title = pdf_meta.get("title") or fpath.stem.replace("-", " ").replace("_", " ")
content_preview = raw[:200].strip()
raw = ""
content_preview = ""
pdf_text_pending = True
elif ext == ".excalidraw" or fpath.name.lower().endswith(".excalidraw.md"):
raw = fpath.read_text(encoding="utf-8", errors="replace")
raw = extract_excalidraw_indexable(raw)
@@ -510,7 +516,7 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
title, post.content
)
files.append({
file_info = {
"path": str(relative).replace("\\", "/"),
"title": title,
"tags": tags,
@@ -519,7 +525,10 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
"size": stat.st_size,
"modified": modified,
"extension": ext,
})
}
if pdf_text_pending:
file_info["pdf_text_pending"] = True
files.append(file_info)
for tag in tags:
tag_counts[tag] = tag_counts.get(tag, 0) + 1
@@ -535,6 +544,60 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
return {"files": files, "tags": tag_counts, "path": vault_path, "paths": paths, "config": {}}
async def enrich_pdf_texts(vault_name: str | None = None) -> int:
"""Extract text from PDFs whose extraction was deferred during the scan (BUG-040).
``_scan_vault`` only reads PDF metadata so a vault with many or large PDFs
starts serving immediately. This coroutine runs *after* the index (and the
inverted index) is ready, extracts the missing text off the event loop and
updates the in-memory entry plus the incremental index hooks.
Args:
vault_name: Restrict the pass to a single vault; ``None`` covers every
indexed vault.
Returns:
Number of deferred PDFs whose text extraction was attempted.
"""
from backend.pdf_reader import extract_pdf_text
pending: list[tuple[str, dict[str, Any], Path]] = []
with _index_lock:
for name, vault_data in index.items():
if vault_name is not None and name != vault_name:
continue
vault_root = Path(vault_data.get("path", ""))
for file_info in vault_data.get("files", []):
if file_info.get("pdf_text_pending"):
pending.append((name, file_info, vault_root / file_info["path"]))
if not pending:
return 0
loop = asyncio.get_running_loop()
enriched = 0
for name, file_info, file_path in pending:
try:
raw = await loop.run_in_executor(None, extract_pdf_text, file_path, 100000)
except Exception as exc: # pragma: no cover - defensive
logger.warning("PDF enrichment failed for %s: %s", file_path, exc)
raw = ""
file_info["content"] = raw[:SEARCH_CONTENT_LIMIT]
file_info["content_preview"] = raw[:200].strip()
file_info.pop("pdf_text_pending", None)
enriched += 1
if _on_index_change:
try:
_on_index_change("add", name, file_info["path"], file_info)
except Exception as exc: # pragma: no cover - defensive
logger.warning(
"Index hook failed after PDF enrichment for %s: %s", file_path, exc
)
logger.info("PDF enrichment: extracted text for %d deferred PDF(s)", enriched)
return enriched
async def build_index(progress_callback=None) -> None:
"""Build the full in-memory index for all configured vaults.
@@ -632,6 +695,8 @@ async def reload_index() -> dict[str, Any]:
Dict mapping vault names to their file/tag counts.
"""
await build_index()
# BUG-040: complete the deferred PDF extraction for the rebuilt index.
await enrich_pdf_texts()
stats = {}
for name, data in index.items():
stats[name] = {"file_count": len(data["files"]), "tag_count": len(data["tags"])}
@@ -695,7 +760,10 @@ async def reload_single_vault(vault_name: str) -> dict[str, Any]:
# Rebuild attachment index for this vault only
from backend.attachment_indexer import build_attachment_index
await build_attachment_index({vault_name: config})
# BUG-040: complete the deferred PDF extraction for this vault.
await enrich_pdf_texts(vault_name)
stats = {"file_count": len(vault_data["files"]), "tag_count": len(vault_data["tags"])}
logger.info(f"Vault '{vault_name}' reindexed: {stats['file_count']} files, {stats['tag_count']} tags")
return stats
+50 -1
View File
@@ -722,12 +722,56 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
return response
def _guard_insecure_auth() -> None:
"""Warn or refuse to start when authentication is disabled (BUG-037).
With ``OBSIGATE_AUTH_ENABLED=false`` every request is served as an
anonymous admin. That is convenient for local use but dangerous when the
process is reachable from a network. Binding to a non-loopback host
without the explicit ``OBSIGATE_ALLOW_INSECURE=true`` opt-in is refused.
"""
from backend.auth.middleware import (
bind_host_from_argv,
is_auth_enabled,
is_insecure_mode_allowed,
is_loopback_host,
)
if is_auth_enabled():
return
if is_insecure_mode_allowed():
logger.warning(
"Authentication is DISABLED and OBSIGATE_ALLOW_INSECURE=true: every request "
"is treated as an anonymous administrator. Do not expose this instance."
)
return
host = bind_host_from_argv()
if not is_loopback_host(host):
raise RuntimeError(
"Refusing to start: authentication is disabled (OBSIGATE_AUTH_ENABLED=false) "
f"while binding to a non-loopback address ('{host}'). This would expose an "
"unauthenticated instance with admin access. Enable authentication, or set "
"OBSIGATE_ALLOW_INSECURE=true if you really know what you are doing."
)
logger.warning(
"Authentication is DISABLED (OBSIGATE_AUTH_ENABLED=false): every request is "
"treated as an anonymous administrator. This is only safe on a trusted, "
"loopback-only deployment."
)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: build index on startup, cleanup on shutdown."""
global _search_executor, _vault_watcher
_search_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="search")
# BUG-037: refuse to expose an unauthenticated instance on a public bind.
_guard_insecure_auth()
# Bootstrap admin account if needed
bootstrap_admin()
@@ -748,6 +792,11 @@ async def lifespan(app: FastAPI):
# Build the semantic (embedding) index in the same background thread pool.
await loop.run_in_executor(_search_executor, init_semantic_index)
# BUG-040: extract the PDF text deferred during the scan now that the
# index and inverted index are queryable (keeps startup non-blocking).
from backend.indexer import enrich_pdf_texts
await enrich_pdf_texts()
# Scan for plugins in all vaults
logger.info("Scanning for plugins...")
from backend.indexer import vault_config
+45 -3
View File
@@ -34,7 +34,7 @@ _PATTERNS = [
(re.compile(r'(?:api[_-]?key|apikey|secret|token|password|passwd|auth[_-]?token)\s*[:=]\s*[\'"]?([^\s\'"]{20,})[\'"]?', re.IGNORECASE),
lambda m: f'{m.group(0).split("=")[0].split(":")[0]}=[MASQUÉ]' if "=" in m.group(0) or ":" in m.group(0) else '[MASQUÉ]'),
# Generic long hex/base64 strings that look like secrets (40+ chars)
# Prefixed API keys (sk-..., pk-..., rk-...)
(re.compile(r'(?:sk|pk|rk)-[a-zA-Z0-9]{20,}'), '[CLÉ API MASQUÉE]'),
# AWS access keys
@@ -43,10 +43,50 @@ _PATTERNS = [
# GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_)
(re.compile(r'gh[pousr]_[a-zA-Z0-9]{36,}'), '[GITHUB_TOKEN MASQUÉ]'),
# Generic long random-looking strings (40+ hex chars)
(re.compile(r'\b[a-fA-F0-9]{40,64}\b'), '[HEX_KEY MASQUÉ]'),
]
# BUG-035: bare 40–64 char hex strings used to be redacted unconditionally,
# which mangled legitimate git commit SHAs, checksums and hashes in notes.
# They are now only redacted when a secret-ish keyword sits in the immediate
# context; hash/commit keywords explicitly exempt them.
_HEX_RE = re.compile(r'\b[a-fA-F0-9]{40,64}\b')
_SECRET_CONTEXT_RE = re.compile(
r'(?i)\b(?:secret|token|key|apikey|api[_-]?key|password|passwd|auth|bearer|'
r'credential|x-api-key|x-auth-token)\b'
)
_HASH_CONTEXT_RE = re.compile(
r'(?i)\b(?:commit|sha\d*|hash|md5|blob|git|checksum|digest|integrity|'
r'revision|rev|etag|fingerprint)\b'
)
#: How far before the hex string a keyword may appear to count as context.
_HEX_CONTEXT_WINDOW = 60
def _redact_bare_hex_secrets(text: str) -> tuple:
"""Redact 40–64 char hex strings only when a secret keyword is nearby.
Git/SHA/checksum contexts are left untouched (BUG-035).
Args:
text: Text to scan.
Returns:
(redacted_text, redaction_count) tuple.
"""
count = 0
def _replace(match: re.Match) -> str:
nonlocal count
window = text[max(0, match.start() - _HEX_CONTEXT_WINDOW):match.start()]
if _HASH_CONTEXT_RE.search(window):
return match.group(0)
if _SECRET_CONTEXT_RE.search(window):
count += 1
return '[HEX_KEY MASQUÉ]'
return match.group(0)
return _HEX_RE.sub(_replace, text), count
def redact(text: str) -> tuple:
"""Redact sensitive patterns from text.
@@ -66,6 +106,8 @@ def redact(text: str) -> tuple:
new_result, n = pattern.subn(str(replacement), result)
count += n
result = new_result
result, hex_count = _redact_bare_hex_secrets(result)
count += hex_count
if count > 0:
logger.info(f"Redacted {count} secret(s) from content")
return result, count