fix: corrige 6 bugs mineurs (BUG-035 a BUG-040)
This commit is contained in:
@@ -7,6 +7,11 @@ OBSIGATE_AUTH_ENABLED=true
|
|||||||
OBSIGATE_ADMIN_USER=admin
|
OBSIGATE_ADMIN_USER=admin
|
||||||
OBSIGATE_ADMIN_PASSWORD=chab30
|
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)
|
# Sécurité des cookies (activer si derrière HTTPS)
|
||||||
# OBSIGATE_SECURE_COOKIES=false
|
# OBSIGATE_SECURE_COOKIES=false
|
||||||
|
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ jobs:
|
|||||||
-e DIR_1_NAME=TestDir \
|
-e DIR_1_NAME=TestDir \
|
||||||
-e DIR_1_PATH=/vaults/TestDir \
|
-e DIR_1_PATH=/vaults/TestDir \
|
||||||
-e OBSIGATE_AUTH_ENABLED=false \
|
-e OBSIGATE_AUTH_ENABLED=false \
|
||||||
|
-e OBSIGATE_ALLOW_INSECURE=true \
|
||||||
obsigate:ci
|
obsigate:ci
|
||||||
# Docker-in-docker : le bind mount $(pwd)/... pointe sur un chemin
|
# Docker-in-docker : le bind mount $(pwd)/... pointe sur un chemin
|
||||||
# du job container, inexistant sur l'hôte → montage vide. Les -v
|
# du job container, inexistant sur l'hôte → montage vide. Les -v
|
||||||
|
|||||||
+42
-1
@@ -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).
|
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
|
> **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
|
## [2.11.2] — 2026-09-17
|
||||||
|
|
||||||
### Modifié
|
### Modifié
|
||||||
|
|||||||
+3
-3
@@ -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.
|
**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.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -926,8 +926,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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*
|
||||||
|
|||||||
@@ -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.
|
**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.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1095,8 +1095,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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*
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Request
|
from fastapi import Depends, HTTPException, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
@@ -17,6 +18,9 @@ logger = logging.getLogger("obsigate.auth.middleware")
|
|||||||
|
|
||||||
security = HTTPBearer(auto_error=False)
|
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:
|
def is_auth_enabled() -> bool:
|
||||||
"""Check if authentication is enabled via environment variable.
|
"""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"
|
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(
|
def get_current_user(
|
||||||
request: Request,
|
request: Request,
|
||||||
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
credentials: HTTPAuthorizationCredentials | None = Depends(security),
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
# backend/auth/password.py
|
# backend/auth/password.py
|
||||||
# Argon2id password hashing — OWASP 2024 recommended algorithm.
|
# 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 import PasswordHasher
|
||||||
from argon2.exceptions import VerificationError, VerifyMismatchError
|
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(
|
ph = PasswordHasher(
|
||||||
time_cost=2,
|
time_cost=ARGON2_TIME_COST,
|
||||||
memory_cost=65536, # 64 MB
|
memory_cost=ARGON2_MEMORY_COST_KIB,
|
||||||
parallelism=2,
|
parallelism=ARGON2_PARALLELISM,
|
||||||
hash_len=32,
|
hash_len=32,
|
||||||
salt_len=16,
|
salt_len=16,
|
||||||
)
|
)
|
||||||
|
|||||||
+18
-17
@@ -124,31 +124,32 @@ async def auth_status():
|
|||||||
async def login(body: LoginRequest, response: Response, request: Request):
|
async def login(body: LoginRequest, response: Response, request: Request):
|
||||||
"""Authenticate a user. Returns access token and sets refresh cookie.
|
"""Authenticate a user. Returns access token and sets refresh cookie.
|
||||||
|
|
||||||
Implements timing-safe responses to prevent user enumeration:
|
Implements timing-safe responses to prevent user enumeration: a failed
|
||||||
a failed login with an unknown user takes the same time as one
|
login with an unknown user takes the same time as one with a known user
|
||||||
with a known user (dummy hash is computed).
|
(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)
|
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
|
# Timing-safe: simulate hash computation to prevent user enumeration
|
||||||
hash_password("dummy_timing_protection")
|
hash_password("dummy_timing_protection")
|
||||||
raise HTTPException(401, "Identifiants invalides")
|
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.
|
# BUG-031: per-account budget still applies when the attacker rotates IPs.
|
||||||
if is_account_rate_limited(body.username):
|
# Kept indistinguishable from a wrong password (BUG-039).
|
||||||
raise HTTPException(429, "Trop de tentatives sur ce compte (15min)")
|
if is_account_rate_limited(body.username) or is_locked(body.username):
|
||||||
|
hash_password("dummy_timing_protection")
|
||||||
if is_locked(body.username):
|
raise HTTPException(401, "Identifiants invalides")
|
||||||
raise HTTPException(429, "Compte temporairement verrouillé (15min)")
|
|
||||||
|
|
||||||
if not verify_password(body.password, user["password_hash"]):
|
if not verify_password(body.password, user["password_hash"]):
|
||||||
attempts = record_login_failure(body.username)
|
attempts = record_login_failure(body.username)
|
||||||
|
|||||||
+14
-4
@@ -44,6 +44,9 @@ MAX_UPDATE_BYTES = 8 * 1024 * 1024
|
|||||||
#: Taille maximale d'un snapshot texte (protection anti-abus).
|
#: Taille maximale d'un snapshot texte (protection anti-abus).
|
||||||
MAX_TEXT_CHARS = 8 * 1024 * 1024
|
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).
|
#: Palette de couleurs attribuées aux utilisateurs (curseurs + avatars).
|
||||||
PEER_COLORS = [
|
PEER_COLORS = [
|
||||||
"#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4",
|
"#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4",
|
||||||
@@ -68,9 +71,13 @@ def authenticate_websocket(websocket: WebSocket) -> dict[str, Any] | None:
|
|||||||
"""Authenticate a WebSocket connection.
|
"""Authenticate a WebSocket connection.
|
||||||
|
|
||||||
Mirrors :func:`backend.auth.middleware.get_current_user` but works on the
|
Mirrors :func:`backend.auth.middleware.get_current_user` but works on the
|
||||||
WebSocket scope: the JWT is read from the ``access_token`` cookie (sent
|
WebSocket scope: the JWT is read from the ``access_token`` cookie, which
|
||||||
automatically by same-origin browsers during the handshake) or, as a
|
same-origin browsers send automatically during the handshake.
|
||||||
fallback, from the ``token`` query parameter.
|
|
||||||
|
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.
|
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_vaults": ["*"],
|
||||||
}
|
}
|
||||||
|
|
||||||
token = websocket.query_params.get("token") or websocket.cookies.get("access_token")
|
token = websocket.cookies.get("access_token")
|
||||||
if not token:
|
if not token:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -274,6 +281,9 @@ class CollabManager:
|
|||||||
|
|
||||||
# -- message handling ---------------------------------------------------
|
# -- message handling ---------------------------------------------------
|
||||||
async def _on_message(self, room: CollabRoom, client: CollabClient, raw: str) -> None:
|
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:
|
try:
|
||||||
message = json.loads(raw)
|
message = json.loads(raw)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
|
|||||||
+73
-5
@@ -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)
|
# PDF handling — special path (binary, uses pdf_reader)
|
||||||
tags: list[str] = []
|
tags: list[str] = []
|
||||||
|
pdf_text_pending = False
|
||||||
if ext == ".pdf":
|
if ext == ".pdf":
|
||||||
from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text
|
from backend.pdf_reader import extract_pdf_metadata
|
||||||
raw = extract_pdf_text(fpath, max_chars=100000)
|
# 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)
|
pdf_meta = extract_pdf_metadata(fpath)
|
||||||
title = pdf_meta.get("title") or fpath.stem.replace("-", " ").replace("_", " ")
|
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"):
|
elif ext == ".excalidraw" or fpath.name.lower().endswith(".excalidraw.md"):
|
||||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||||
raw = extract_excalidraw_indexable(raw)
|
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
|
title, post.content
|
||||||
)
|
)
|
||||||
|
|
||||||
files.append({
|
file_info = {
|
||||||
"path": str(relative).replace("\\", "/"),
|
"path": str(relative).replace("\\", "/"),
|
||||||
"title": title,
|
"title": title,
|
||||||
"tags": tags,
|
"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,
|
"size": stat.st_size,
|
||||||
"modified": modified,
|
"modified": modified,
|
||||||
"extension": ext,
|
"extension": ext,
|
||||||
})
|
}
|
||||||
|
if pdf_text_pending:
|
||||||
|
file_info["pdf_text_pending"] = True
|
||||||
|
files.append(file_info)
|
||||||
|
|
||||||
for tag in tags:
|
for tag in tags:
|
||||||
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
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": {}}
|
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:
|
async def build_index(progress_callback=None) -> None:
|
||||||
"""Build the full in-memory index for all configured vaults.
|
"""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.
|
Dict mapping vault names to their file/tag counts.
|
||||||
"""
|
"""
|
||||||
await build_index()
|
await build_index()
|
||||||
|
# BUG-040: complete the deferred PDF extraction for the rebuilt index.
|
||||||
|
await enrich_pdf_texts()
|
||||||
stats = {}
|
stats = {}
|
||||||
for name, data in index.items():
|
for name, data in index.items():
|
||||||
stats[name] = {"file_count": len(data["files"]), "tag_count": len(data["tags"])}
|
stats[name] = {"file_count": len(data["files"]), "tag_count": len(data["tags"])}
|
||||||
@@ -696,6 +761,9 @@ async def reload_single_vault(vault_name: str) -> dict[str, Any]:
|
|||||||
from backend.attachment_indexer import build_attachment_index
|
from backend.attachment_indexer import build_attachment_index
|
||||||
await build_attachment_index({vault_name: config})
|
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"])}
|
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")
|
logger.info(f"Vault '{vault_name}' reindexed: {stats['file_count']} files, {stats['tag_count']} tags")
|
||||||
return stats
|
return stats
|
||||||
|
|||||||
@@ -722,12 +722,56 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
return response
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Application lifespan: build index on startup, cleanup on shutdown."""
|
"""Application lifespan: build index on startup, cleanup on shutdown."""
|
||||||
global _search_executor, _vault_watcher
|
global _search_executor, _vault_watcher
|
||||||
_search_executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="search")
|
_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 account if needed
|
||||||
bootstrap_admin()
|
bootstrap_admin()
|
||||||
|
|
||||||
@@ -748,6 +792,11 @@ async def lifespan(app: FastAPI):
|
|||||||
# Build the semantic (embedding) index in the same background thread pool.
|
# Build the semantic (embedding) index in the same background thread pool.
|
||||||
await loop.run_in_executor(_search_executor, init_semantic_index)
|
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
|
# Scan for plugins in all vaults
|
||||||
logger.info("Scanning for plugins...")
|
logger.info("Scanning for plugins...")
|
||||||
from backend.indexer import vault_config
|
from backend.indexer import vault_config
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ _PATTERNS = [
|
|||||||
(re.compile(r'(?:api[_-]?key|apikey|secret|token|password|passwd|auth[_-]?token)\s*[:=]\s*[\'"]?([^\s\'"]{20,})[\'"]?', re.IGNORECASE),
|
(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É]'),
|
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]'),
|
(re.compile(r'(?:sk|pk|rk)-[a-zA-Z0-9]{20,}'), '[CLÉ API MASQUÉE]'),
|
||||||
|
|
||||||
# AWS access keys
|
# AWS access keys
|
||||||
@@ -43,10 +43,50 @@ _PATTERNS = [
|
|||||||
# GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_)
|
# GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_)
|
||||||
(re.compile(r'gh[pousr]_[a-zA-Z0-9]{36,}'), '[GITHUB_TOKEN MASQUÉ]'),
|
(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:
|
def redact(text: str) -> tuple:
|
||||||
"""Redact sensitive patterns from text.
|
"""Redact sensitive patterns from text.
|
||||||
@@ -66,6 +106,8 @@ def redact(text: str) -> tuple:
|
|||||||
new_result, n = pattern.subn(str(replacement), result)
|
new_result, n = pattern.subn(str(replacement), result)
|
||||||
count += n
|
count += n
|
||||||
result = new_result
|
result = new_result
|
||||||
|
result, hex_count = _redact_bare_hex_secrets(result)
|
||||||
|
count += hex_count
|
||||||
if count > 0:
|
if count > 0:
|
||||||
logger.info(f"Redacted {count} secret(s) from content")
|
logger.info(f"Redacted {count} secret(s) from content")
|
||||||
return result, count
|
return result, count
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.11.2"
|
version = "2.11.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.11.2"
|
version = "2.11.3"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.11.2",
|
"version": "2.11.3",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
@@ -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-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-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-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-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 | 🔴 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-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 | 🔴 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-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 | 🔴 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-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) | 🔴 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-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 | 🔴 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-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-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-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) |
|
| *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-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-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 `<embed>`/`<object>` — alors que le viewer rendait le PDF via `<embed type="application/pdf">` : le lecteur natif était bloqué. Correctif : rendu dans une `<iframe>` (autorisée par `frame-src 'self'`, le stream `/api/file/{vault}/pdf/stream` étant same-origin) ; `object-src 'none'` conservé. Tests : `pdf-viewer.test.mjs` 6/6 (statique : pas d'`<embed>`, CSP `frame-src 'self'`, iframe pleine hauteur), `pdf-viewer.spec.js` (E2E : iframe + stream `application/pdf` 200/206 + zéro violation CSP ; échoue bien avec l'ancien `<embed>`). Vérifié : pytest 1184 passed / 6 skipped, frontend 14 suites JSDOM vertes, validate-imports 38 modules, ruff/mypy 0. | 🟢 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 `<embed>`/`<object>` — alors que le viewer rendait le PDF via `<embed type="application/pdf">` : le lecteur natif était bloqué. Correctif : rendu dans une `<iframe>` (autorisée par `frame-src 'self'`, le stream `/api/file/{vault}/pdf/stream` étant same-origin) ; `object-src 'none'` conservé. Tests : `pdf-viewer.test.mjs` 6/6 (statique : pas d'`<embed>`, CSP `frame-src 'self'`, iframe pleine hauteur), `pdf-viewer.spec.js` (E2E : iframe + stream `application/pdf` 200/206 + zéro violation CSP ; échoue bien avec l'ancien `<embed>`). Vérifié : pytest 1184 passed / 6 skipped, frontend 14 suites JSDOM vertes, validate-imports 38 modules, ruff/mypy 0. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
|
| 2026-09-17 | BUG-035, BUG-036, BUG-037, BUG-038, BUG-039, BUG-040 | Correction | `backend/secret_redactor.py`, `backend/collab.py`, `backend/auth/{middleware,password,router}.py`, `backend/indexer.py`, `backend/main.py`, `tests/test_api_main.py`, `tests/test_auth.py`, `tests/test_auth_api.py`, `tests/test_collab.py`, `tests/test_pdf.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **Lot de 6 bugs mineurs (P2/P3)** : BUG-035 masquage hex conditionné au contexte (git/SHA épargnés) ; BUG-036 jeton WebSocket cookie-only (plus de `?token=`) + plafond de trame 16 Mio ; BUG-037 garde-fou au démarrage (refus d'un bind public sans auth sauf `OBSIGATE_ALLOW_INSECURE=true`) ; BUG-038 Argon2 recalibré 19 Mio/t=2/p=1 ; BUG-039 login uniforme 401 (fini 429/403 distinctifs) ; BUG-040 extraction PDF différée via `enrich_pdf_texts()`. Vérifié : pytest 1204 passed / 6 skipped, ruff 0, mypy 0 (77 fichiers), frontend validate-imports 38 modules + unit 9/9. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# ObsiGate — Roadmap
|
||||||
|
|
||||||
> **Version :** 2.11.2 | **Dernière mise à jour :** 2026-09-17
|
> **Version :** 2.11.3 | **Dernière mise à jour :** 2026-09-17
|
||||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||||
@@ -196,7 +196,7 @@
|
|||||||
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93, #94–100, #102, #92 | ~114 jours réalisés |
|
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93, #94–100, #102, #92 | ~114 jours réalisés |
|
||||||
| 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour |
|
| 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour |
|
||||||
| ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
|
| ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
|
||||||
| ⚪ P0/P1 restant | #85-87 Refonte architecturale, performance, CI/CD (issues BUG-035 → BUG-040) | ~15-23 jours |
|
| ⚪ P0/P1 restant | #85-87 Refonte architecturale, performance, CI/CD (BUG-035 → BUG-040 corrigés) | ~15-23 jours |
|
||||||
| **Total restant** | **7 items + finitions** | **~27-42 jours** |
|
| **Total restant** | **7 items + finitions** | **~27-42 jours** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -109,11 +109,14 @@ Serveur → client :
|
|||||||
|
|
||||||
## Sécurité
|
## Sécurité
|
||||||
|
|
||||||
- Authentification obligatoire si `OBSIGATE_AUTH_ENABLED=true` (cookie ou `?token=`).
|
- Authentification obligatoire si `OBSIGATE_AUTH_ENABLED=true` : le jeton est lu depuis le cookie
|
||||||
|
HttpOnly `access_token` (envoyé lors du handshake same-origin). Le jeton en query string
|
||||||
|
(`?token=`) n'est **plus accepté** (BUG-036 : URLs journalisées par les proxies).
|
||||||
- Vérification `check_vault_access()` par connexion (un utilisateur ne peut pas rejoindre une room
|
- Vérification `check_vault_access()` par connexion (un utilisateur ne peut pas rejoindre une room
|
||||||
d'une vault non autorisée).
|
d'une vault non autorisée).
|
||||||
- `resolve_safe_path()` empêche toute traversée de chemin (`../../`).
|
- `resolve_safe_path()` empêche toute traversée de chemin (`../../`).
|
||||||
- Bornes anti-abus : `MAX_UPDATE_BYTES` (8 Mo) par mise à jour, `MAX_TEXT_CHARS` (8 Mio) par snapshot.
|
- Bornes anti-abus : `MAX_UPDATE_BYTES` (8 Mo) par mise à jour, `MAX_TEXT_CHARS` (8 Mio) par snapshot,
|
||||||
|
`MAX_MESSAGE_CHARS` (16 Mio) par trame brute.
|
||||||
- Le serveur ne décode pas le binaire Yjs : il le stocke et le relaie tel quel (pas de surface
|
- Le serveur ne décode pas le binaire Yjs : il le stocke et le relaie tel quel (pas de surface
|
||||||
d'attaque supplémentaire côté parsing).
|
d'attaque supplémentaire côté parsing).
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"name": "obsigate",
|
||||||
"version": "2.11.2",
|
"version": "2.11.3",
|
||||||
"description": "**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.",
|
"description": "**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.",
|
||||||
"main": "patch.js",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -743,6 +743,33 @@ class TestSecretRedactor:
|
|||||||
result = redact_file_content("hello world this is safe")
|
result = redact_file_content("hello world this is safe")
|
||||||
assert result == "hello world this is safe"
|
assert result == "hello world this is safe"
|
||||||
|
|
||||||
|
def test_git_sha_not_redacted(self):
|
||||||
|
"""BUG-035: a bare git commit SHA must not be mangled."""
|
||||||
|
from backend.secret_redactor import redact_file_content
|
||||||
|
sha = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678"
|
||||||
|
text = f"commit {sha}\nMerge: {sha}"
|
||||||
|
assert redact_file_content(text) == text
|
||||||
|
|
||||||
|
def test_sha256_checksum_not_redacted(self):
|
||||||
|
from backend.secret_redactor import redact_file_content
|
||||||
|
digest = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||||
|
text = f"sha256:{digest} file.tar.gz"
|
||||||
|
assert redact_file_content(text) == text
|
||||||
|
|
||||||
|
def test_hex_secret_in_context_is_redacted(self):
|
||||||
|
from backend.secret_redactor import redact_file_content
|
||||||
|
secret = "0123456789abcdef0123456789abcdef01234567"
|
||||||
|
result = redact_file_content(f"api_key={secret}")
|
||||||
|
assert secret not in result
|
||||||
|
assert "MASQUÉ" in result
|
||||||
|
|
||||||
|
def test_ambiguous_bare_hex_left_intact(self):
|
||||||
|
"""A 40-char hex with no secret/hash keyword stays untouched."""
|
||||||
|
from backend.secret_redactor import redact_file_content
|
||||||
|
blob = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
|
||||||
|
text = f"value {blob} end"
|
||||||
|
assert redact_file_content(text) == text
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
# Static / PWA caching (Cloudflare / mobile freshness)
|
# Static / PWA caching (Cloudflare / mobile freshness)
|
||||||
|
|||||||
@@ -44,6 +44,22 @@ class TestPasswordHashing:
|
|||||||
result = hash_password("ab")
|
result = hash_password("ab")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
|
|
||||||
|
def test_argon2_memory_recalibrated(self):
|
||||||
|
"""BUG-038: memory cost must stay at the OWASP 19 MiB recommendation."""
|
||||||
|
from backend.auth.password import (
|
||||||
|
ARGON2_MEMORY_COST_KIB,
|
||||||
|
ARGON2_PARALLELISM,
|
||||||
|
ARGON2_TIME_COST,
|
||||||
|
ph,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ARGON2_MEMORY_COST_KIB == 19456
|
||||||
|
assert ARGON2_TIME_COST == 2
|
||||||
|
assert ARGON2_PARALLELISM == 1
|
||||||
|
assert ph.memory_cost == 19456
|
||||||
|
assert ph.time_cost == 2
|
||||||
|
assert ph.parallelism == 1
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
# JWT Handler
|
# JWT Handler
|
||||||
@@ -279,3 +295,61 @@ class TestMiddleware:
|
|||||||
assert check_vault_access("Vault1", user) is True
|
assert check_vault_access("Vault1", user) is True
|
||||||
assert check_vault_access("Vault3", user) is False
|
assert check_vault_access("Vault3", user) is False
|
||||||
assert check_vault_access("Vault1", nobody) is False
|
assert check_vault_access("Vault1", nobody) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# Insecure (auth-disabled) deployment guard — BUG-037
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestInsecureAuthGuard:
|
||||||
|
def test_is_loopback_host(self):
|
||||||
|
from backend.auth.middleware import is_loopback_host
|
||||||
|
|
||||||
|
assert is_loopback_host(None) is True
|
||||||
|
assert is_loopback_host("127.0.0.1") is True
|
||||||
|
assert is_loopback_host("::1") is True
|
||||||
|
assert is_loopback_host("[::1]") is True
|
||||||
|
assert is_loopback_host("localhost") is True
|
||||||
|
assert is_loopback_host("0.0.0.0") is False
|
||||||
|
assert is_loopback_host("192.168.1.10") is False
|
||||||
|
|
||||||
|
def test_bind_host_from_argv(self):
|
||||||
|
from backend.auth.middleware import bind_host_from_argv
|
||||||
|
|
||||||
|
assert bind_host_from_argv(
|
||||||
|
["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
|
) == "0.0.0.0"
|
||||||
|
assert bind_host_from_argv(["uvicorn", "app", "--host=127.0.0.1"]) == "127.0.0.1"
|
||||||
|
assert bind_host_from_argv(["uvicorn", "app"]) is None
|
||||||
|
|
||||||
|
def test_guard_refuses_public_bind_without_optin(self, monkeypatch):
|
||||||
|
from backend import main
|
||||||
|
|
||||||
|
monkeypatch.setenv("OBSIGATE_AUTH_ENABLED", "false")
|
||||||
|
monkeypatch.delenv("OBSIGATE_ALLOW_INSECURE", raising=False)
|
||||||
|
monkeypatch.setattr("sys.argv", ["uvicorn", "backend.main:app", "--host", "0.0.0.0"])
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
main._guard_insecure_auth()
|
||||||
|
|
||||||
|
def test_guard_allows_loopback(self, monkeypatch):
|
||||||
|
from backend import main
|
||||||
|
|
||||||
|
monkeypatch.setenv("OBSIGATE_AUTH_ENABLED", "false")
|
||||||
|
monkeypatch.delenv("OBSIGATE_ALLOW_INSECURE", raising=False)
|
||||||
|
monkeypatch.setattr("sys.argv", ["uvicorn", "backend.main:app", "--host", "127.0.0.1"])
|
||||||
|
main._guard_insecure_auth() # must not raise
|
||||||
|
|
||||||
|
def test_guard_allows_explicit_optin(self, monkeypatch):
|
||||||
|
from backend import main
|
||||||
|
|
||||||
|
monkeypatch.setenv("OBSIGATE_AUTH_ENABLED", "false")
|
||||||
|
monkeypatch.setenv("OBSIGATE_ALLOW_INSECURE", "true")
|
||||||
|
monkeypatch.setattr("sys.argv", ["uvicorn", "backend.main:app", "--host", "0.0.0.0"])
|
||||||
|
main._guard_insecure_auth() # must not raise
|
||||||
|
|
||||||
|
def test_guard_noop_when_auth_enabled(self, monkeypatch):
|
||||||
|
from backend import main
|
||||||
|
|
||||||
|
monkeypatch.setenv("OBSIGATE_AUTH_ENABLED", "true")
|
||||||
|
monkeypatch.setattr("sys.argv", ["uvicorn", "backend.main:app", "--host", "0.0.0.0"])
|
||||||
|
main._guard_insecure_auth() # must not raise
|
||||||
|
|||||||
@@ -137,6 +137,42 @@ class TestLogin:
|
|||||||
})
|
})
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_locked_account_returns_401_not_429(self, auth_client, monkeypatch):
|
||||||
|
"""BUG-039: a locked account must be indistinguishable from an unknown one."""
|
||||||
|
import backend.auth.router as auth_router
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth_router, "is_locked", lambda username: True)
|
||||||
|
resp = auth_client.post("/api/auth/login", json={
|
||||||
|
"username": "admin",
|
||||||
|
"password": "chab30",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert "verrouill" not in resp.json()["detail"].lower()
|
||||||
|
|
||||||
|
def test_account_rate_limited_returns_401(self, auth_client, monkeypatch):
|
||||||
|
"""BUG-039: per-account throttling must not reveal the account exists."""
|
||||||
|
import backend.auth.router as auth_router
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth_router, "is_account_rate_limited", lambda username: True)
|
||||||
|
resp = auth_client.post("/api/auth/login", json={
|
||||||
|
"username": "admin",
|
||||||
|
"password": "chab30",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_inactive_account_returns_401(self, auth_client, monkeypatch):
|
||||||
|
"""BUG-039: a disabled account answers like an unknown user."""
|
||||||
|
import backend.auth.router as auth_router
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth_router, "get_user", lambda username: {
|
||||||
|
"username": username, "active": False, "password_hash": "x",
|
||||||
|
})
|
||||||
|
resp = auth_client.post("/api/auth/login", json={
|
||||||
|
"username": "admin",
|
||||||
|
"password": "chab30",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
def test_login_remember_me(self, auth_client):
|
def test_login_remember_me(self, auth_client):
|
||||||
resp = auth_client.post("/api/auth/login", json={
|
resp = auth_client.post("/api/auth/login", json={
|
||||||
"username": "admin",
|
"username": "admin",
|
||||||
|
|||||||
@@ -73,6 +73,36 @@ def test_authenticate_websocket_invalid_token_returns_none(monkeypatch):
|
|||||||
assert authenticate_websocket(_StubWebSocket(cookies={"access_token": "garbage"})) is None
|
assert authenticate_websocket(_StubWebSocket(cookies={"access_token": "garbage"})) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticate_websocket_query_token_rejected(monkeypatch):
|
||||||
|
"""BUG-036: the access token must never be accepted from the query string."""
|
||||||
|
monkeypatch.setenv("OBSIGATE_AUTH_ENABLED", "true")
|
||||||
|
from backend.auth.jwt_handler import create_access_token
|
||||||
|
|
||||||
|
token = create_access_token({
|
||||||
|
"username": "u", "role": "user", "vaults": ["*"], "display_name": "U",
|
||||||
|
})
|
||||||
|
ws = _StubWebSocket(query={"token": token})
|
||||||
|
assert authenticate_websocket(ws) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticate_websocket_cookie_token_accepted(monkeypatch):
|
||||||
|
"""The HttpOnly access_token cookie remains the supported transport."""
|
||||||
|
monkeypatch.setenv("OBSIGATE_AUTH_ENABLED", "true")
|
||||||
|
import backend.auth.user_store as user_store
|
||||||
|
from backend.auth.jwt_handler import create_access_token
|
||||||
|
|
||||||
|
token = create_access_token({
|
||||||
|
"username": "u", "role": "user", "vaults": ["*"], "display_name": "U",
|
||||||
|
})
|
||||||
|
monkeypatch.setattr(user_store, "get_user", lambda username: {
|
||||||
|
"username": username, "role": "user", "vaults": ["*"],
|
||||||
|
"display_name": "U", "active": True,
|
||||||
|
})
|
||||||
|
user = authenticate_websocket(_StubWebSocket(cookies={"access_token": token}))
|
||||||
|
assert user is not None
|
||||||
|
assert user["username"] == "u"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Manager unit tests (no WebSocket transport)
|
# Manager unit tests (no WebSocket transport)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -127,6 +157,27 @@ async def test_on_message_rejects_oversized_update(tmp_path: Path):
|
|||||||
assert room.updates == []
|
assert room.updates == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_on_message_rejects_oversized_raw(tmp_path: Path):
|
||||||
|
"""BUG-036: oversized raw frames are dropped before parsing."""
|
||||||
|
target = tmp_path / "note.md"
|
||||||
|
target.write_text("x", encoding="utf-8")
|
||||||
|
manager = _make_manager(tmp_path)
|
||||||
|
room = CollabRoom(vault="V", path="note.md", file_path=target)
|
||||||
|
client = _FakeClient(conn_id=1)
|
||||||
|
|
||||||
|
import backend.collab as collab_mod
|
||||||
|
|
||||||
|
original = collab_mod.MAX_MESSAGE_CHARS
|
||||||
|
try:
|
||||||
|
collab_mod.MAX_MESSAGE_CHARS = 10
|
||||||
|
await manager._on_message(room, client, json.dumps({"type": "text", "text": "hello"}))
|
||||||
|
finally:
|
||||||
|
collab_mod.MAX_MESSAGE_CHARS = original
|
||||||
|
|
||||||
|
assert room.pending_text is None
|
||||||
|
|
||||||
|
|
||||||
class _FakeWebSocket:
|
class _FakeWebSocket:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.sent: list[dict] = []
|
self.sent: list[dict] = []
|
||||||
|
|||||||
+90
-3
@@ -275,6 +275,7 @@ class TestPdfIndexing:
|
|||||||
"""When a vault directory is scanned with .pdf files, they appear in files list.
|
"""When a vault directory is scanned with .pdf files, they appear in files list.
|
||||||
|
|
||||||
Uses the public _scan_vault() helper directly — no global state needed.
|
Uses the public _scan_vault() helper directly — no global state needed.
|
||||||
|
BUG-040: text extraction is deferred, so the scan only carries metadata.
|
||||||
"""
|
"""
|
||||||
from backend.indexer import _scan_vault
|
from backend.indexer import _scan_vault
|
||||||
|
|
||||||
@@ -286,10 +287,96 @@ class TestPdfIndexing:
|
|||||||
names = {f["path"] for f in result["files"]}
|
names = {f["path"] for f in result["files"]}
|
||||||
assert "a.pdf" in names
|
assert "a.pdf" in names
|
||||||
assert "b.pdf" in names
|
assert "b.pdf" in names
|
||||||
# The PDF content should have been extracted.
|
# The scan defers the expensive text extraction.
|
||||||
a_file = next(f for f in result["files"] if f["path"] == "a.pdf")
|
a_file = next(f for f in result["files"] if f["path"] == "a.pdf")
|
||||||
assert "ObsiGate test PDF" in (a_file.get("content") or "")
|
assert a_file["content"] == ""
|
||||||
assert "uniqueword0" in (a_file.get("content") or "")
|
assert a_file["pdf_text_pending"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestPdfLazyEnrichment:
|
||||||
|
"""BUG-040: PDF text is extracted in a deferred background pass."""
|
||||||
|
|
||||||
|
def test_scan_defers_pdf_text_extraction(self, pdf_dir: Path, tmp_path: Path):
|
||||||
|
from backend.indexer import _scan_vault
|
||||||
|
|
||||||
|
vault_root = tmp_path / "vault"
|
||||||
|
vault_root.mkdir()
|
||||||
|
shutil.copy2(pdf_dir / "simple.pdf", vault_root / "a.pdf")
|
||||||
|
result = _scan_vault("v", str(vault_root), {})
|
||||||
|
a_file = next(f for f in result["files"] if f["path"] == "a.pdf")
|
||||||
|
assert a_file["content"] == ""
|
||||||
|
assert a_file["content_preview"] == ""
|
||||||
|
assert a_file["pdf_text_pending"] is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enrich_pdf_texts_fills_content_and_clears_flag(
|
||||||
|
self, pdf_dir: Path, tmp_path: Path
|
||||||
|
):
|
||||||
|
import backend.indexer as idx
|
||||||
|
|
||||||
|
vault_root = tmp_path / "vault"
|
||||||
|
vault_root.mkdir()
|
||||||
|
shutil.copy2(pdf_dir / "simple.pdf", vault_root / "a.pdf")
|
||||||
|
file_info = {
|
||||||
|
"path": "a.pdf",
|
||||||
|
"title": "a",
|
||||||
|
"tags": [],
|
||||||
|
"content": "",
|
||||||
|
"content_preview": "",
|
||||||
|
"size": 0,
|
||||||
|
"modified": "",
|
||||||
|
"extension": ".pdf",
|
||||||
|
"pdf_text_pending": True,
|
||||||
|
}
|
||||||
|
with idx._index_lock:
|
||||||
|
idx.index["LazyV"] = {
|
||||||
|
"files": [file_info],
|
||||||
|
"tags": {},
|
||||||
|
"path": str(vault_root),
|
||||||
|
"paths": [],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
count = await idx.enrich_pdf_texts("LazyV")
|
||||||
|
assert count == 1
|
||||||
|
assert "ObsiGate test PDF" in file_info["content"]
|
||||||
|
assert "uniqueword0" in file_info["content"]
|
||||||
|
assert file_info["content_preview"]
|
||||||
|
assert "pdf_text_pending" not in file_info
|
||||||
|
finally:
|
||||||
|
with idx._index_lock:
|
||||||
|
idx.index.pop("LazyV", None)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enrich_pdf_texts_skips_other_vaults(self, pdf_dir: Path, tmp_path: Path):
|
||||||
|
import backend.indexer as idx
|
||||||
|
|
||||||
|
vault_root = tmp_path / "vault"
|
||||||
|
vault_root.mkdir()
|
||||||
|
shutil.copy2(pdf_dir / "simple.pdf", vault_root / "a.pdf")
|
||||||
|
file_info = {
|
||||||
|
"path": "a.pdf",
|
||||||
|
"title": "a",
|
||||||
|
"tags": [],
|
||||||
|
"content": "",
|
||||||
|
"content_preview": "",
|
||||||
|
"size": 0,
|
||||||
|
"modified": "",
|
||||||
|
"extension": ".pdf",
|
||||||
|
"pdf_text_pending": True,
|
||||||
|
}
|
||||||
|
with idx._index_lock:
|
||||||
|
idx.index["OtherV"] = {
|
||||||
|
"files": [file_info],
|
||||||
|
"tags": {},
|
||||||
|
"path": str(vault_root),
|
||||||
|
"paths": [],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
assert await idx.enrich_pdf_texts("LazyV") == 0
|
||||||
|
assert file_info["content"] == ""
|
||||||
|
finally:
|
||||||
|
with idx._index_lock:
|
||||||
|
idx.index.pop("OtherV", None)
|
||||||
|
|
||||||
|
|
||||||
# ── Search filter `ext:` ───────────────────────────────────────────────────
|
# ── Search filter `ext:` ───────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user