diff --git a/.env.example b/.env.example index b159929..906eb15 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,11 @@ OBSIGATE_AUTH_ENABLED=true OBSIGATE_ADMIN_USER=admin OBSIGATE_ADMIN_PASSWORD=chab30 +# DANGER : si OBSIGATE_AUTH_ENABLED=false, toute requête devient un admin +# anonyme. Le serveur REFUSE de démarrer sur une adresse non-loopback +# (ex. 0.0.0.0) sauf si l'on force l'opt-in ci-dessous. À réserver au local. +# OBSIGATE_ALLOW_INSECURE=false + # Sécurité des cookies (activer si derrière HTTPS) # OBSIGATE_SECURE_COOKIES=false diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5c7cbad..cf374e7 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -197,6 +197,7 @@ jobs: -e DIR_1_NAME=TestDir \ -e DIR_1_PATH=/vaults/TestDir \ -e OBSIGATE_AUTH_ENABLED=false \ + -e OBSIGATE_ALLOW_INSECURE=true \ obsigate:ci # Docker-in-docker : le bind mount $(pwd)/... pointe sur un chemin # du job container, inexistant sur l'hôte → montage vide. Les -v diff --git a/CHANGELOG.md b/CHANGELOG.md index ba29610..e4889e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.11.2**. +> [Unreleased](#unreleased). La dernière version livrée est **2.11.3**. --- @@ -14,6 +14,47 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.11.3] — 2026-09-17 + +### Corrigé + +- **BUG-035 — `secret_redactor` : faux positifs sur les hashs hex** : la règle qui masquait + tout jeton hexadécimal de 40 à 64 caractères mutilait les hashs git/SHA légitimes des notes. + Le masquage des chaînes hexadécimales n'a désormais lieu que si un mot-clé de secret + (`secret`, `token`, `key`, `password`, `bearer`…) figure dans les 60 caractères précédents ; + un contexte de hash (`commit`, `sha256`, `hash`, `checksum`, `git`, `etag`…) exempte + explicitement la chaîne. Fichier : `backend/secret_redactor.py`. +- **BUG-036 — Collaboration WebSocket : jeton accepté en query string** : le JWT n'est plus lu + depuis `?token=` (URLs journalisées par les proxies et l'historique navigateur). Le cookie + HttpOnly `access_token`, envoyé automatiquement par le navigateur lors du handshake + same-origin, est le seul transport supporté ; les trames brutes dépassant + `MAX_MESSAGE_CHARS` (16 Mio) sont rejetées avant analyse. Fichier : `backend/collab.py`. +- **BUG-037 — Mode sans authentification** : au démarrage, un avertissement explicite est + journalisé quand `OBSIGATE_AUTH_ENABLED=false`. Le serveur **refuse désormais de démarrer** + s'il est lié à une adresse non-loopback sans l'opt-in explicite `OBSIGATE_ALLOW_INSECURE=true`, + pour empêcher l'exposition publique d'une instance sans authentification (admin anonyme). + Fichiers : `backend/auth/middleware.py`, `backend/main.py`. +- **BUG-038 — Argon2 : coût mémoire recalibré** : `memory_cost` passe de 64 Mio à 19 Mio + (`m=19456 Kio, t=2, p=1`, recommandation OWASP actuelle) pour supprimer le risque + d'épuisement mémoire sous connexions simultanées ; les anciens hachages restent valides et + sont migrés automatiquement (`needs_rehash`). Fichier : `backend/auth/password.py`. +- **BUG-039 — Énumération de comptes au login** : les comptes inconnus, désactivés, verrouillés + et limités par le budget par compte répondent tous un `401 Identifiants invalides` avec un + temps équivalent (hachage factice), au lieu d'un `429`/`403` distinctif ; seul le rate-limit + par IP (non lié à un compte) conserve le `429`. Fichier : `backend/auth/router.py`. +- **BUG-040 — Extraction PDF différée au scan** : `_scan_vault` ne lit plus que les métadonnées + des PDF ; l'extraction de texte intégrale (100 kio) est déléguée à `enrich_pdf_texts()`, + exécutée après la construction de l'index/inverted index (démarrage) et après chaque + réindexation. Un vault contenant de nombreux/gros PDF démarre sans être bloqué ; le texte + reste recherchable une fois l'enrichissement terminé. Fichiers : `backend/indexer.py`, + `backend/main.py`. +- **Tests** : `tests/test_api_main.py` (redactor hex), `tests/test_auth.py` (coût Argon2, + garde-fou d'instance non authentifiée), `tests/test_auth_api.py` (login uniforme), + `tests/test_collab.py` (jeton query rejeté, trame surdimensionnée), `tests/test_pdf.py` + (scan différé + enrichissement). + +--- + ## [2.11.2] — 2026-09-17 ### Modifié diff --git a/README.fr.md b/README.fr.md index f5a95cb..c7b772c 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.11.2-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.11.3-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -926,8 +926,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.11.2). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.11.3). --- -*Projet : ObsiGate | Version : 2.11.2 | Dernière mise à jour : Juin 2026* +*Projet : ObsiGate | Version : 2.11.3 | Dernière mise à jour : Juin 2026* diff --git a/README.md b/README.md index edfd23f..3e7ebe9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.11.2-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.11.3-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1095,8 +1095,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.11.2). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.11.3). --- -*Project: ObsiGate | Version: 2.11.2 | Last updated: May 2026* +*Project: ObsiGate | Version: 2.11.3 | Last updated: May 2026* diff --git a/VERSION b/VERSION index 9e5bb77..22e3b6b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.11.2 +2.11.3 diff --git a/backend/auth/middleware.py b/backend/auth/middleware.py index 57c27f7..41000ea 100644 --- a/backend/auth/middleware.py +++ b/backend/auth/middleware.py @@ -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), diff --git a/backend/auth/password.py b/backend/auth/password.py index a2b6b7f..5396e73 100644 --- a/backend/auth/password.py +++ b/backend/auth/password.py @@ -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, ) diff --git a/backend/auth/router.py b/backend/auth/router.py index 8f807c9..2758cc2 100644 --- a/backend/auth/router.py +++ b/backend/auth/router.py @@ -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) diff --git a/backend/collab.py b/backend/collab.py index 65a63f0..5fdfe9c 100644 --- a/backend/collab.py +++ b/backend/collab.py @@ -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): diff --git a/backend/indexer.py b/backend/indexer.py index 5149369..69866cf 100644 --- a/backend/indexer.py +++ b/backend/indexer.py @@ -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 diff --git a/backend/main.py b/backend/main.py index 93c800f..ec3596b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 diff --git a/backend/secret_redactor.py b/backend/secret_redactor.py index d6cf56e..1ee56f2 100644 --- a/backend/secret_redactor.py +++ b/backend/secret_redactor.py @@ -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 diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 1a9d8dd..f973dec 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.11.2" +version = "2.11.3" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index c6d4498..475941c 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.11.2" +version = "2.11.3" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 9486612..99129ee 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.11.2", + "version": "2.11.3", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 8cfa77a..7ee8a8a 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -144,12 +144,12 @@ Avant de corriger quoi que ce soit, un agent IA doit : | *BUG-032* | [🟡 IMPORTANT] Indexation : symlinks suivis (contenu hors vault indexé) + scan initial coûteux | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/indexer.py` | Placer un symlink dans le vault vers un dossier externe puis relancer l'index | `_scan_vault` réécrit avec `os.walk(followlinks=False)` + refus des symlinks sortant de la racine ; test `TestSymlinkIndexing` | Scan incrémental/index persistant : voir #86 (phase 3) | | *BUG-033* | [🟡 IMPORTANT] Recherche classique et tool IA `search_fulltext` en O(N) sans inverted index | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/search.py`, `backend/tools/service.py` | `GET /api/search` sur un vault de 50 000 fichiers | `search()` récupère les candidats via l'inverted index (intersection des termes + expansion de préfixes), repli sur le scan pendant la construction | `search_fulltext` en bénéficie automatiquement | | *BUG-034* | [🟡 IMPORTANT] CSP affaiblie (`'unsafe-inline'` + CDN distants) et token d'accès en sessionStorage | 🟢 corrigé | P1 | 🔐 sécurité | IA | `backend/main.py`, `frontend/js/auth.js`, `frontend/js/admin.js`, `frontend/js/sync.js` | Inspecter les en-têtes CSP ; lire sessionStorage en console | Token en mémoire + cookie HttpOnly (plus de `sessionStorage`) ; CSP durcie (`object-src 'none'`, `base-uri`, `form-action`, `frame-ancestors`). *Reste : migration nonce* | `'unsafe-inline'` conservé tant que les gestionnaires inline n'ont pas été convertis (résidu documenté) | -| *BUG-035* | [🔵 MINEUR] `secret_redactor` : faux positifs sur les hashs hex (git, SHA) | 🔴 ouvert | P2 | ⚙️ backend | IA | `backend/secret_redactor.py` | Lire une note contenant un commit git (40 caractères hexadécimaux) | Restreindre le périmètre de détection (contexte clé/token) + whitelist | Contenus mutilés dans les lectures et réponses IA | -| *BUG-036* | [🔵 MINEUR] Collab WebSocket : token en query string | 🔴 ouvert | P2 | ⚙️ backend | IA | `backend/collab.py` | Observer l'URL du websocket dans le trafic réseau | Passer le token en header / étape d'authentification initiale ; borner la taille des messages | Jeton visible dans les logs/proxys | -| *BUG-037* | [🔵 MINEUR] Compte « anonymous » administrateur si auth désactivée | 🔴 ouvert | P2 | 🔐 sécurité | IA | `backend/auth/middleware.py` | Démarrer avec l'authentification désactivée | Avertissement explicite au démarrage + refus de déploiement public sans auth | Comportement par conception mais risqué si mal configuré | -| *BUG-038* | [🔵 MINEUR] Argon2 à 64 MB par vérification : risque d'épuisement mémoire | 🔴 ouvert | P2 | 🔐 sécurité | IA | `backend/auth/password.py:8` | Lancer de nombreux `POST /api/auth/login` simultanés | Recalibrer (~19 MB, t=2, p=1, norme OWASP actuelle) + maintien du rate-limit | DoS mémoire possible sur les petites instances | -| *BUG-039* | [🔵 MINEUR] Enumération de comptes : 429 (verrouillé) vs 401 (inconnu) | 🔴 ouvert | P3 | 🔐 sécurité | IA | `backend/auth/router.py:120` | Tenter un login sur un compte verrouillé puis un nom inconnu | Répondre 401 uniforme avec un timing équivalent | Le statut HTTP distingue l'existence d'un compte | -| *BUG-040* | [🔵 MINEUR] Extraction PDF intégrale (100 ko) au scan de démarrage | 🔴 ouvert | P2 | ⚙️ backend | IA | `backend/indexer.py:465` | Démarrer sur un vault contenant de nombreux PDF | Analyser les PDF en tâche de fond / à la demande (lazy) | Ralentit fortement le démarrage et le rebuild d'index | +| *BUG-035* | [🔵 MINEUR] `secret_redactor` : faux positifs sur les hashs hex (git, SHA) | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/secret_redactor.py` | Lire une note contenant un commit git (40 caractères hexadécimaux) | Masquage hex conditionné au contexte (`_redact_bare_hex_secrets`) : secret exigé dans les 60 caractères précédents, exemption explicite pour `commit`/`sha*`/`hash`/`checksum`/`git`/`etag`. Tests : `tests/test_api_main.py::TestSecretRedactor` (+4) | Contenus mutilés dans les lectures et réponses IA | +| *BUG-036* | [🔵 MINEUR] Collab WebSocket : token en query string | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/collab.py` | Observer l'URL du websocket dans le trafic réseau | `authenticate_websocket` ne lit plus `?token=` : cookie HttpOnly `access_token` uniquement ; rejet des trames > `MAX_MESSAGE_CHARS` (16 Mio) avant analyse. Tests : `tests/test_collab.py` (+3) | Jeton visible dans les logs/proxys | +| *BUG-037* | [🔵 MINEUR] Compte « anonymous » administrateur si auth désactivée | 🟢 corrigé | P2 | 🔐 sécurité | IA | `backend/auth/middleware.py`, `backend/main.py` | Démarrer avec l'authentification désactivée | `_guard_insecure_auth()` : avertissement explicite + refus de démarrage sur bind non-loopback sans `OBSIGATE_ALLOW_INSECURE=true`. Tests : `tests/test_auth.py::TestInsecureAuthGuard` (+6) | Comportement par conception mais risqué si mal configuré | +| *BUG-038* | [🔵 MINEUR] Argon2 à 64 MB par vérification : risque d'épuisement mémoire | 🟢 corrigé | P2 | 🔐 sécurité | IA | `backend/auth/password.py` | Lancer de nombreux `POST /api/auth/login` simultanés | Recalibré à `m=19456 Kio (19 Mio), t=2, p=1` (OWASP) ; anciens hachages valides + rehash auto. Test : `tests/test_auth.py::TestPasswordHashing::test_argon2_memory_recalibrated` | DoS mémoire possible sur les petites instances | +| *BUG-039* | [🔵 MINEUR] Enumération de comptes : 429 (verrouillé) vs 401 (inconnu) | 🟢 corrigé | P3 | 🔐 sécurité | IA | `backend/auth/router.py` | Tenter un login sur un compte verrouillé puis un nom inconnu | Login uniforme : inconnu / désactivé / verrouillé / rate-limit par compte → `401 Identifiants invalides` + hachage factice (timing équivalent) ; seul le rate-limit IP reste `429`. Tests : `tests/test_auth_api.py` (+3) | Le statut HTTP distinguait l'existence d'un compte | +| *BUG-040* | [🔵 MINEUR] Extraction PDF intégrale (100 ko) au scan de démarrage | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/indexer.py`, `backend/main.py` | Démarrer sur un vault contenant de nombreux PDF | `_scan_vault` ne lit que les métadonnées ; `enrich_pdf_texts()` extrait le texte après l'index (démarrage) et après chaque réindexation. Tests : `tests/test_pdf.py` (+3) | Ralentit fortement le démarrage et le rebuild d'index | | *BUG-041* | [🟡 IMPORTANT] Assistant IA : échec sur un répertoire vide (« Aucun fichier markdown trouvé dans ce dossier ») au lieu de répondre | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `backend/bookslm_routes.py`, `backend/bookslm.py`, `frontend/js/bookslm.js` | Ouvrir l'assistant sur un dossier vide puis envoyer une question | `_resolve_system_prompt` dégrade vers le prompt Général + bloc « Dossier vide » (plus de 404) ; contexte applicatif `app_context` enrichi (documents ouverts, répertoire, recherche, fichiers récents) | Le 404 bloquait toute la requête. Feature #88, fiche `docs/features/ai-app-context.md`. Tests : `tests/test_bookslm.py` (+3), `tests/frontend/ai.test.mjs` | | *BUG-042* | [🟡 IMPORTANT] Assistant IA : liens de fichiers non fiables (« File not found: ») — pas de règle déterministe nom / dossier / chemin | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Cliquer les liens de fichiers/dossiers dans une réponse de l'assistant (noms avec espaces et/ou accents, chemin préfixé par le nom du vault) | `_classifyPath` distingue `name` (copie presse-papiers) / `dir` (révélation arborescence) / `file` (ouverture) ; `_activatePath()` résout le chemin contre l'index du vault (exact → suffixe → basename unique) avant d'agir ; espaces + accents pris en charge (classes Unicode `\p{L}\p{N}\p{M}`, comparaison normalisée NFC, markdown `<…>`/`%20`, code inline, mentions brutes confirmées par l'index) ; `_splitVaultPrefix` retire un préfixe `Vault/…` et ouvre dans ce vault (`_fetchPathsForVault`) | Les liens morts ouvraient un fichier inexistant. Feature #88. Tests : `tests/frontend/ai.test.mjs` (+11) | | *BUG-043* | [🟡 IMPORTANT] Assistant IA : la liste des fournisseurs de la barre latérale ne suit pas les ajouts/retraits de clés API dans la configuration du projet | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/ai.js`, `frontend/js/bookslm.js`, `frontend/js/config.js` | Ajouter (ou supprimer) une clé de fournisseur AI dans la configuration puis observer le menu Fournisseur de l'assistant sans recharger la page | Le picker lit `/api/ai/status` **une seule fois**, à sa construction, et le panneau de l'assistant est un singleton monté pour toute la session → liste figée. Nouveau `refreshAIPickers()` (exporté par `ai.js`) qui reconstruit chaque picker monté dans son emplacement `.ai-picker-slot` (conservé même sans fournisseur configuré, donc un premier fournisseur s'y monte aussi) ; appelé après `saveAIKeys()` et `deleteAIKey()` (`config.js`) ; une sélection dont le fournisseur n'est plus configuré est purgée de `obsigate_ai_picker` (retour au défaut + modèle effacé au lieu d'un nom fantôme) | Il fallait recharger la page pour voir un nouveau fournisseur (ou en voir disparaître un). Feature #82. Tests : `tests/frontend/ai.test.mjs` (+4) | @@ -232,6 +232,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-09-17 | BUG-058 | Correction | `frontend/style.css`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-058** : la barre de numérotation de ligne de l'éditeur « Editer » ne suivait pas le thème — CodeMirror peint `.cm-gutters` avec des valeurs claires codées en dur (`#f5f5f5`, bordure `#ddd`), visibles en thème sombre. Correctif : le gutter dérive des variables CSS ObsiGate (`background: color-mix(in srgb, var(--text-primary) 5%, transparent)`, `color: var(--text-secondary)`, `border-right: 1px solid var(--border)`, ligne active `color-mix(… 10% …)` / `--text-primary`), donc il suit les 15 thèmes et les 4 modes. Vérifié : `editor-inline.test.mjs` 44/44 (+1), unit 9/9, validate-imports 38 modules, pytest 1101 passed / 6 skipped, ruff 0, mypy 0, et Playwright sur l'instance de test (route `style.css` remplacée par le fichier local) — sombre `color(srgb 0.90 0.93 0.95 / 0.05)` + bordure `#21262d`, clair `color(srgb 0.12 0.14 0.16 / 0.05)` + bordure `#d0d7de`, plus de `rgb(245,245,245)`. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-17 | BUG-059 | Correction | `frontend/js/bookslm.js`, `tests/frontend/ai.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-059** : dans une conversation ouverte (post ancré en haut), **tout clic** dans la fenêtre de messages — lien de fichier, étapes, sélection de texte — faisait sauter toute la conversation au bas de la fenêtre. Cause : le gestionnaire `mousedown` de dépintage (prévu pour la molette/tactile/poignée de scroll) se déclenchait aussi sur un simple clic, et le retrait du padding d'ancre (`paddingBottom`) bornait le `scrollTop` à la nouvelle hauteur max → saut au bas. Correctif : helper pur `isScrollbarPress(target, clientX, container)` — un appui ne dépine que s'il vise la **poignée de scroll** (cible = conteneur + zone de gouttière droite) ; molette et tactile conservent leur comportement. Vérifié : `ai.test.mjs` 92/92 (+1), unit 9/9, validate-imports 38 modules, pytest / ruff / mypy inchangés côté backend. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-17 | BUG-060 | Correction | `frontend/js/viewer.js`, `.gitea/workflows/ci.yml`, `tests/frontend/pdf-viewer.test.mjs` (nouveau), `tests/e2e/pdf-viewer.spec.js` (nouveau), `test_vault/sample-pdf.pdf` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-060** : l'ouverture d'un PDF n'affichait aucune page (barre d'outils « PDF — N pages » présente, corps vide). Cause : la CSP durcie en BUG-034 pose `object-src 'none'` — directive qui gouverne ``/`` — alors que le viewer rendait le PDF via `` : le lecteur natif était bloqué. Correctif : rendu dans une `