fix(security): consolidation & securite phase 1 (#84, BUG-021 a BUG-034)
- sanitizer XSS serveur (markdown + page de partage) [BUG-021/022] - rate-limit/lockout MFA [BUG-023] - isolation vaults par segments [BUG-024] - caps regex ReDoS [BUG-025] - SSRF webhooks + secrets externalises [BUG-026] - rotation/revocation des jetons [BUG-027] - politique de mot de passe + invalidation sessions [BUG-028] - verrous users.json [BUG-029] - IP reelle dans les audits [BUG-030] - rate-limit par compte [BUG-031] - symlinks hors vault ignores [BUG-032] - recherche simple via inverted index [BUG-033] - token en memoire + cookie HttpOnly, CSP durcie [BUG-034] Tests: pytest 961 passed / 6 skipped, ruff 0, mypy 0, frontend vert.
This commit is contained in:
@@ -16,8 +16,17 @@ OBSIGATE_ADMIN_PASSWORD=chab30
|
|||||||
|
|
||||||
# Rate limiting
|
# Rate limiting
|
||||||
# OBSIGATE_LOGIN_MAX_ATTEMPTS=10
|
# OBSIGATE_LOGIN_MAX_ATTEMPTS=10
|
||||||
|
# OBSIGATE_ACCOUNT_MAX_ATTEMPTS=10
|
||||||
# OBSIGATE_LOGIN_WINDOW_SECONDS=900
|
# OBSIGATE_LOGIN_WINDOW_SECONDS=900
|
||||||
|
|
||||||
|
# IP client derrière un reverse proxy (fait confiance à X-Forwarded-For)
|
||||||
|
# OBSIGATE_TRUST_PROXY=false
|
||||||
|
|
||||||
|
# Webhooks : sécurité SSRF
|
||||||
|
# OBSIGATE_WEBHOOK_ALLOW_HTTP=false # autoriser http:// (défaut : HTTPS requis)
|
||||||
|
# OBSIGATE_WEBHOOK_ALLOW_PRIVATE=false # autoriser les IP privées/boucle
|
||||||
|
# Secret d'un webhook : OBSIGATE_WEBHOOK_SECRET_<ID_WEBHOOK_EN_MAJUSCULES>
|
||||||
|
|
||||||
# Watcher
|
# Watcher
|
||||||
# OBSIGATE_WATCHER_ENABLED=true
|
# OBSIGATE_WATCHER_ENABLED=true
|
||||||
# OBSIGATE_WATCHER_USE_POLLING=false
|
# OBSIGATE_WATCHER_USE_POLLING=false
|
||||||
|
|||||||
@@ -12,6 +12,46 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Sécurité
|
||||||
|
|
||||||
|
- **#84 Consolidation & sécurité — phase 1 (BUG-021 → BUG-034)** — traitement des
|
||||||
|
vulnérabilités de la revue statique du 2026-09-13 :
|
||||||
|
- **BUG-021/022 — XSS stocké** : nouveau sanitizer serveur en liste blanche
|
||||||
|
(`backend/services/sanitizer.py`, stdlib) appliqué au rendu markdown ; échappement
|
||||||
|
systématique du `title`, du frontmatter et du JSON de la page publique `/s/{token}`
|
||||||
|
(`</script>` neutralisé). Tests : `tests/test_security_hardening.py` (9).
|
||||||
|
- **BUG-023 — brute-force MFA** : rate-limit IP + compte et verrouillage de compte sur
|
||||||
|
`mfa/totp/verify`, `mfa/recovery` et `mfa/webauthn/verify` (`_enforce_mfa_rate_limit`).
|
||||||
|
- **BUG-024 — traversal inter-vaults** : `resolve_safe_path` compare désormais les chemins
|
||||||
|
par **segment** (`Path.relative_to` + repli casse-insensible), plus par préfixe de chaîne :
|
||||||
|
`vault` ne peut plus lire `vault-evil`.
|
||||||
|
- **BUG-025 — ReDoS** : validation des regex utilisateur (longueur max, rejet des
|
||||||
|
quantificateurs imbriqués/backrefs), contenu tronqué et nombre de matchs plafonné
|
||||||
|
(`backend/services/regex_safety.py`), appliqué à la recherche avancée et au find/replace.
|
||||||
|
- **BUG-026 — SSRF webhooks** : validation d'URL (HTTPS par défaut, IP privées/boucle
|
||||||
|
interdites, résolution DNS vérifiée au dispatch, redirections non suivies) et
|
||||||
|
**externalisation du secret** dans `data/webhook_secrets.json` (0600) ou variable
|
||||||
|
`OBSIGATE_WEBHOOK_SECRET_<ID>` (plus de secret en clair dans `webhooks.json`).
|
||||||
|
- **BUG-027 — sessions** : rotation du refresh token à chaque usage, révocation du JTI de
|
||||||
|
l'access token au logout, et vérification de la révocation dans le middleware.
|
||||||
|
- **BUG-028 — politique de mot de passe** : validation centralisée (8–128 caractères) à la
|
||||||
|
création, à la modification admin et au changement ; `password_changed_at` invalide tous
|
||||||
|
les jetons émis avant un changement de mot de passe.
|
||||||
|
- **BUG-029 — race `users.json`** : verrou `threading.RLock` autour des cycles
|
||||||
|
lecture-modification-écriture.
|
||||||
|
- **BUG-030 — audits** : l'adresse IP réelle du client (`X-Forwarded-For` si
|
||||||
|
`OBSIGATE_TRUST_PROXY=true`) est injectée dans `current_user` et consignée dans les audits.
|
||||||
|
- **BUG-031 — rate-limiter** : budget **par compte** en plus du budget par IP (rotation d'IP
|
||||||
|
neutralisée) ; limite mono-process documentée.
|
||||||
|
- **BUG-032 — indexation** : `_scan_vault` utilise `os.walk(followlinks=False)` et refuse
|
||||||
|
tout symlink sortant de la racine du vault.
|
||||||
|
- **BUG-033 — recherche O(N)** : la recherche simple et l'outil IA `search_fulltext`
|
||||||
|
utilisent l'inverted index (repli sur le scan pendant la construction).
|
||||||
|
- **BUG-034 — CSP & jetons** : le token d'accès n'est plus persisté dans `sessionStorage`
|
||||||
|
(mémoire + cookie `HttpOnly`) ; directives CSP durcies (`object-src`, `base-uri`,
|
||||||
|
`form-action`, `frame-ancestors`). *Reste : migration CSP par nonce (exige la conversion
|
||||||
|
des gestionnaires d'événements inline).*
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|
||||||
- **#82 Assistant IA — menu `@` instantané** — nouvel endpoint `GET /api/vault/{vault}/paths`
|
- **#82 Assistant IA — menu `@` instantané** — nouvel endpoint `GET /api/vault/{vault}/paths`
|
||||||
|
|||||||
@@ -275,7 +275,11 @@ Un compte **admin** connecté voit une icône 🛡️ dans le header : liste, cr
|
|||||||
| `OBSIGATE_ACCESS_TOKEN_TTL` | Durée de vie token JWT (secondes) | `3600` |
|
| `OBSIGATE_ACCESS_TOKEN_TTL` | Durée de vie token JWT (secondes) | `3600` |
|
||||||
| `OBSIGATE_REFRESH_TOKEN_TTL` | Durée de vie refresh token (secondes) | `2592000` |
|
| `OBSIGATE_REFRESH_TOKEN_TTL` | Durée de vie refresh token (secondes) | `2592000` |
|
||||||
| `OBSIGATE_LOGIN_MAX_ATTEMPTS` | Tentatives de login max par IP | `10` |
|
| `OBSIGATE_LOGIN_MAX_ATTEMPTS` | Tentatives de login max par IP | `10` |
|
||||||
|
| `OBSIGATE_ACCOUNT_MAX_ATTEMPTS` | Tentatives de login max par compte | `10` |
|
||||||
| `OBSIGATE_LOGIN_WINDOW_SECONDS` | Fenêtre de rate limiting (secondes) | `900` |
|
| `OBSIGATE_LOGIN_WINDOW_SECONDS` | Fenêtre de rate limiting (secondes) | `900` |
|
||||||
|
| `OBSIGATE_TRUST_PROXY` | Faire confiance à `X-Forwarded-For` pour l'IP client (reverse proxy) | `false` |
|
||||||
|
| `OBSIGATE_WEBHOOK_ALLOW_HTTP` | Autoriser les webhooks non HTTPS | `false` |
|
||||||
|
| `OBSIGATE_WEBHOOK_ALLOW_PRIVATE` | Autoriser les webhooks vers des adresses privées/boucle | `false` |
|
||||||
| `OBSIGATE_PDF_MAX_SIZE_MB` | Taille max des PDF extraits (text indexation) | `50` |
|
| `OBSIGATE_PDF_MAX_SIZE_MB` | Taille max des PDF extraits (text indexation) | `50` |
|
||||||
| `OBSIGATE_PDF_EXTRACT_TIMEOUT` | Timeout extraction PDF (secondes) | `30` |
|
| `OBSIGATE_PDF_EXTRACT_TIMEOUT` | Timeout extraction PDF (secondes) | `30` |
|
||||||
|
|
||||||
|
|||||||
@@ -313,7 +313,11 @@ When an **admin** account is logged in, a 🛡️ icon appears in the header. Cl
|
|||||||
| `OBSIGATE_ACCESS_TOKEN_TTL` | JWT token lifetime (seconds) | `3600` |
|
| `OBSIGATE_ACCESS_TOKEN_TTL` | JWT token lifetime (seconds) | `3600` |
|
||||||
| `OBSIGATE_REFRESH_TOKEN_TTL` | Refresh token lifetime (seconds) | `2592000` |
|
| `OBSIGATE_REFRESH_TOKEN_TTL` | Refresh token lifetime (seconds) | `2592000` |
|
||||||
| `OBSIGATE_LOGIN_MAX_ATTEMPTS` | Max login attempts per IP | `10` |
|
| `OBSIGATE_LOGIN_MAX_ATTEMPTS` | Max login attempts per IP | `10` |
|
||||||
|
| `OBSIGATE_ACCOUNT_MAX_ATTEMPTS` | Max login attempts per account | `10` |
|
||||||
| `OBSIGATE_LOGIN_WINDOW_SECONDS` | Rate limiting window (seconds) | `900` |
|
| `OBSIGATE_LOGIN_WINDOW_SECONDS` | Rate limiting window (seconds) | `900` |
|
||||||
|
| `OBSIGATE_TRUST_PROXY` | Trust `X-Forwarded-For` for the client IP (reverse proxy) | `false` |
|
||||||
|
| `OBSIGATE_WEBHOOK_ALLOW_HTTP` | Allow non-HTTPS webhook targets | `false` |
|
||||||
|
| `OBSIGATE_WEBHOOK_ALLOW_PRIVATE` | Allow webhooks to private/loopback addresses | `false` |
|
||||||
| `OBSIGATE_PDF_MAX_SIZE_MB` | Max PDF size for text extraction | `50` |
|
| `OBSIGATE_PDF_MAX_SIZE_MB` | Max PDF size for text extraction | `50` |
|
||||||
| `OBSIGATE_PDF_EXTRACT_TIMEOUT` | PDF extraction timeout (seconds) | `30` |
|
| `OBSIGATE_PDF_EXTRACT_TIMEOUT` | PDF extraction timeout (seconds) | `30` |
|
||||||
|
|
||||||
|
|||||||
@@ -62,16 +62,21 @@ def create_access_token(user: dict) -> str:
|
|||||||
return jwt.encode(payload, get_secret_key(), algorithm=ALGORITHM)
|
return jwt.encode(payload, get_secret_key(), algorithm=ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
def create_refresh_token(username: str) -> tuple:
|
def create_refresh_token(username: str, remember: bool = False) -> tuple:
|
||||||
"""Create a JWT refresh token. Returns (token_string, jti)."""
|
"""Create a JWT refresh token. Returns (token_string, jti).
|
||||||
|
|
||||||
|
``remember`` is carried as a claim so token rotation can preserve the
|
||||||
|
30-day vs 7-day lifetime chosen at login.
|
||||||
|
"""
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
jti = str(uuid.uuid4())
|
jti = str(uuid.uuid4())
|
||||||
payload = {
|
payload = {
|
||||||
"sub": username,
|
"sub": username,
|
||||||
"jti": jti,
|
"jti": jti,
|
||||||
"iat": now,
|
"iat": now,
|
||||||
"exp": now + REFRESH_TOKEN_EXPIRE_SECONDS,
|
"exp": now + (2592000 if remember else REFRESH_TOKEN_EXPIRE_SECONDS),
|
||||||
"type": "refresh",
|
"type": "refresh",
|
||||||
|
"remember": remember,
|
||||||
}
|
}
|
||||||
return jwt.encode(payload, get_secret_key(), algorithm=ALGORITHM), jti
|
return jwt.encode(payload, get_secret_key(), algorithm=ALGORITHM), jti
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import os
|
|||||||
from fastapi import Depends, HTTPException, Request
|
from fastapi import Depends, HTTPException, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
from .jwt_handler import decode_token
|
from backend.services.net import get_client_ip
|
||||||
|
|
||||||
|
from .jwt_handler import decode_token, is_token_revoked
|
||||||
from .user_store import get_user
|
from .user_store import get_user
|
||||||
|
|
||||||
logger = logging.getLogger("obsigate.auth.middleware")
|
logger = logging.getLogger("obsigate.auth.middleware")
|
||||||
@@ -42,6 +44,7 @@ def get_current_user(
|
|||||||
"vaults": ["*"],
|
"vaults": ["*"],
|
||||||
"active": True,
|
"active": True,
|
||||||
"_token_vaults": ["*"],
|
"_token_vaults": ["*"],
|
||||||
|
"_request_ip": get_client_ip(request),
|
||||||
}
|
}
|
||||||
|
|
||||||
token = None
|
token = None
|
||||||
@@ -57,14 +60,31 @@ def get_current_user(
|
|||||||
if not payload or payload.get("type") != "access":
|
if not payload or payload.get("type") != "access":
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# BUG-027: access tokens revoked at logout must be rejected immediately.
|
||||||
|
jti = payload.get("jti")
|
||||||
|
if jti and is_token_revoked(jti):
|
||||||
|
return None
|
||||||
|
|
||||||
user = get_user(payload["sub"])
|
user = get_user(payload["sub"])
|
||||||
if not user or not user.get("active"):
|
if not user or not user.get("active"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# BUG-028: a password change invalidates every token issued before it.
|
||||||
|
pca = user.get("password_changed_at")
|
||||||
|
iat = payload.get("iat")
|
||||||
|
if pca is not None and iat is not None:
|
||||||
|
try:
|
||||||
|
if int(iat) < int(float(pca)):
|
||||||
|
return None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
# Attach vault permissions from the token (snapshot at login time)
|
# Attach vault permissions from the token (snapshot at login time)
|
||||||
user["_token_vaults"] = payload.get("vaults", [])
|
user["_token_vaults"] = payload.get("vaults", [])
|
||||||
# Attach the token id for per-token rate limiting (AI tool layer).
|
# Attach the token id for per-token rate limiting (AI tool layer).
|
||||||
user["_token_jti"] = payload.get("jti")
|
user["_token_jti"] = payload.get("jti")
|
||||||
|
# BUG-030: expose the real client IP to the audit log.
|
||||||
|
user["_request_ip"] = get_client_ip(request)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,32 @@ ph = PasswordHasher(
|
|||||||
salt_len=16,
|
salt_len=16,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Password policy (BUG-028). Applied by the API validators at account creation
|
||||||
|
# and password change so the rules stay consistent across both paths.
|
||||||
|
MIN_PASSWORD_LENGTH = 8
|
||||||
|
MAX_PASSWORD_LENGTH = 128
|
||||||
|
|
||||||
|
|
||||||
|
def validate_password_strength(password: str) -> str:
|
||||||
|
"""Validate a plaintext password against the project policy.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
password: Candidate password.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The password unchanged when valid.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: When the password is too short, too long or blank.
|
||||||
|
"""
|
||||||
|
if password is None or len(password) < MIN_PASSWORD_LENGTH:
|
||||||
|
raise ValueError(f"Minimum {MIN_PASSWORD_LENGTH} caractères")
|
||||||
|
if len(password) > MAX_PASSWORD_LENGTH:
|
||||||
|
raise ValueError(f"Maximum {MAX_PASSWORD_LENGTH} caractères")
|
||||||
|
if not password.strip():
|
||||||
|
raise ValueError("Le mot de passe ne peut pas être vide")
|
||||||
|
return password
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
"""Hash a password with Argon2id."""
|
"""Hash a password with Argon2id."""
|
||||||
|
|||||||
+116
-16
@@ -8,9 +8,12 @@ import re
|
|||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
|
||||||
from pydantic import BaseModel, validator
|
from pydantic import BaseModel, validator
|
||||||
|
|
||||||
from backend.ratelimit import is_rate_limited
|
from backend.ratelimit import is_account_rate_limited, is_rate_limited
|
||||||
|
from backend.ratelimit import record_account_failure as rl_record_account_failure
|
||||||
|
from backend.ratelimit import record_account_success as rl_record_account_success
|
||||||
from backend.ratelimit import record_failure as rl_record_failure
|
from backend.ratelimit import record_failure as rl_record_failure
|
||||||
from backend.ratelimit import record_success as rl_record_success
|
from backend.ratelimit import record_success as rl_record_success
|
||||||
|
from backend.services.net import get_client_ip
|
||||||
|
|
||||||
from .jwt_handler import (
|
from .jwt_handler import (
|
||||||
ACCESS_TOKEN_EXPIRE_SECONDS,
|
ACCESS_TOKEN_EXPIRE_SECONDS,
|
||||||
@@ -29,7 +32,7 @@ from .mfa import (
|
|||||||
verify_totp,
|
verify_totp,
|
||||||
)
|
)
|
||||||
from .middleware import is_auth_enabled, require_admin, require_auth
|
from .middleware import is_auth_enabled, require_admin, require_auth
|
||||||
from .password import hash_password, verify_password
|
from .password import hash_password, validate_password_strength, verify_password
|
||||||
from .user_store import (
|
from .user_store import (
|
||||||
create_user,
|
create_user,
|
||||||
delete_user,
|
delete_user,
|
||||||
@@ -61,9 +64,7 @@ class ChangePasswordRequest(BaseModel):
|
|||||||
|
|
||||||
@validator("new_password")
|
@validator("new_password")
|
||||||
def password_strength(cls, v):
|
def password_strength(cls, v):
|
||||||
if len(v) < 8:
|
return validate_password_strength(v)
|
||||||
raise ValueError("Minimum 8 caractères")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class CreateUserRequest(BaseModel):
|
class CreateUserRequest(BaseModel):
|
||||||
@@ -73,6 +74,10 @@ class CreateUserRequest(BaseModel):
|
|||||||
role: str = "user"
|
role: str = "user"
|
||||||
vaults: list[str] = []
|
vaults: list[str] = []
|
||||||
|
|
||||||
|
@validator("password")
|
||||||
|
def password_valid(cls, v):
|
||||||
|
return validate_password_strength(v)
|
||||||
|
|
||||||
@validator("username")
|
@validator("username")
|
||||||
def username_valid(cls, v):
|
def username_valid(cls, v):
|
||||||
if not re.match(r"^[a-zA-Z0-9_-]{2,32}$", v):
|
if not re.match(r"^[a-zA-Z0-9_-]{2,32}$", v):
|
||||||
@@ -93,6 +98,12 @@ class UpdateUserRequest(BaseModel):
|
|||||||
password: str | None = None
|
password: str | None = None
|
||||||
role: str | None = None
|
role: str | None = None
|
||||||
|
|
||||||
|
@validator("password")
|
||||||
|
def password_valid(cls, v):
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
return validate_password_strength(v)
|
||||||
|
|
||||||
|
|
||||||
# ── Public endpoints ──────────────────────────────────────────────────
|
# ── Public endpoints ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -128,16 +139,21 @@ async def login(body: LoginRequest, response: Response, request: Request):
|
|||||||
raise HTTPException(403, "Compte désactivé")
|
raise HTTPException(403, "Compte désactivé")
|
||||||
|
|
||||||
# IP-based rate limiting (10 failures / 15 min per IP)
|
# IP-based rate limiting (10 failures / 15 min per IP)
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
client_ip = get_client_ip(request)
|
||||||
if is_rate_limited(client_ip):
|
if is_rate_limited(client_ip):
|
||||||
raise HTTPException(429, "Trop de tentatives depuis cette adresse IP (15min)")
|
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):
|
if is_locked(body.username):
|
||||||
raise HTTPException(429, "Compte temporairement verrouillé (15min)")
|
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)
|
||||||
rl_attempts, rl_remaining = rl_record_failure(client_ip)
|
rl_attempts, rl_remaining = rl_record_failure(client_ip)
|
||||||
|
rl_record_account_failure(body.username)
|
||||||
remaining = max(0, 5 - attempts)
|
remaining = max(0, 5 - attempts)
|
||||||
detail = "Identifiants invalides"
|
detail = "Identifiants invalides"
|
||||||
if 0 < remaining <= 2:
|
if 0 < remaining <= 2:
|
||||||
@@ -164,9 +180,10 @@ async def login(body: LoginRequest, response: Response, request: Request):
|
|||||||
def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response) -> dict:
|
def _issue_tokens(user: dict, username: str, remember_me: bool, response: Response) -> dict:
|
||||||
"""Issue JWT tokens after successful authentication (password or MFA verified)."""
|
"""Issue JWT tokens after successful authentication (password or MFA verified)."""
|
||||||
record_login_success(username)
|
record_login_success(username)
|
||||||
|
rl_record_account_success(username)
|
||||||
|
|
||||||
access_token = create_access_token(user)
|
access_token = create_access_token(user)
|
||||||
refresh_token, refresh_jti = create_refresh_token(username)
|
refresh_token, refresh_jti = create_refresh_token(username, remember=remember_me)
|
||||||
|
|
||||||
import os
|
import os
|
||||||
max_age = 2592000 if remember_me else 604800 # 30d or 7d
|
max_age = 2592000 if remember_me else 604800 # 30d or 7d
|
||||||
@@ -208,6 +225,8 @@ async def refresh_token_endpoint(request: Request, response: Response):
|
|||||||
"""Renew access token via refresh token cookie.
|
"""Renew access token via refresh token cookie.
|
||||||
|
|
||||||
Called automatically by the frontend when the access token expires.
|
Called automatically by the frontend when the access token expires.
|
||||||
|
The refresh token is rotated on every use (BUG-027) and rejected if it
|
||||||
|
predates the user's last password change (BUG-028).
|
||||||
"""
|
"""
|
||||||
refresh_tok = request.cookies.get("refresh_token")
|
refresh_tok = request.cookies.get("refresh_token")
|
||||||
if not refresh_tok:
|
if not refresh_tok:
|
||||||
@@ -224,11 +243,38 @@ async def refresh_token_endpoint(request: Request, response: Response):
|
|||||||
if not user or not user.get("active"):
|
if not user or not user.get("active"):
|
||||||
raise HTTPException(401, "Utilisateur introuvable ou inactif")
|
raise HTTPException(401, "Utilisateur introuvable ou inactif")
|
||||||
|
|
||||||
|
# BUG-028: reject refresh tokens issued before the last password change.
|
||||||
|
pca = user.get("password_changed_at")
|
||||||
|
iat = payload.get("iat")
|
||||||
|
if pca is not None and iat is not None:
|
||||||
|
try:
|
||||||
|
stale = int(iat) < int(float(pca))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
stale = True
|
||||||
|
if stale:
|
||||||
|
raise HTTPException(401, "Session expirée, veuillez vous reconnecter")
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
|
||||||
|
remember_me = bool(payload.get("remember", False))
|
||||||
|
|
||||||
|
# BUG-027: rotate the refresh token — the old one is now single-use.
|
||||||
|
revoke_token(payload["jti"])
|
||||||
|
new_refresh_token, _new_jti = create_refresh_token(user["username"], remember=remember_me)
|
||||||
|
max_age = 2592000 if remember_me else 604800
|
||||||
|
response.set_cookie(
|
||||||
|
key="refresh_token",
|
||||||
|
value=new_refresh_token,
|
||||||
|
max_age=max_age,
|
||||||
|
httponly=True,
|
||||||
|
samesite="strict",
|
||||||
|
secure=secure,
|
||||||
|
path="/api/auth/refresh",
|
||||||
|
)
|
||||||
|
|
||||||
new_access_token = create_access_token(user)
|
new_access_token = create_access_token(user)
|
||||||
|
|
||||||
# Update cookies
|
|
||||||
import os
|
|
||||||
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
|
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key="access_token",
|
key="access_token",
|
||||||
value=new_access_token,
|
value=new_access_token,
|
||||||
@@ -251,7 +297,7 @@ async def logout(
|
|||||||
request: Request,
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
):
|
):
|
||||||
"""Logout: revoke refresh token and delete cookies."""
|
"""Logout: revoke refresh and access tokens, then delete cookies."""
|
||||||
refresh_tok = request.cookies.get("refresh_token")
|
refresh_tok = request.cookies.get("refresh_token")
|
||||||
if refresh_tok:
|
if refresh_tok:
|
||||||
payload = decode_token(refresh_tok)
|
payload = decode_token(refresh_tok)
|
||||||
@@ -261,6 +307,21 @@ async def logout(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # token already revoked
|
pass # token already revoked
|
||||||
|
|
||||||
|
# BUG-027: revoke the access token too, otherwise it stays valid until expiry.
|
||||||
|
access_tok = None
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
if auth_header.lower().startswith("bearer "):
|
||||||
|
access_tok = auth_header[7:].strip()
|
||||||
|
if not access_tok:
|
||||||
|
access_tok = request.cookies.get("access_token")
|
||||||
|
if access_tok:
|
||||||
|
access_payload = decode_token(access_tok)
|
||||||
|
if access_payload and access_payload.get("type") == "access":
|
||||||
|
try:
|
||||||
|
revoke_token(access_payload["jti"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
response.delete_cookie("refresh_token", path="/api/auth/refresh")
|
response.delete_cookie("refresh_token", path="/api/auth/refresh")
|
||||||
response.delete_cookie("access_token", path="/")
|
response.delete_cookie("access_token", path="/")
|
||||||
response.delete_cookie("access_token", path="/api") # just in case
|
response.delete_cookie("access_token", path="/api") # just in case
|
||||||
@@ -313,19 +374,51 @@ async def patch_me(req: UpdateMeRequest, current_user=Depends(require_auth)):
|
|||||||
@router.post("/change-password")
|
@router.post("/change-password")
|
||||||
async def change_password(
|
async def change_password(
|
||||||
req: ChangePasswordRequest,
|
req: ChangePasswordRequest,
|
||||||
|
response: Response,
|
||||||
current_user=Depends(require_auth),
|
current_user=Depends(require_auth),
|
||||||
):
|
):
|
||||||
"""Change own password."""
|
"""Change own password.
|
||||||
|
|
||||||
|
BUG-028: changing the password invalidates all previously issued tokens;
|
||||||
|
a fresh pair is issued to keep the current session alive.
|
||||||
|
"""
|
||||||
user = get_user(current_user["username"])
|
user = get_user(current_user["username"])
|
||||||
assert user is not None, f"User {current_user['username']} not found"
|
assert user is not None, f"User {current_user['username']} not found"
|
||||||
if not verify_password(req.current_password, user["password_hash"]):
|
if not verify_password(req.current_password, user["password_hash"]):
|
||||||
raise HTTPException(400, "Mot de passe actuel incorrect")
|
raise HTTPException(400, "Mot de passe actuel incorrect")
|
||||||
update_user(current_user["username"], {"password": req.new_password})
|
update_user(current_user["username"], {"password": req.new_password})
|
||||||
return {"message": "Mot de passe mis à jour"}
|
updated = get_user(current_user["username"])
|
||||||
|
result: dict = {"message": "Mot de passe mis à jour"}
|
||||||
|
if updated is not None:
|
||||||
|
result.update(_issue_tokens(updated, updated["username"], False, response))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ── MFA endpoints ────────────────────────────────────────────────────
|
# ── MFA endpoints ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _enforce_mfa_rate_limit(request: Request, username: str) -> str:
|
||||||
|
"""Reject MFA attempts from a rate-limited IP or on a locked account.
|
||||||
|
|
||||||
|
BUG-023: the second-factor endpoints were previously unprotected, making
|
||||||
|
the 6-digit TOTP brute-forceable. Returns the resolved client IP.
|
||||||
|
"""
|
||||||
|
client_ip = get_client_ip(request)
|
||||||
|
if is_rate_limited(client_ip):
|
||||||
|
raise HTTPException(429, "Trop de tentatives depuis cette adresse IP (15min)")
|
||||||
|
if is_account_rate_limited(username):
|
||||||
|
raise HTTPException(429, "Trop de tentatives sur ce compte (15min)")
|
||||||
|
if is_locked(username):
|
||||||
|
raise HTTPException(429, "Compte temporairement verrouillé (15min)")
|
||||||
|
return client_ip
|
||||||
|
|
||||||
|
|
||||||
|
def _record_mfa_failure(client_ip: str, username: str) -> None:
|
||||||
|
"""Record a failed MFA attempt for the IP, the account and the lockout."""
|
||||||
|
record_login_failure(username)
|
||||||
|
rl_record_failure(client_ip)
|
||||||
|
rl_record_account_failure(username)
|
||||||
|
|
||||||
|
|
||||||
class MfaVerifyRequest(BaseModel):
|
class MfaVerifyRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
code: str
|
code: str
|
||||||
@@ -601,6 +694,8 @@ async def mfa_webauthn_verify(
|
|||||||
from .user_store import get_user, update_user
|
from .user_store import get_user, update_user
|
||||||
from .webauthn_mfa import complete_authentication
|
from .webauthn_mfa import complete_authentication
|
||||||
|
|
||||||
|
client_ip = _enforce_mfa_rate_limit(request, body.username)
|
||||||
|
|
||||||
user = get_user(body.username)
|
user = get_user(body.username)
|
||||||
if not user:
|
if not user:
|
||||||
hash_password("dummy_timing_protection")
|
hash_password("dummy_timing_protection")
|
||||||
@@ -616,8 +711,10 @@ async def mfa_webauthn_verify(
|
|||||||
raise ValueError("Credential non enregistré")
|
raise ValueError("Credential non enregistré")
|
||||||
new_count = complete_authentication(body.username, body.credential, stored)
|
new_count = complete_authentication(body.username, body.credential, stored)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
_record_mfa_failure(client_ip, body.username)
|
||||||
raise HTTPException(401, str(e))
|
raise HTTPException(401, str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
_record_mfa_failure(client_ip, body.username)
|
||||||
logger.warning(f"WebAuthn verification failed for {body.username}: {e}")
|
logger.warning(f"WebAuthn verification failed for {body.username}: {e}")
|
||||||
raise HTTPException(401, "Vérification WebAuthn échouée")
|
raise HTTPException(401, "Vérification WebAuthn échouée")
|
||||||
|
|
||||||
@@ -627,7 +724,6 @@ async def mfa_webauthn_verify(
|
|||||||
c["sign_count"] = new_count
|
c["sign_count"] = new_count
|
||||||
update_user(body.username, {"webauthn_credentials": updated})
|
update_user(body.username, {"webauthn_credentials": updated})
|
||||||
|
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
|
||||||
rl_record_success(client_ip)
|
rl_record_success(client_ip)
|
||||||
logger.info(f"User '{body.username}' logged in via WebAuthn")
|
logger.info(f"User '{body.username}' logged in via WebAuthn")
|
||||||
return _issue_tokens(user, body.username, body.remember_me, response)
|
return _issue_tokens(user, body.username, body.remember_me, response)
|
||||||
@@ -655,6 +751,8 @@ async def mfa_totp_verify(body: MfaVerifyRequest, response: Response, request: R
|
|||||||
"""
|
"""
|
||||||
from .user_store import get_user
|
from .user_store import get_user
|
||||||
|
|
||||||
|
client_ip = _enforce_mfa_rate_limit(request, body.username)
|
||||||
|
|
||||||
user = get_user(body.username)
|
user = get_user(body.username)
|
||||||
if not user:
|
if not user:
|
||||||
# Timing-safe: simulate work
|
# Timing-safe: simulate work
|
||||||
@@ -665,10 +763,10 @@ async def mfa_totp_verify(body: MfaVerifyRequest, response: Response, request: R
|
|||||||
raise HTTPException(400, "MFA non activé pour cet utilisateur")
|
raise HTTPException(400, "MFA non activé pour cet utilisateur")
|
||||||
|
|
||||||
if not verify_totp(user["mfa_secret"], body.code):
|
if not verify_totp(user["mfa_secret"], body.code):
|
||||||
|
_record_mfa_failure(client_ip, body.username)
|
||||||
raise HTTPException(401, "Code TOTP invalide")
|
raise HTTPException(401, "Code TOTP invalide")
|
||||||
|
|
||||||
# Clear IP rate limit on success
|
# Clear IP rate limit on success
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
|
||||||
rl_record_success(client_ip)
|
rl_record_success(client_ip)
|
||||||
|
|
||||||
return _issue_tokens(user, body.username, body.remember_me, response)
|
return _issue_tokens(user, body.username, body.remember_me, response)
|
||||||
@@ -682,6 +780,8 @@ async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, reque
|
|||||||
"""
|
"""
|
||||||
from .user_store import get_user, update_user
|
from .user_store import get_user, update_user
|
||||||
|
|
||||||
|
client_ip = _enforce_mfa_rate_limit(request, body.username)
|
||||||
|
|
||||||
user = get_user(body.username)
|
user = get_user(body.username)
|
||||||
if not user:
|
if not user:
|
||||||
hash_password("dummy_timing_protection")
|
hash_password("dummy_timing_protection")
|
||||||
@@ -696,6 +796,7 @@ async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, reque
|
|||||||
|
|
||||||
idx = verify_recovery_code(body.recovery_code, hashed_codes)
|
idx = verify_recovery_code(body.recovery_code, hashed_codes)
|
||||||
if idx is None:
|
if idx is None:
|
||||||
|
_record_mfa_failure(client_ip, body.username)
|
||||||
raise HTTPException(401, "Code de récupération invalide")
|
raise HTTPException(401, "Code de récupération invalide")
|
||||||
|
|
||||||
# Remove used recovery code (single-use)
|
# Remove used recovery code (single-use)
|
||||||
@@ -703,7 +804,6 @@ async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, reque
|
|||||||
update_user(body.username, {"mfa_recovery_codes": hashed_codes})
|
update_user(body.username, {"mfa_recovery_codes": hashed_codes})
|
||||||
|
|
||||||
# Clear IP rate limit
|
# Clear IP rate limit
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
|
||||||
rl_record_success(client_ip)
|
rl_record_success(client_ip)
|
||||||
|
|
||||||
logger.info(f"User '{body.username}' logged in via recovery code")
|
logger.info(f"User '{body.username}' logged in via recovery code")
|
||||||
|
|||||||
+64
-49
@@ -6,6 +6,7 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -16,6 +17,12 @@ logger = logging.getLogger("obsigate.auth.users")
|
|||||||
|
|
||||||
USERS_FILE = Path("data/users.json")
|
USERS_FILE = Path("data/users.json")
|
||||||
|
|
||||||
|
# Serialises read-modify-write cycles on users.json. ``RLock`` because a few
|
||||||
|
# helpers (e.g. ``record_login_failure``) call other mutators while holding it.
|
||||||
|
# BUG-029: without this, concurrent MFA enable + password change could lose one
|
||||||
|
# of the two updates (last writer wins).
|
||||||
|
_users_lock = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
def _read() -> dict:
|
def _read() -> dict:
|
||||||
"""Read users.json. Returns empty structure if file doesn't exist."""
|
"""Read users.json. Returns empty structure if file doesn't exist."""
|
||||||
@@ -75,26 +82,28 @@ def create_user(
|
|||||||
display_name: str | None = None,
|
display_name: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a new user. Raises ValueError if username already taken."""
|
"""Create a new user. Raises ValueError if username already taken."""
|
||||||
data = _read()
|
with _users_lock:
|
||||||
if username in data["users"]:
|
data = _read()
|
||||||
raise ValueError(f"User '{username}' already exists")
|
if username in data["users"]:
|
||||||
|
raise ValueError(f"User '{username}' already exists")
|
||||||
|
|
||||||
user = {
|
user = {
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"username": username,
|
"username": username,
|
||||||
"display_name": display_name or username,
|
"display_name": display_name or username,
|
||||||
"password_hash": hash_password(password),
|
"password_hash": hash_password(password),
|
||||||
"role": role,
|
"role": role,
|
||||||
"vaults": vaults or [],
|
"vaults": vaults or [],
|
||||||
"active": True,
|
"active": True,
|
||||||
"language": "fr", # default UI language
|
"language": "fr", # default UI language
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"last_login": None,
|
"password_changed_at": datetime.now(timezone.utc).timestamp(),
|
||||||
"failed_attempts": 0,
|
"last_login": None,
|
||||||
"locked_until": None,
|
"failed_attempts": 0,
|
||||||
}
|
"locked_until": None,
|
||||||
data["users"][username] = user
|
}
|
||||||
_write(data)
|
data["users"][username] = user
|
||||||
|
_write(data)
|
||||||
logger.info(f"Created user '{username}' (role={role})")
|
logger.info(f"Created user '{username}' (role={role})")
|
||||||
return {k: v for k, v in user.items() if k != "password_hash"}
|
return {k: v for k, v in user.items() if k != "password_hash"}
|
||||||
|
|
||||||
@@ -105,28 +114,33 @@ def update_user(username: str, updates: dict) -> dict:
|
|||||||
Forbidden fields (id, username, created_at) are silently ignored.
|
Forbidden fields (id, username, created_at) are silently ignored.
|
||||||
If 'password' is in updates, it's hashed and stored as password_hash.
|
If 'password' is in updates, it's hashed and stored as password_hash.
|
||||||
"""
|
"""
|
||||||
data = _read()
|
with _users_lock:
|
||||||
if username not in data["users"]:
|
data = _read()
|
||||||
raise ValueError(f"User '{username}' not found")
|
if username not in data["users"]:
|
||||||
|
raise ValueError(f"User '{username}' not found")
|
||||||
|
|
||||||
forbidden = {"id", "username", "created_at"}
|
forbidden = {"id", "username", "created_at"}
|
||||||
safe_updates = {k: v for k, v in updates.items() if k not in forbidden}
|
safe_updates = {k: v for k, v in updates.items() if k not in forbidden}
|
||||||
|
|
||||||
if "password" in safe_updates:
|
if "password" in safe_updates:
|
||||||
safe_updates["password_hash"] = hash_password(safe_updates.pop("password"))
|
safe_updates["password_hash"] = hash_password(safe_updates.pop("password"))
|
||||||
|
# BUG-028: invalidate every token issued before this change.
|
||||||
|
safe_updates["password_changed_at"] = datetime.now(timezone.utc).timestamp()
|
||||||
|
|
||||||
data["users"][username].update(safe_updates)
|
data["users"][username].update(safe_updates)
|
||||||
_write(data)
|
_write(data)
|
||||||
return {k: v for k, v in data["users"][username].items() if k != "password_hash"}
|
result = {k: v for k, v in data["users"][username].items() if k != "password_hash"}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def delete_user(username: str):
|
def delete_user(username: str):
|
||||||
"""Delete a user. Raises ValueError if not found."""
|
"""Delete a user. Raises ValueError if not found."""
|
||||||
data = _read()
|
with _users_lock:
|
||||||
if username not in data["users"]:
|
data = _read()
|
||||||
raise ValueError(f"User '{username}' not found")
|
if username not in data["users"]:
|
||||||
del data["users"][username]
|
raise ValueError(f"User '{username}' not found")
|
||||||
_write(data)
|
del data["users"][username]
|
||||||
|
_write(data)
|
||||||
logger.info(f"Deleted user '{username}'")
|
logger.info(f"Deleted user '{username}'")
|
||||||
|
|
||||||
|
|
||||||
@@ -144,24 +158,25 @@ def record_login_failure(username: str) -> int:
|
|||||||
|
|
||||||
After 5 failures, locks the account for 15 minutes.
|
After 5 failures, locks the account for 15 minutes.
|
||||||
"""
|
"""
|
||||||
data = _read()
|
with _users_lock:
|
||||||
user = data["users"].get(username)
|
data = _read()
|
||||||
if not user:
|
user = data["users"].get(username)
|
||||||
return 0
|
if not user:
|
||||||
|
return 0
|
||||||
|
|
||||||
attempts = user.get("failed_attempts", 0) + 1
|
attempts = user.get("failed_attempts", 0) + 1
|
||||||
updates = {"failed_attempts": attempts}
|
updates = {"failed_attempts": attempts}
|
||||||
|
|
||||||
# Lock after 5 failed attempts (15 minutes)
|
# Lock after 5 failed attempts (15 minutes)
|
||||||
if attempts >= 5:
|
if attempts >= 5:
|
||||||
locked_until = (
|
locked_until = (
|
||||||
datetime.now(timezone.utc) + timedelta(minutes=15)
|
datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||||
).isoformat()
|
).isoformat()
|
||||||
updates["locked_until"] = locked_until
|
updates["locked_until"] = locked_until
|
||||||
logger.warning(f"Account '{username}' locked after {attempts} failed attempts")
|
logger.warning(f"Account '{username}' locked after {attempts} failed attempts")
|
||||||
|
|
||||||
update_user(username, updates)
|
update_user(username, updates)
|
||||||
return attempts
|
return attempts
|
||||||
|
|
||||||
|
|
||||||
def is_locked(username: str) -> bool:
|
def is_locked(username: str) -> bool:
|
||||||
|
|||||||
+95
-76
@@ -423,94 +423,113 @@ def _scan_vault(vault_name: str, vault_path: str, vault_cfg: dict[str, Any] | No
|
|||||||
logger.warning(f"Vault path does not exist: {vault_path}")
|
logger.warning(f"Vault path does not exist: {vault_path}")
|
||||||
return {"files": [], "tags": {}, "path": vault_path, "paths": []}
|
return {"files": [], "tags": {}, "path": vault_path, "paths": []}
|
||||||
|
|
||||||
for fpath in vault_root.rglob("*"):
|
root_resolved = vault_root.resolve(strict=False)
|
||||||
# Skip ignored directories
|
|
||||||
if any(part in IGNORED_DIRS for part in fpath.relative_to(vault_root).parts):
|
|
||||||
continue
|
|
||||||
|
|
||||||
rel_path_str = str(fpath.relative_to(vault_root)).replace("\\", "/")
|
# BUG-032: walk without following symlinks and refuse any symlink that
|
||||||
|
# escapes the vault root, so external data can never be indexed/exposed.
|
||||||
# Add all paths (files and directories) to path index
|
for dirpath, dirnames, filenames in os.walk(vault_root, followlinks=False):
|
||||||
if fpath.is_dir():
|
current_dir = Path(dirpath)
|
||||||
|
|
||||||
|
# Prune ignored and symlinked directories in place (no recursion).
|
||||||
|
dirnames[:] = [
|
||||||
|
d for d in dirnames
|
||||||
|
if d not in IGNORED_DIRS and not (current_dir / d).is_symlink()
|
||||||
|
]
|
||||||
|
|
||||||
|
for d in dirnames:
|
||||||
|
dpath = current_dir / d
|
||||||
|
rel_path_str = str(dpath.relative_to(vault_root)).replace("\\", "/")
|
||||||
paths.append({
|
paths.append({
|
||||||
"path": rel_path_str,
|
"path": rel_path_str,
|
||||||
"name": fpath.name,
|
"name": d,
|
||||||
"type": "directory"
|
"type": "directory"
|
||||||
})
|
})
|
||||||
continue
|
|
||||||
|
|
||||||
# Files only from here
|
|
||||||
if not fpath.is_file():
|
|
||||||
continue
|
|
||||||
ext = fpath.suffix.lower()
|
|
||||||
# Also match extensionless files named like Dockerfile, Makefile
|
|
||||||
basename_lower = fpath.name.lower()
|
|
||||||
if ext not in SUPPORTED_EXTENSIONS and basename_lower not in ("dockerfile", "makefile", "cmakelists.txt"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Add file to path index
|
|
||||||
paths.append({
|
|
||||||
"path": rel_path_str,
|
|
||||||
"name": fpath.name,
|
|
||||||
"type": "file"
|
|
||||||
})
|
|
||||||
|
|
||||||
try:
|
|
||||||
relative = fpath.relative_to(vault_root)
|
|
||||||
stat = fpath.stat()
|
|
||||||
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
|
|
||||||
|
|
||||||
# PDF handling — special path (binary, uses pdf_reader)
|
for fname in filenames:
|
||||||
tags: list[str] = []
|
fpath = current_dir / fname
|
||||||
if ext == ".pdf":
|
|
||||||
from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text
|
|
||||||
raw = extract_pdf_text(fpath, max_chars=100000)
|
|
||||||
pdf_meta = extract_pdf_metadata(fpath)
|
|
||||||
title = pdf_meta.get("title") or fpath.stem.replace("-", " ").replace("_", " ")
|
|
||||||
content_preview = raw[:200].strip()
|
|
||||||
elif ext == ".excalidraw" or fpath.name.lower().endswith(".excalidraw.md"):
|
|
||||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
|
||||||
raw = extract_excalidraw_indexable(raw)
|
|
||||||
title = fpath.stem.replace(".excalidraw", "").replace("-", " ").replace("_", " ")
|
|
||||||
content_preview = raw[:200].strip()
|
|
||||||
else:
|
|
||||||
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
|
||||||
title = fpath.stem.replace("-", " ").replace("_", " ")
|
|
||||||
content_preview = raw[:200].strip()
|
|
||||||
|
|
||||||
if ext == ".md":
|
if fpath.is_symlink():
|
||||||
post = parse_markdown_file(raw)
|
try:
|
||||||
tags = _extract_tags(post)
|
target = fpath.resolve(strict=True)
|
||||||
inline_tags = _extract_inline_tags(post.content)
|
except OSError:
|
||||||
tags = list(set(tags) | set(inline_tags))
|
continue
|
||||||
title = _extract_title(post, fpath)
|
try:
|
||||||
content_preview = post.content[:200].strip()
|
target.relative_to(root_resolved)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(f"Skipping symlink outside vault: {fpath}")
|
||||||
|
continue
|
||||||
|
|
||||||
_extract_wikilinks_for_backlinks(
|
rel_path_str = str(fpath.relative_to(vault_root)).replace("\\", "/")
|
||||||
vault_name, str(relative).replace("\\", "/"),
|
|
||||||
title, post.content
|
|
||||||
)
|
|
||||||
|
|
||||||
files.append({
|
ext = fpath.suffix.lower()
|
||||||
"path": str(relative).replace("\\", "/"),
|
# Also match extensionless files named like Dockerfile, Makefile
|
||||||
"title": title,
|
basename_lower = fpath.name.lower()
|
||||||
"tags": tags,
|
if ext not in SUPPORTED_EXTENSIONS and basename_lower not in ("dockerfile", "makefile", "cmakelists.txt"):
|
||||||
"content_preview": content_preview,
|
continue
|
||||||
"content": raw[:SEARCH_CONTENT_LIMIT],
|
|
||||||
"size": stat.st_size,
|
# Add file to path index
|
||||||
"modified": modified,
|
paths.append({
|
||||||
"extension": ext,
|
"path": rel_path_str,
|
||||||
|
"name": fname,
|
||||||
|
"type": "file"
|
||||||
})
|
})
|
||||||
|
|
||||||
for tag in tags:
|
try:
|
||||||
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
relative = fpath.relative_to(vault_root)
|
||||||
|
stat = fpath.stat()
|
||||||
|
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
|
||||||
|
|
||||||
except PermissionError:
|
# PDF handling — special path (binary, uses pdf_reader)
|
||||||
logger.debug(f"Permission denied, skipping {fpath}")
|
tags: list[str] = []
|
||||||
continue
|
if ext == ".pdf":
|
||||||
except Exception as e:
|
from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text
|
||||||
logger.error(f"Error indexing {fpath}: {e}")
|
raw = extract_pdf_text(fpath, max_chars=100000)
|
||||||
continue
|
pdf_meta = extract_pdf_metadata(fpath)
|
||||||
|
title = pdf_meta.get("title") or fpath.stem.replace("-", " ").replace("_", " ")
|
||||||
|
content_preview = raw[:200].strip()
|
||||||
|
elif ext == ".excalidraw" or fpath.name.lower().endswith(".excalidraw.md"):
|
||||||
|
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||||
|
raw = extract_excalidraw_indexable(raw)
|
||||||
|
title = fpath.stem.replace(".excalidraw", "").replace("-", " ").replace("_", " ")
|
||||||
|
content_preview = raw[:200].strip()
|
||||||
|
else:
|
||||||
|
raw = fpath.read_text(encoding="utf-8", errors="replace")
|
||||||
|
title = fpath.stem.replace("-", " ").replace("_", " ")
|
||||||
|
content_preview = raw[:200].strip()
|
||||||
|
|
||||||
|
if ext == ".md":
|
||||||
|
post = parse_markdown_file(raw)
|
||||||
|
tags = _extract_tags(post)
|
||||||
|
inline_tags = _extract_inline_tags(post.content)
|
||||||
|
tags = list(set(tags) | set(inline_tags))
|
||||||
|
title = _extract_title(post, fpath)
|
||||||
|
content_preview = post.content[:200].strip()
|
||||||
|
|
||||||
|
_extract_wikilinks_for_backlinks(
|
||||||
|
vault_name, str(relative).replace("\\", "/"),
|
||||||
|
title, post.content
|
||||||
|
)
|
||||||
|
|
||||||
|
files.append({
|
||||||
|
"path": str(relative).replace("\\", "/"),
|
||||||
|
"title": title,
|
||||||
|
"tags": tags,
|
||||||
|
"content_preview": content_preview,
|
||||||
|
"content": raw[:SEARCH_CONTENT_LIMIT],
|
||||||
|
"size": stat.st_size,
|
||||||
|
"modified": modified,
|
||||||
|
"extension": ext,
|
||||||
|
})
|
||||||
|
|
||||||
|
for tag in tags:
|
||||||
|
tag_counts[tag] = tag_counts.get(tag, 0) + 1
|
||||||
|
|
||||||
|
except PermissionError:
|
||||||
|
logger.debug(f"Permission denied, skipping {fpath}")
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error indexing {fpath}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
logger.info(f"Vault '{vault_name}': indexed {len(files)} files, {len(paths)} paths, {len(tag_counts)} unique tags")
|
logger.info(f"Vault '{vault_name}': indexed {len(files)} files, {len(paths)} paths, {len(tag_counts)} unique tags")
|
||||||
return {"files": files, "tags": tag_counts, "path": vault_path, "paths": paths, "config": {}}
|
return {"files": files, "tags": tag_counts, "path": vault_path, "paths": paths, "config": {}}
|
||||||
|
|||||||
+40
-14
@@ -136,6 +136,7 @@ from backend.services.mutations import (
|
|||||||
restore_backup as service_restore_backup,
|
restore_backup as service_restore_backup,
|
||||||
)
|
)
|
||||||
from backend.services.recent import humanize_mtime, list_recent
|
from backend.services.recent import humanize_mtime, list_recent
|
||||||
|
from backend.services.sanitizer import sanitize_html
|
||||||
from backend.services.search import advanced_search_vaults, list_paths, search_paths, search_vaults
|
from backend.services.search import advanced_search_vaults, list_paths, search_paths, search_vaults
|
||||||
from backend.services.search import list_tags as service_list_tags
|
from backend.services.search import list_tags as service_list_tags
|
||||||
from backend.services.vaults import (
|
from backend.services.vaults import (
|
||||||
@@ -678,7 +679,11 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
|
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
|
||||||
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
|
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
|
||||||
"worker-src 'self' blob:; "
|
"worker-src 'self' blob:; "
|
||||||
"frame-src 'self' blob:;"
|
"frame-src 'self' blob:; "
|
||||||
|
"object-src 'none'; "
|
||||||
|
"base-uri 'self'; "
|
||||||
|
"form-action 'self'; "
|
||||||
|
"frame-ancestors 'self';"
|
||||||
)
|
)
|
||||||
# Static assets are NOT content-hashed, so they must revalidate:
|
# Static assets are NOT content-hashed, so they must revalidate:
|
||||||
# ``immutable``/long max-age made Cloudflare and mobile browsers serve
|
# ``immutable``/long max-age made Cloudflare and mobile browsers serve
|
||||||
@@ -1178,7 +1183,10 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | Non
|
|||||||
|
|
||||||
# Add heading IDs for TOC navigation
|
# Add heading IDs for TOC navigation
|
||||||
rendered = _add_heading_ids(rendered)
|
rendered = _add_heading_ids(rendered)
|
||||||
|
|
||||||
|
# Sanitize: raw HTML in vault content must never reach the DOM (BUG-021).
|
||||||
|
rendered = sanitize_html(rendered)
|
||||||
|
|
||||||
return rendered
|
return rendered
|
||||||
|
|
||||||
|
|
||||||
@@ -2657,14 +2665,15 @@ async def api_advanced_search(
|
|||||||
Results include ``<mark>``-highlighted snippets and faceted tag/vault counts.
|
Results include ``<mark>``-highlighted snippets and faceted tag/vault counts.
|
||||||
"""
|
"""
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
return await loop.run_in_executor(
|
search_fn = partial(advanced_search_vaults, q, vault=vault, tag=tag,
|
||||||
_search_executor,
|
|
||||||
partial(advanced_search_vaults, q, vault=vault, tag=tag,
|
|
||||||
limit=limit, offset=offset, sort=sort,
|
limit=limit, offset=offset, sort=sort,
|
||||||
case_sensitive=case_sensitive, whole_word=whole_word, regex=regex,
|
case_sensitive=case_sensitive, whole_word=whole_word, regex=regex,
|
||||||
include_paths=include_paths, exclude_paths=exclude_paths,
|
include_paths=include_paths, exclude_paths=exclude_paths,
|
||||||
created=created, modified=modified, size=size, semantic=semantic),
|
created=created, modified=modified, size=size, semantic=semantic)
|
||||||
)
|
try:
|
||||||
|
return await loop.run_in_executor(_search_executor, search_fn)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e)) from e
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/search/replace", response_model=ReplaceResponse)
|
@app.post("/api/search/replace", response_model=ReplaceResponse)
|
||||||
@@ -4012,9 +4021,23 @@ async def public_share_view(token: str):
|
|||||||
|
|
||||||
title = post.metadata.get("title", file_path.stem)
|
title = post.metadata.get("title", file_path.stem)
|
||||||
|
|
||||||
# JSON-escape raw content for embedding in HTML
|
# Escape everything user-controlled before embedding in HTML/JS (BUG-022).
|
||||||
import json as _json
|
title_esc = html_mod.escape(str(title))
|
||||||
raw_json = _json.dumps(raw)
|
# Neutralise ``</script>`` in the JS string literal too.
|
||||||
|
title_download_js = (
|
||||||
|
_json.dumps(f"{title}.md")
|
||||||
|
.replace("<", "\\u003c")
|
||||||
|
.replace(">", "\\u003e")
|
||||||
|
.replace("&", "\\u0026")
|
||||||
|
)
|
||||||
|
|
||||||
|
# JSON-escape raw content for embedding in HTML, and neutralise ``</script>``.
|
||||||
|
raw_json = (
|
||||||
|
_json.dumps(raw)
|
||||||
|
.replace("<", "\\u003c")
|
||||||
|
.replace(">", "\\u003e")
|
||||||
|
.replace("&", "\\u0026")
|
||||||
|
)
|
||||||
fm_html = ""
|
fm_html = ""
|
||||||
if post.metadata:
|
if post.metadata:
|
||||||
fm_items = []
|
fm_items = []
|
||||||
@@ -4028,12 +4051,15 @@ async def public_share_view(token: str):
|
|||||||
v = "✓" if v else "✗"
|
v = "✓" if v else "✗"
|
||||||
elif v is None:
|
elif v is None:
|
||||||
v = "—"
|
v = "—"
|
||||||
fm_items.append(f'<div class="fm-row"><span class="fm-key">{k}</span><span class="fm-val">{v}</span></div>')
|
fm_items.append(
|
||||||
|
f'<div class="fm-row"><span class="fm-key">{html_mod.escape(str(k))}</span>'
|
||||||
|
f'<span class="fm-val">{html_mod.escape(str(v))}</span></div>'
|
||||||
|
)
|
||||||
if fm_items:
|
if fm_items:
|
||||||
fm_html = f'<div class="fm-section"><div class="fm-header">Frontmatter</div><div class="fm-body">{"".join(fm_items)}</div></div>'
|
fm_html = f'<div class="fm-section"><div class="fm-header">Frontmatter</div><div class="fm-body">{"".join(fm_items)}</div></div>'
|
||||||
|
|
||||||
return HTMLResponse(f"""<!DOCTYPE html><html lang="fr" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
return HTMLResponse(f"""<!DOCTYPE html><html lang="fr" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>{title} — ObsiGate Share</title>
|
<title>{title_esc} — ObsiGate Share</title>
|
||||||
<style>
|
<style>
|
||||||
:root {{ --bg:#1a1a2e; --bg-card:#16213e; --text:#e0e0e0; --text-muted:#888; --accent:#6366f1; --border:#2a2a4a; --banner-bg:var(--accent); --banner-text:#fff; }}
|
:root {{ --bg:#1a1a2e; --bg-card:#16213e; --text:#e0e0e0; --text-muted:#888; --accent:#6366f1; --border:#2a2a4a; --banner-bg:var(--accent); --banner-text:#fff; }}
|
||||||
[data-theme="light"] {{ --bg:#f8f9fa; --bg-card:#fff; --text:#1a1a2e; --text-muted:#666; --accent:#4f46e5; --border:#ddd; --banner-bg:#eef2ff; --banner-text:#4338ca; }}
|
[data-theme="light"] {{ --bg:#f8f9fa; --bg-card:#fff; --text:#1a1a2e; --text-muted:#666; --accent:#4f46e5; --border:#ddd; --banner-bg:#eef2ff; --banner-text:#4338ca; }}
|
||||||
@@ -4075,7 +4101,7 @@ body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:
|
|||||||
Document partagé via ObsiGate
|
Document partagé via ObsiGate
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<span class="toolbar-title">{title}</span>
|
<span class="toolbar-title">{title_esc}</span>
|
||||||
<button class="toolbar-btn" onclick="toggleTheme()" title="Thème clair/sombre">
|
<button class="toolbar-btn" onclick="toggleTheme()" title="Thème clair/sombre">
|
||||||
<svg id="theme-icon-dark" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
<svg id="theme-icon-dark" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||||
<svg id="theme-icon-light" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
<svg id="theme-icon-light" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||||
@@ -4094,7 +4120,7 @@ body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:
|
|||||||
<script>
|
<script>
|
||||||
function toggleTheme(){{var t=document.documentElement;var isDark=t.dataset.theme==="dark";t.dataset.theme=isDark?"light":"dark";document.getElementById("theme-icon-dark").style.display=isDark?"none":"";document.getElementById("theme-icon-light").style.display=isDark?"":"none";localStorage.setItem("obsigate-share-theme",t.dataset.theme)}}
|
function toggleTheme(){{var t=document.documentElement;var isDark=t.dataset.theme==="dark";t.dataset.theme=isDark?"light":"dark";document.getElementById("theme-icon-dark").style.display=isDark?"none":"";document.getElementById("theme-icon-light").style.display=isDark?"":"none";localStorage.setItem("obsigate-share-theme",t.dataset.theme)}}
|
||||||
(function(){{var s=localStorage.getItem("obsigate-share-theme");if(!s)s="dark";document.documentElement.dataset.theme=s;var isDark=s==="dark";document.getElementById("theme-icon-dark").style.display=isDark?"":"none";document.getElementById("theme-icon-light").style.display=isDark?"none":""}})();
|
(function(){{var s=localStorage.getItem("obsigate-share-theme");if(!s)s="dark";document.documentElement.dataset.theme=s;var isDark=s==="dark";document.getElementById("theme-icon-dark").style.display=isDark?"":"none";document.getElementById("theme-icon-light").style.display=isDark?"none":""}})();
|
||||||
function exportMD(){{var raw=JSON.parse(document.getElementById("raw-content").textContent);var b=new Blob([raw],{{type:"text/markdown"}});var a=document.createElement("a");a.href=URL.createObjectURL(b);a.download="{title}.md";a.click()}}
|
function exportMD(){{var raw=JSON.parse(document.getElementById("raw-content").textContent);var b=new Blob([raw],{{type:"text/markdown"}});var a=document.createElement("a");a.href=URL.createObjectURL(b);a.download={title_download_js};a.click()}}
|
||||||
</script></body></html>""")
|
</script></body></html>""")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+64
-15
@@ -1,13 +1,21 @@
|
|||||||
"""
|
"""
|
||||||
IP-based rate limiter for authentication endpoints.
|
In-memory rate limiter for authentication endpoints.
|
||||||
|
|
||||||
Tracks failed login attempts per IP address with automatic
|
Tracks failed attempts per IP **and** per account with automatic cleanup of
|
||||||
cleanup of expired entries. Complements the per-account lockout
|
expired entries. The per-IP budget stops a single source; the per-account
|
||||||
in user_store.py.
|
budget (BUG-031) still throttles an attacker who rotates IPs. It complements
|
||||||
|
the per-account lockout in ``user_store.py``.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
The counters live in process memory only. They are **not** shared between
|
||||||
|
multiple workers/containers and are lost on restart. For a multi-node
|
||||||
|
deployment, front this service with a shared store (Redis) or a single
|
||||||
|
worker. This limitation is intentional and documented (BUG-031).
|
||||||
|
|
||||||
Configuration via environment variables:
|
Configuration via environment variables:
|
||||||
OBSIGATE_LOGIN_MAX_ATTEMPTS Max failures per IP (default: 10)
|
OBSIGATE_LOGIN_MAX_ATTEMPTS Max failures per IP (default: 10)
|
||||||
OBSIGATE_LOGIN_WINDOW_SECONDS Lockout window in seconds (default: 900 = 15min)
|
OBSIGATE_ACCOUNT_MAX_ATTEMPTS Max failures per account (default: 10)
|
||||||
|
OBSIGATE_LOGIN_WINDOW_SECONDS Lockout window in seconds (default: 900)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -19,29 +27,37 @@ logger = logging.getLogger("obsigate.ratelimit")
|
|||||||
|
|
||||||
# --- Configuration ---
|
# --- Configuration ---
|
||||||
MAX_ATTEMPTS = int(os.environ.get("OBSIGATE_LOGIN_MAX_ATTEMPTS", "10"))
|
MAX_ATTEMPTS = int(os.environ.get("OBSIGATE_LOGIN_MAX_ATTEMPTS", "10"))
|
||||||
|
ACCOUNT_MAX_ATTEMPTS = int(os.environ.get("OBSIGATE_ACCOUNT_MAX_ATTEMPTS", "10"))
|
||||||
WINDOW_SECONDS = int(os.environ.get("OBSIGATE_LOGIN_WINDOW_SECONDS", "900")) # 15 min
|
WINDOW_SECONDS = int(os.environ.get("OBSIGATE_LOGIN_WINDOW_SECONDS", "900")) # 15 min
|
||||||
|
|
||||||
# --- In-memory store: {ip: [(timestamp, success_bool), ...]} ---
|
# --- In-memory stores: {key: [(timestamp, success_bool), ...]} ---
|
||||||
_ip_attempts: dict[str, list] = defaultdict(list)
|
_ip_attempts: dict[str, list] = defaultdict(list)
|
||||||
|
_account_attempts: dict[str, list] = defaultdict(list)
|
||||||
_last_cleanup = time.time()
|
_last_cleanup = time.time()
|
||||||
CLEANUP_INTERVAL = 60 # seconds
|
CLEANUP_INTERVAL = 60 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
def _prune(store: dict[str, list], cutoff: float) -> None:
|
||||||
|
"""Drop expired entries from one store in place."""
|
||||||
|
expired = []
|
||||||
|
for key, attempts in store.items():
|
||||||
|
store[key] = [a for a in attempts if a[0] > cutoff]
|
||||||
|
if not store[key]:
|
||||||
|
expired.append(key)
|
||||||
|
for key in expired:
|
||||||
|
del store[key]
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_expired():
|
def _cleanup_expired():
|
||||||
"""Remove entries older than the window."""
|
"""Remove entries older than the window from both stores."""
|
||||||
global _last_cleanup
|
global _last_cleanup
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - _last_cleanup < CLEANUP_INTERVAL:
|
if now - _last_cleanup < CLEANUP_INTERVAL:
|
||||||
return
|
return
|
||||||
_last_cleanup = now
|
_last_cleanup = now
|
||||||
cutoff = now - WINDOW_SECONDS
|
cutoff = now - WINDOW_SECONDS
|
||||||
expired_ips = []
|
_prune(_ip_attempts, cutoff)
|
||||||
for ip, attempts in _ip_attempts.items():
|
_prune(_account_attempts, cutoff)
|
||||||
_ip_attempts[ip] = [a for a in attempts if a[0] > cutoff]
|
|
||||||
if not _ip_attempts[ip]:
|
|
||||||
expired_ips.append(ip)
|
|
||||||
for ip in expired_ips:
|
|
||||||
del _ip_attempts[ip]
|
|
||||||
|
|
||||||
|
|
||||||
def record_failure(ip: str) -> tuple[int, int]:
|
def record_failure(ip: str) -> tuple[int, int]:
|
||||||
@@ -72,6 +88,37 @@ def is_rate_limited(ip: str) -> bool:
|
|||||||
return failures >= MAX_ATTEMPTS
|
return failures >= MAX_ATTEMPTS
|
||||||
|
|
||||||
|
|
||||||
|
def record_account_failure(account: str) -> tuple[int, int]:
|
||||||
|
"""Record a failed attempt for an account, regardless of source IP.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(current_failure_count, remaining_attempts)
|
||||||
|
"""
|
||||||
|
_cleanup_expired()
|
||||||
|
key = account.lower()
|
||||||
|
_account_attempts[key].append((time.time(), False))
|
||||||
|
failures = sum(1 for _, success in _account_attempts[key] if not success)
|
||||||
|
remaining = max(0, ACCOUNT_MAX_ATTEMPTS - failures)
|
||||||
|
if failures >= ACCOUNT_MAX_ATTEMPTS:
|
||||||
|
logger.warning(f"Account {account} rate-limited after {failures} failed attempts")
|
||||||
|
return failures, remaining
|
||||||
|
|
||||||
|
|
||||||
|
def record_account_success(account: str):
|
||||||
|
"""Clear the per-account rate limit state after a successful login."""
|
||||||
|
_cleanup_expired()
|
||||||
|
_account_attempts[account.lower()] = [(time.time(), True)]
|
||||||
|
|
||||||
|
|
||||||
|
def is_account_rate_limited(account: str) -> bool:
|
||||||
|
"""Check if an account has exceeded the per-account rate limit."""
|
||||||
|
_cleanup_expired()
|
||||||
|
failures = sum(
|
||||||
|
1 for _, success in _account_attempts.get(account.lower(), []) if not success
|
||||||
|
)
|
||||||
|
return failures >= ACCOUNT_MAX_ATTEMPTS
|
||||||
|
|
||||||
|
|
||||||
def get_status(ip: str | None = None) -> dict:
|
def get_status(ip: str | None = None) -> dict:
|
||||||
"""Get rate limit status for an IP (for diagnostics)."""
|
"""Get rate limit status for an IP (for diagnostics)."""
|
||||||
_cleanup_expired()
|
_cleanup_expired()
|
||||||
@@ -87,7 +134,9 @@ def get_status(ip: str | None = None) -> dict:
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"tracked_ips": len(_ip_attempts),
|
"tracked_ips": len(_ip_attempts),
|
||||||
|
"tracked_accounts": len(_account_attempts),
|
||||||
"max_attempts": MAX_ATTEMPTS,
|
"max_attempts": MAX_ATTEMPTS,
|
||||||
|
"account_max_attempts": ACCOUNT_MAX_ATTEMPTS,
|
||||||
"window_seconds": WINDOW_SECONDS,
|
"window_seconds": WINDOW_SECONDS,
|
||||||
"limited_ips": sum(
|
"limited_ips": sum(
|
||||||
1 for ip_addr in _ip_attempts
|
1 for ip_addr in _ip_attempts
|
||||||
|
|||||||
+103
-50
@@ -13,6 +13,11 @@ from sortedcontainers import SortedList
|
|||||||
from backend import indexer as _indexer
|
from backend import indexer as _indexer
|
||||||
from backend import semantic_search as _semantic
|
from backend import semantic_search as _semantic
|
||||||
from backend.indexer import index
|
from backend.indexer import index
|
||||||
|
from backend.services.regex_safety import (
|
||||||
|
MAX_REGEX_MATCHES,
|
||||||
|
truncate_for_regex,
|
||||||
|
validate_regex,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("obsigate.search")
|
logger = logging.getLogger("obsigate.search")
|
||||||
|
|
||||||
@@ -226,12 +231,16 @@ def _extract_regex_snippet(
|
|||||||
if not content or not pattern_text:
|
if not content or not pattern_text:
|
||||||
return content[:200].strip() if content else ""
|
return content[:200].strip() if content else ""
|
||||||
|
|
||||||
|
# BUG-025: bound the text scanned and the number of matches collected.
|
||||||
|
content = truncate_for_regex(content)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
validate_regex(pattern_text)
|
||||||
pattern = re.compile(pattern_text, re.IGNORECASE)
|
pattern = re.compile(pattern_text, re.IGNORECASE)
|
||||||
except re.error:
|
except (re.error, ValueError):
|
||||||
return _escape_html(content[:200].strip())
|
return _escape_html(content[:200].strip())
|
||||||
|
|
||||||
matches = list(pattern.finditer(content))
|
matches = list(pattern.finditer(content))[:MAX_REGEX_MATCHES]
|
||||||
if not matches:
|
if not matches:
|
||||||
return _escape_html(content[:200].strip())
|
return _escape_html(content[:200].strip())
|
||||||
|
|
||||||
@@ -729,60 +738,97 @@ def search(
|
|||||||
query_lower = query.lower()
|
query_lower = query.lower()
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
|
|
||||||
for vault_name, vault_data in index.items():
|
inv = get_inverted_index()
|
||||||
if vault_filter != "all" and vault_name != vault_filter:
|
use_index = (not inv.is_stale()) and inv.doc_count > 0
|
||||||
|
|
||||||
|
if use_index:
|
||||||
|
# BUG-033: retrieve candidates from the inverted index instead of
|
||||||
|
# scanning every document. Multi-term queries require all terms
|
||||||
|
# (a superset of exact-phrase matches), single terms use prefix
|
||||||
|
# expansion. Falls back to a full scan while the index is building.
|
||||||
|
if has_query:
|
||||||
|
terms = [t for t in tokenize(query) if t]
|
||||||
|
if not terms:
|
||||||
|
return []
|
||||||
|
doc_sets: list[set] = []
|
||||||
|
for term in terms:
|
||||||
|
term_docs: set = set(inv.word_index.get(term, {}).keys())
|
||||||
|
if len(term) >= MIN_PREFIX_LENGTH:
|
||||||
|
for expanded in inv.get_prefix_tokens(term):
|
||||||
|
term_docs.update(inv.word_index.get(expanded, {}).keys())
|
||||||
|
doc_sets.append(term_docs)
|
||||||
|
doc_keys = set.intersection(*doc_sets) if doc_sets else set()
|
||||||
|
else:
|
||||||
|
doc_keys = set(inv.doc_info.keys())
|
||||||
|
|
||||||
|
if vault_filter != "all":
|
||||||
|
doc_keys &= inv.vault_docs.get(vault_filter, set())
|
||||||
|
for tag in selected_tags:
|
||||||
|
doc_keys &= inv.tag_docs.get(tag.lower(), set())
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
(inv.doc_vault[dk], inv.doc_info[dk])
|
||||||
|
for dk in doc_keys
|
||||||
|
if dk in inv.doc_info
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
candidates = [
|
||||||
|
(vault_name, file_info)
|
||||||
|
for vault_name, vault_data in index.items()
|
||||||
|
if vault_filter == "all" or vault_name == vault_filter
|
||||||
|
for file_info in vault_data["files"]
|
||||||
|
]
|
||||||
|
|
||||||
|
for vault_name, file_info in candidates:
|
||||||
|
# Tag filter: all selected tags must be present
|
||||||
|
if selected_tags and not all(tag in file_info["tags"] for tag in selected_tags):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for file_info in vault_data["files"]:
|
score = 0
|
||||||
# Tag filter: all selected tags must be present
|
snippet = file_info.get("content_preview", "")
|
||||||
if selected_tags and not all(tag in file_info["tags"] for tag in selected_tags):
|
|
||||||
continue
|
|
||||||
|
|
||||||
score = 0
|
if has_query:
|
||||||
snippet = file_info.get("content_preview", "")
|
title_lower = file_info["title"].lower()
|
||||||
|
|
||||||
if has_query:
|
# Exact title match (highest weight)
|
||||||
title_lower = file_info["title"].lower()
|
if query_lower == title_lower:
|
||||||
|
score += 20
|
||||||
|
# Partial title match
|
||||||
|
elif query_lower in title_lower:
|
||||||
|
score += 10
|
||||||
|
|
||||||
# Exact title match (highest weight)
|
# Path match (folder/filename relevance)
|
||||||
if query_lower == title_lower:
|
if query_lower in file_info["path"].lower():
|
||||||
score += 20
|
score += 5
|
||||||
# Partial title match
|
|
||||||
elif query_lower in title_lower:
|
|
||||||
score += 10
|
|
||||||
|
|
||||||
# Path match (folder/filename relevance)
|
# Tag name match
|
||||||
if query_lower in file_info["path"].lower():
|
for tag in file_info.get("tags", []):
|
||||||
score += 5
|
if query_lower in tag.lower():
|
||||||
|
score += 3
|
||||||
|
break # count once per file
|
||||||
|
|
||||||
# Tag name match
|
# Content match — use cached content (no disk I/O)
|
||||||
for tag in file_info.get("tags", []):
|
content = file_info.get("content", "")
|
||||||
if query_lower in tag.lower():
|
content_lower = content.lower()
|
||||||
score += 3
|
if query_lower in content_lower:
|
||||||
break # count once per file
|
# Frequency-based scoring, capped to avoid over-weighting
|
||||||
|
occurrences = content_lower.count(query_lower)
|
||||||
|
score += min(occurrences, 10)
|
||||||
|
snippet = _extract_snippet(content, query)
|
||||||
|
else:
|
||||||
|
# Tag-only filter: all matching files get score 1
|
||||||
|
score = 1
|
||||||
|
|
||||||
# Content match — use cached content (no disk I/O)
|
if score > 0:
|
||||||
content = file_info.get("content", "")
|
results.append({
|
||||||
content_lower = content.lower()
|
"vault": vault_name,
|
||||||
if query_lower in content_lower:
|
"path": file_info["path"],
|
||||||
# Frequency-based scoring, capped to avoid over-weighting
|
"title": file_info["title"],
|
||||||
occurrences = content_lower.count(query_lower)
|
"tags": file_info["tags"],
|
||||||
score += min(occurrences, 10)
|
"score": score,
|
||||||
snippet = _extract_snippet(content, query)
|
"snippet": snippet,
|
||||||
else:
|
"modified": file_info["modified"],
|
||||||
# Tag-only filter: all matching files get score 1
|
})
|
||||||
score = 1
|
|
||||||
|
|
||||||
if score > 0:
|
|
||||||
results.append({
|
|
||||||
"vault": vault_name,
|
|
||||||
"path": file_info["path"],
|
|
||||||
"title": file_info["title"],
|
|
||||||
"tags": file_info["tags"],
|
|
||||||
"score": score,
|
|
||||||
"snippet": snippet,
|
|
||||||
"modified": file_info["modified"],
|
|
||||||
})
|
|
||||||
|
|
||||||
results.sort(key=lambda x: -x["score"])
|
results.sort(key=lambda x: -x["score"])
|
||||||
return results[:limit]
|
return results[:limit]
|
||||||
@@ -908,12 +954,14 @@ def _passes_search_filters(
|
|||||||
title = file_info.get("title", "")
|
title = file_info.get("title", "")
|
||||||
content = file_info.get("content", "")
|
content = file_info.get("content", "")
|
||||||
path = file_info.get("path", "")
|
path = file_info.get("path", "")
|
||||||
search_text = f"{title} {content}"
|
# BUG-025: cap the text scanned by a user-supplied regex.
|
||||||
|
search_text = truncate_for_regex(f"{title} {content}")
|
||||||
search_text_norm = normalize_text(search_text)
|
search_text_norm = normalize_text(search_text)
|
||||||
|
|
||||||
# --- Regex mode ---
|
# --- Regex mode ---
|
||||||
if regex and raw_query:
|
if regex and raw_query:
|
||||||
try:
|
try:
|
||||||
|
validate_regex(raw_query)
|
||||||
flags = 0 if case_sensitive else re.IGNORECASE
|
flags = 0 if case_sensitive else re.IGNORECASE
|
||||||
if whole_word:
|
if whole_word:
|
||||||
pattern = re.compile(rf"\b{raw_query}\b", flags)
|
pattern = re.compile(rf"\b{raw_query}\b", flags)
|
||||||
@@ -921,7 +969,7 @@ def _passes_search_filters(
|
|||||||
pattern = re.compile(raw_query, flags)
|
pattern = re.compile(raw_query, flags)
|
||||||
if not pattern.search(search_text):
|
if not pattern.search(search_text):
|
||||||
return False
|
return False
|
||||||
except re.error:
|
except (re.error, ValueError):
|
||||||
return False
|
return False
|
||||||
return _passes_path_filters(path, include_paths, exclude_paths)
|
return _passes_path_filters(path, include_paths, exclude_paths)
|
||||||
|
|
||||||
@@ -1138,6 +1186,11 @@ def advanced_search(
|
|||||||
"""
|
"""
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
query = query.strip() if query else ""
|
query = query.strip() if query else ""
|
||||||
|
|
||||||
|
# BUG-025: reject oversized / catastrophic regex patterns up front.
|
||||||
|
if regex and query:
|
||||||
|
validate_regex(query)
|
||||||
|
|
||||||
parsed = _parse_advanced_query(query)
|
parsed = _parse_advanced_query(query)
|
||||||
|
|
||||||
# Merge explicit tag_filter with parsed tag: operators
|
# Merge explicit tag_filter with parsed tag: operators
|
||||||
|
|||||||
@@ -433,22 +433,33 @@ def replace_in_files(
|
|||||||
"""
|
"""
|
||||||
import re as re_mod
|
import re as re_mod
|
||||||
|
|
||||||
|
from backend.services.regex_safety import MAX_REGEX_MATCHES, validate_regex
|
||||||
from backend.services.search import advanced_search_vaults
|
from backend.services.search import advanced_search_vaults
|
||||||
|
|
||||||
if not find:
|
if not find:
|
||||||
raise ServiceError("Query is required", code="invalid_arguments", status=400)
|
raise ServiceError("Query is required", code="invalid_arguments", status=400)
|
||||||
|
|
||||||
search_results = advanced_search_vaults(
|
# BUG-025: validate the pattern before it is compiled / applied in bulk.
|
||||||
find,
|
if regex:
|
||||||
vault=vault,
|
try:
|
||||||
case_sensitive=case_sensitive,
|
validate_regex(find)
|
||||||
whole_word=whole_word,
|
except ValueError as e:
|
||||||
regex=regex,
|
raise ServiceError(str(e), code="invalid_arguments", status=400) from e
|
||||||
include_paths=include_paths,
|
|
||||||
exclude_paths=exclude_paths,
|
try:
|
||||||
limit=500,
|
search_results = advanced_search_vaults(
|
||||||
sort="relevance",
|
find,
|
||||||
)
|
vault=vault,
|
||||||
|
case_sensitive=case_sensitive,
|
||||||
|
whole_word=whole_word,
|
||||||
|
regex=regex,
|
||||||
|
include_paths=include_paths,
|
||||||
|
exclude_paths=exclude_paths,
|
||||||
|
limit=500,
|
||||||
|
sort="relevance",
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ServiceError(str(e), code="invalid_arguments", status=400) from e
|
||||||
|
|
||||||
if not search_results["results"]:
|
if not search_results["results"]:
|
||||||
return {"matches": [], "total_matches": 0, "dry_run": dry_run}
|
return {"matches": [], "total_matches": 0, "dry_run": dry_run}
|
||||||
@@ -480,7 +491,7 @@ def replace_in_files(
|
|||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
occurrences = list(pattern.finditer(original))
|
occurrences = list(pattern.finditer(original))[:MAX_REGEX_MATCHES]
|
||||||
if not occurrences:
|
if not occurrences:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Network helpers shared by the auth middleware and rate limiter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
__all__ = ["get_client_ip", "is_trusted_proxy"]
|
||||||
|
|
||||||
|
|
||||||
|
def is_trusted_proxy() -> bool:
|
||||||
|
"""Whether ``X-Forwarded-For`` should be trusted (reverse proxy in front)."""
|
||||||
|
return os.environ.get("OBSIGATE_TRUST_PROXY", "false").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_ip(request: Request) -> str:
|
||||||
|
"""Return the best-known client IP for *request*.
|
||||||
|
|
||||||
|
When ``OBSIGATE_TRUST_PROXY=true`` the left-most ``X-Forwarded-For`` entry
|
||||||
|
is used (the original client behind the proxy). Otherwise the socket peer
|
||||||
|
address is returned. BUG-030: this value feeds the audit log so attacks
|
||||||
|
remain traceable.
|
||||||
|
"""
|
||||||
|
if is_trusted_proxy():
|
||||||
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
if forwarded:
|
||||||
|
first = forwarded.split(",")[0].strip()
|
||||||
|
if first:
|
||||||
|
return first
|
||||||
|
real_ip = request.headers.get("x-real-ip")
|
||||||
|
if real_ip:
|
||||||
|
return real_ip.strip()
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
+29
-12
@@ -15,6 +15,27 @@ from backend.services.errors import ServiceError
|
|||||||
logger = logging.getLogger("obsigate.services.paths")
|
logger = logging.getLogger("obsigate.services.paths")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_within(resolved: Path, root: Path) -> bool:
|
||||||
|
"""Return True when *resolved* is *root* or lives below it.
|
||||||
|
|
||||||
|
The comparison is segment-aware so that a sibling directory whose name
|
||||||
|
merely shares a prefix (``vault`` vs ``vault-evil``) is rejected. A
|
||||||
|
case-insensitive fallback preserves the Windows / Docker behaviour where
|
||||||
|
the resolved casing can differ from the configured root.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
resolved.relative_to(root)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
resolved_parts = tuple(part.lower() for part in resolved.parts)
|
||||||
|
root_parts = tuple(part.lower() for part in root.parts)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return resolved_parts[: len(root_parts)] == root_parts
|
||||||
|
|
||||||
|
|
||||||
def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
|
def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
|
||||||
"""Resolve a vault-relative path, rejecting traversal outside the vault.
|
"""Resolve a vault-relative path, rejecting traversal outside the vault.
|
||||||
|
|
||||||
@@ -30,16 +51,12 @@ def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
|
|||||||
logger.error(f"Path resolution error - vault_root: {vault_root}, relative_path: {relative_path}, error: {e}")
|
logger.error(f"Path resolution error - vault_root: {vault_root}, relative_path: {relative_path}, error: {e}")
|
||||||
raise ServiceError(f"Path resolution error: {e!s}", code="path_error", status=500) from e
|
raise ServiceError(f"Path resolution error: {e!s}", code="path_error", status=500) from e
|
||||||
|
|
||||||
try:
|
if not _is_within(resolved, root):
|
||||||
resolved.relative_to(root)
|
logger.warning(f"Path outside vault - vault: {root}, requested: {relative_path}, resolved: {resolved}")
|
||||||
except ValueError:
|
raise ServiceError(
|
||||||
# Case-insensitive fallback for Windows / Docker path casing.
|
"Access denied: path outside vault",
|
||||||
if not str(resolved).lower().startswith(str(root).lower()):
|
code="path_outside_vault",
|
||||||
logger.warning(f"Path outside vault - vault: {root}, requested: {relative_path}, resolved: {resolved}")
|
status=403,
|
||||||
raise ServiceError(
|
details={"path": relative_path},
|
||||||
"Access denied: path outside vault",
|
)
|
||||||
code="path_outside_vault",
|
|
||||||
status=403,
|
|
||||||
details={"path": relative_path},
|
|
||||||
) from None
|
|
||||||
return resolved
|
return resolved
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Regex safety helpers (BUG-025).
|
||||||
|
|
||||||
|
User-supplied regular expressions are applied to large amounts of indexed
|
||||||
|
content. Python's :mod:`re` has no timeout, so a malicious pattern such as
|
||||||
|
``(a+)+$`` can pin a CPU for a long time (ReDoS). Without adding a native
|
||||||
|
dependency we mitigate by:
|
||||||
|
|
||||||
|
* bounding the pattern length,
|
||||||
|
* rejecting nested-quantifier constructs (the classic catastrophic form),
|
||||||
|
* capping the amount of text a single regex pass may scan,
|
||||||
|
* capping the number of matches collected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MAX_PATTERN_LENGTH",
|
||||||
|
"MAX_REGEX_CONTENT",
|
||||||
|
"MAX_REGEX_MATCHES",
|
||||||
|
"truncate_for_regex",
|
||||||
|
"validate_regex",
|
||||||
|
]
|
||||||
|
|
||||||
|
MAX_PATTERN_LENGTH = 500
|
||||||
|
MAX_REGEX_CONTENT = 200_000
|
||||||
|
MAX_REGEX_MATCHES = 1000
|
||||||
|
|
||||||
|
# A quantified group whose body already contains a quantifier, immediately
|
||||||
|
# followed by another quantifier: ``(a+)+``, ``(.*)*``, ``(a+){2,}``, ...
|
||||||
|
_NESTED_QUANTIFIER_RE = re.compile(r"\([^()]*[+*][^()]*\)\s*(?:[+*?]|\{)")
|
||||||
|
# Backreferences combined with quantifiers are a common ReDoS vector too.
|
||||||
|
_BACKREF_QUANTIFIER_RE = re.compile(r"\\[1-9][0-9]*\s*(?:[+*]|\{)")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_regex(pattern: str) -> str:
|
||||||
|
"""Validate a user-supplied regex against the safety policy.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pattern: Raw regex pattern.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The pattern unchanged when acceptable.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: When the pattern is empty, too long, or uses a construct
|
||||||
|
known to cause catastrophic backtracking.
|
||||||
|
"""
|
||||||
|
if not pattern:
|
||||||
|
raise ValueError("Expression régulière vide")
|
||||||
|
if len(pattern) > MAX_PATTERN_LENGTH:
|
||||||
|
raise ValueError(f"Expression régulière trop longue (max {MAX_PATTERN_LENGTH})")
|
||||||
|
if _NESTED_QUANTIFIER_RE.search(pattern) or _BACKREF_QUANTIFIER_RE.search(pattern):
|
||||||
|
raise ValueError("Expression régulière refusée (quantificateurs imbriqués)")
|
||||||
|
try:
|
||||||
|
re.compile(pattern)
|
||||||
|
except re.error as e:
|
||||||
|
raise ValueError(f"Expression régulière invalide : {e}") from e
|
||||||
|
return pattern
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_for_regex(content: str, limit: int = MAX_REGEX_CONTENT) -> str:
|
||||||
|
"""Return at most *limit* characters to bound a single regex pass."""
|
||||||
|
if content and len(content) > limit:
|
||||||
|
return content[:limit]
|
||||||
|
return content
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
"""Whitelist HTML sanitizer used for untrusted markdown / AI output.
|
||||||
|
|
||||||
|
The markdown renderer runs with ``escape=False`` so raw HTML authored inside a
|
||||||
|
vault (or returned by a model) reaches the browser. This module scrubs the
|
||||||
|
rendered HTML against a strict whitelist of tags and attributes, drops
|
||||||
|
dangerous URL schemes and strips every event handler / ``style`` attribute.
|
||||||
|
|
||||||
|
Implemented with the standard library only (no third-party dependency) so the
|
||||||
|
runtime footprint stays unchanged. It is *not* a full HTML5 parser: it is a
|
||||||
|
conservative, allow-list based filter intended for already well-formed output
|
||||||
|
produced by mistune and the image/wikilink pre-processors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html as _html
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
__all__ = ["is_safe_url", "sanitize_html"]
|
||||||
|
|
||||||
|
# Tags whose *content* is discarded entirely (never rendered as text).
|
||||||
|
_DROP_CONTENT_TAGS = frozenset({
|
||||||
|
"script", "style", "iframe", "object", "embed", "template", "noscript",
|
||||||
|
"svg", "math", "applet", "form", "button", "select", "textarea", "option",
|
||||||
|
"frame", "frameset", "base", "link", "meta", "title", "head",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Tags kept in the output (text content preserved for unknown tags).
|
||||||
|
_ALLOWED_TAGS = frozenset({
|
||||||
|
"a", "abbr", "b", "blockquote", "br", "caption", "code", "col", "colgroup",
|
||||||
|
"dd", "del", "details", "div", "dl", "dt", "em", "figcaption", "figure",
|
||||||
|
"h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "input", "kbd", "li",
|
||||||
|
"mark", "ol", "p", "pre", "q", "s", "section", "small", "span", "strong",
|
||||||
|
"sub", "summary", "sup", "table", "tbody", "td", "tfoot", "th", "thead",
|
||||||
|
"time", "tr", "u", "ul", "video", "audio", "source", "track",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Attributes allowed on any element.
|
||||||
|
_GLOBAL_ATTRS = frozenset({"class", "id", "title", "dir", "lang", "role"})
|
||||||
|
|
||||||
|
# Per-tag attribute whitelist (in addition to globals and ``data-*``).
|
||||||
|
_TAG_ATTRS: dict[str, frozenset[str]] = {
|
||||||
|
"a": frozenset({"href", "target", "rel", "name", "download"}),
|
||||||
|
"img": frozenset({"src", "alt", "width", "height", "loading"}),
|
||||||
|
"input": frozenset({"type", "checked", "disabled", "value"}),
|
||||||
|
"ol": frozenset({"start", "type", "reversed"}),
|
||||||
|
"ul": frozenset({"type"}),
|
||||||
|
"li": frozenset({"value"}),
|
||||||
|
"td": frozenset({"colspan", "rowspan", "align", "valign"}),
|
||||||
|
"th": frozenset({"colspan", "rowspan", "align", "valign", "scope"}),
|
||||||
|
"col": frozenset({"span", "width"}),
|
||||||
|
"colgroup": frozenset({"span"}),
|
||||||
|
"video": frozenset({"src", "controls", "width", "height", "loop", "muted",
|
||||||
|
"poster", "preload", "playsinline"}),
|
||||||
|
"audio": frozenset({"src", "controls", "loop", "muted", "preload"}),
|
||||||
|
"source": frozenset({"src", "type", "srcset", "media"}),
|
||||||
|
"track": frozenset({"src", "kind", "srclang", "label", "default"}),
|
||||||
|
"details": frozenset({"open"}),
|
||||||
|
"time": frozenset({"datetime"}),
|
||||||
|
"blockquote": frozenset({"cite"}),
|
||||||
|
"q": frozenset({"cite"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
# URL-bearing attributes per tag, and whether ``data:`` URIs are acceptable.
|
||||||
|
_URL_ATTRS: dict[str, frozenset[str]] = {
|
||||||
|
"a": frozenset({"href"}),
|
||||||
|
"img": frozenset({"src"}),
|
||||||
|
"video": frozenset({"src", "poster"}),
|
||||||
|
"audio": frozenset({"src"}),
|
||||||
|
"source": frozenset({"src", "srcset"}),
|
||||||
|
"track": frozenset({"src"}),
|
||||||
|
"blockquote": frozenset({"cite"}),
|
||||||
|
"q": frozenset({"cite"}),
|
||||||
|
}
|
||||||
|
|
||||||
|
_SAFE_SCHEMES = frozenset({"http", "https", "mailto", "tel", "ftp"})
|
||||||
|
|
||||||
|
# Characters that browsers ignore inside a scheme (tab/newline/CR) and that
|
||||||
|
# could otherwise smuggle ``java\tscript:`` past a naive check.
|
||||||
|
_URL_STRIP_CHARS = "\t\n\r\x00"
|
||||||
|
|
||||||
|
|
||||||
|
def is_safe_url(value: str, *, allow_data: bool = False, tag: str = "") -> bool:
|
||||||
|
"""Return True when *value* is a URL with an allowed scheme.
|
||||||
|
|
||||||
|
Relative URLs (``/foo``, ``./foo``, ``#anchor``) are allowed. Dangerous
|
||||||
|
schemes such as ``javascript:`` and ``vbscript:`` are always rejected.
|
||||||
|
``data:`` URIs are only allowed for image/video/audio sources.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
# Decode entities and strip whitespace/control chars before inspecting.
|
||||||
|
candidate = _html.unescape(str(value)).strip()
|
||||||
|
for ch in _URL_STRIP_CHARS:
|
||||||
|
candidate = candidate.replace(ch, "")
|
||||||
|
if not candidate:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Detect a scheme: ``scheme:`` where scheme is [a-zA-Z][a-zA-Z0-9+.-]*
|
||||||
|
lowered = candidate.lower()
|
||||||
|
if lowered.startswith("data:"):
|
||||||
|
if not allow_data:
|
||||||
|
return False
|
||||||
|
# Only media data URIs are permitted.
|
||||||
|
if tag in ("img",):
|
||||||
|
return lowered.startswith("data:image/")
|
||||||
|
if tag in ("video", "audio", "source", "track"):
|
||||||
|
return (
|
||||||
|
lowered.startswith("data:image/")
|
||||||
|
or lowered.startswith("data:video/")
|
||||||
|
or lowered.startswith("data:audio/")
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if lowered.startswith("blob:"):
|
||||||
|
return tag in ("img", "video", "audio", "source")
|
||||||
|
|
||||||
|
# No scheme at all (relative / fragment / protocol-relative) → safe.
|
||||||
|
colon = candidate.find(":")
|
||||||
|
slash = candidate.find("/")
|
||||||
|
if colon == -1 or (slash != -1 and slash < colon):
|
||||||
|
return True
|
||||||
|
# ``//host`` protocol-relative has no scheme.
|
||||||
|
if candidate.startswith("//"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
scheme = lowered[:colon]
|
||||||
|
if not scheme or not scheme[0].isalpha():
|
||||||
|
return True # not a real scheme, treat as relative
|
||||||
|
return scheme in _SAFE_SCHEMES
|
||||||
|
|
||||||
|
|
||||||
|
class _Sanitizer(HTMLParser):
|
||||||
|
"""Rebuild HTML while dropping anything not explicitly allowed."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(convert_charrefs=True)
|
||||||
|
self._out: list[str] = []
|
||||||
|
# Stack of tag names currently open (only allowed tags).
|
||||||
|
self._open: list[str] = []
|
||||||
|
# Stack tracking dropped-content depth: each entry is the tag name.
|
||||||
|
self._suppress: list[str] = []
|
||||||
|
|
||||||
|
# -- helpers ----------------------------------------------------------
|
||||||
|
def _filter_attrs(self, tag: str, attrs: list[tuple[str, str | None]]) -> str:
|
||||||
|
allowed_extra = _TAG_ATTRS.get(tag, frozenset())
|
||||||
|
url_attrs = _URL_ATTRS.get(tag, frozenset())
|
||||||
|
parts: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for name, value in attrs:
|
||||||
|
if value is None:
|
||||||
|
value = ""
|
||||||
|
lname = name.lower()
|
||||||
|
if lname in seen:
|
||||||
|
continue
|
||||||
|
seen.add(lname)
|
||||||
|
# Event handlers and style are never allowed.
|
||||||
|
if lname.startswith("on") or lname in ("style", "srcdoc", "formaction", "xlink:href"):
|
||||||
|
continue
|
||||||
|
if lname.startswith("data-") or lname.startswith("aria-"):
|
||||||
|
pass
|
||||||
|
elif lname not in _GLOBAL_ATTRS and lname not in allowed_extra:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if lname in url_attrs:
|
||||||
|
allow_data = tag in ("img", "video", "audio", "source", "track")
|
||||||
|
# ``srcset`` may contain multiple comma-separated candidates.
|
||||||
|
if lname == "srcset":
|
||||||
|
if not _safe_srcset(value, tag):
|
||||||
|
continue
|
||||||
|
elif not is_safe_url(value, allow_data=allow_data, tag=tag):
|
||||||
|
continue
|
||||||
|
parts.append(f' {lname}="{_html.escape(value, quote=True)}"')
|
||||||
|
return "".join(parts)
|
||||||
|
|
||||||
|
def _emit_start(self, tag: str, attrs, self_closing: bool) -> None:
|
||||||
|
attrs_html = self._filter_attrs(tag, attrs)
|
||||||
|
if self_closing or tag in ("br", "hr", "img", "input", "col", "source", "track"):
|
||||||
|
self._out.append(f"<{tag}{attrs_html} />")
|
||||||
|
else:
|
||||||
|
self._out.append(f"<{tag}{attrs_html}>")
|
||||||
|
self._open.append(tag)
|
||||||
|
|
||||||
|
# -- HTMLParser callbacks --------------------------------------------
|
||||||
|
def handle_starttag(self, tag: str, attrs) -> None:
|
||||||
|
tag = tag.lower()
|
||||||
|
if tag in _DROP_CONTENT_TAGS:
|
||||||
|
self._suppress.append(tag)
|
||||||
|
return
|
||||||
|
if self._suppress:
|
||||||
|
return
|
||||||
|
if tag not in _ALLOWED_TAGS:
|
||||||
|
return # drop the tag, keep its text content
|
||||||
|
self._emit_start(tag, attrs, self_closing=False)
|
||||||
|
|
||||||
|
def handle_startendtag(self, tag: str, attrs) -> None:
|
||||||
|
tag = tag.lower()
|
||||||
|
if tag in _DROP_CONTENT_TAGS or self._suppress:
|
||||||
|
return
|
||||||
|
if tag not in _ALLOWED_TAGS:
|
||||||
|
return
|
||||||
|
self._emit_start(tag, attrs, self_closing=True)
|
||||||
|
|
||||||
|
def handle_endtag(self, tag: str) -> None:
|
||||||
|
tag = tag.lower()
|
||||||
|
if tag in _DROP_CONTENT_TAGS:
|
||||||
|
# Close the innermost matching suppress marker.
|
||||||
|
for i in range(len(self._suppress) - 1, -1, -1):
|
||||||
|
if self._suppress[i] == tag:
|
||||||
|
del self._suppress[i:]
|
||||||
|
break
|
||||||
|
return
|
||||||
|
if self._suppress:
|
||||||
|
return
|
||||||
|
if tag not in _ALLOWED_TAGS:
|
||||||
|
return
|
||||||
|
# Only close if currently open (tolerate malformed nesting).
|
||||||
|
if tag in self._open:
|
||||||
|
while self._open:
|
||||||
|
top = self._open.pop()
|
||||||
|
self._out.append(f"</{top}>")
|
||||||
|
if top == tag:
|
||||||
|
break
|
||||||
|
|
||||||
|
def handle_data(self, data: str) -> None:
|
||||||
|
if self._suppress:
|
||||||
|
return
|
||||||
|
self._out.append(_html.escape(data, quote=False))
|
||||||
|
|
||||||
|
def handle_comment(self, data: str) -> None:
|
||||||
|
return # comments are dropped
|
||||||
|
|
||||||
|
def handle_decl(self, decl: str) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def handle_pi(self, data: str) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def handle_entityref(self, name: str) -> None:
|
||||||
|
if self._suppress:
|
||||||
|
return
|
||||||
|
self._out.append(f"&{name};")
|
||||||
|
|
||||||
|
def handle_charref(self, name: str) -> None:
|
||||||
|
if self._suppress:
|
||||||
|
return
|
||||||
|
self._out.append(f"&#{name};")
|
||||||
|
|
||||||
|
def get_html(self) -> str:
|
||||||
|
# Close any tags left open by malformed input.
|
||||||
|
while self._open:
|
||||||
|
self._out.append(f"</{self._open.pop()}>")
|
||||||
|
return "".join(self._out)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_srcset(value: str, tag: str) -> bool:
|
||||||
|
"""Validate every candidate in a ``srcset`` attribute."""
|
||||||
|
for candidate in value.split(","):
|
||||||
|
candidate = candidate.strip()
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
url = candidate.split()[0] if candidate.split() else candidate
|
||||||
|
if not is_safe_url(url, allow_data=(tag == "img"), tag=tag):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_html(html: str) -> str:
|
||||||
|
"""Return *html* with only whitelisted tags/attributes/schemes preserved.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html: Untrusted HTML (typically mistune output with ``escape=False``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sanitized HTML string.
|
||||||
|
"""
|
||||||
|
if not html:
|
||||||
|
return ""
|
||||||
|
parser = _Sanitizer()
|
||||||
|
try:
|
||||||
|
parser.feed(html)
|
||||||
|
parser.close()
|
||||||
|
except Exception:
|
||||||
|
# Never let sanitization crash a request; fail closed to plain text.
|
||||||
|
return _html.escape(html)
|
||||||
|
return parser.get_html()
|
||||||
+172
-13
@@ -4,6 +4,16 @@ Webhook management and dispatch for ObsiGate.
|
|||||||
Webhooks are HTTP POST callbacks triggered on file/directory events.
|
Webhooks are HTTP POST callbacks triggered on file/directory events.
|
||||||
Configuration is persisted in data/webhooks.json.
|
Configuration is persisted in data/webhooks.json.
|
||||||
|
|
||||||
|
Security (BUG-026):
|
||||||
|
* target URLs are validated against SSRF (scheme + resolved IP must be public);
|
||||||
|
* HTTP is refused unless ``OBSIGATE_WEBHOOK_ALLOW_HTTP=true``;
|
||||||
|
* private/loopback/link-local targets are refused unless
|
||||||
|
``OBSIGATE_WEBHOOK_ALLOW_PRIVATE=true``;
|
||||||
|
* redirects are never followed;
|
||||||
|
* signing secrets are **not** stored in the public config file — they live in
|
||||||
|
``data/webhook_secrets.json`` (0600) or in an environment variable named
|
||||||
|
``OBSIGATE_WEBHOOK_SECRET_<ID>``.
|
||||||
|
|
||||||
Events: file_created, file_deleted, file_modified, file_renamed,
|
Events: file_created, file_deleted, file_modified, file_renamed,
|
||||||
directory_created, directory_deleted, directory_renamed
|
directory_created, directory_deleted, directory_renamed
|
||||||
"""
|
"""
|
||||||
@@ -11,17 +21,22 @@ Events: file_created, file_deleted, file_modified, file_renamed,
|
|||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
logger = logging.getLogger("obsigate.webhooks")
|
logger = logging.getLogger("obsigate.webhooks")
|
||||||
|
|
||||||
WEBHOOKS_FILE = Path("data/webhooks.json")
|
WEBHOOKS_FILE = Path("data/webhooks.json")
|
||||||
|
WEBHOOK_SECRETS_FILE = Path("data/webhook_secrets.json")
|
||||||
|
|
||||||
VALID_EVENTS = {
|
VALID_EVENTS = {
|
||||||
"file_created", "file_deleted", "file_modified", "file_renamed",
|
"file_created", "file_deleted", "file_modified", "file_renamed",
|
||||||
@@ -29,6 +44,81 @@ VALID_EVENTS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _allow_http() -> bool:
|
||||||
|
return os.environ.get("OBSIGATE_WEBHOOK_ALLOW_HTTP", "false").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def _allow_private() -> bool:
|
||||||
|
return os.environ.get("OBSIGATE_WEBHOOK_ALLOW_PRIVATE", "false").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_public_ip(ip_str: str) -> bool:
|
||||||
|
"""True when *ip_str* is a globally routable unicast address."""
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(ip_str)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return not (
|
||||||
|
ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved
|
||||||
|
or ip.is_multicast or ip.is_unspecified
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_webhook_url(url: str) -> str:
|
||||||
|
"""Validate the URL syntax/scheme and literal-IP safety at config time.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: When the URL is malformed or points at an obviously
|
||||||
|
forbidden scheme/host.
|
||||||
|
"""
|
||||||
|
if not url or not isinstance(url, str):
|
||||||
|
raise ValueError("URL requise")
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ("http", "https"):
|
||||||
|
raise ValueError("L'URL doit utiliser http ou https")
|
||||||
|
if parsed.scheme == "http" and not _allow_http():
|
||||||
|
raise ValueError("HTTPS requis (définir OBSIGATE_WEBHOOK_ALLOW_HTTP=true pour autoriser http)")
|
||||||
|
host = parsed.hostname
|
||||||
|
if not host:
|
||||||
|
raise ValueError("Hôte manquant dans l'URL")
|
||||||
|
# Reject literal private/loopback IPs immediately (no DNS needed).
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(host)
|
||||||
|
except ValueError:
|
||||||
|
return url # hostname — resolved and checked at dispatch time
|
||||||
|
if not _allow_private() and not _is_public_ip(str(ip)):
|
||||||
|
raise ValueError("Adresse privée/interne refusée")
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def is_safe_target(url: str) -> bool:
|
||||||
|
"""Full SSRF check performed right before dispatch (resolves the host).
|
||||||
|
|
||||||
|
Returns False when the URL is malformed, the scheme is forbidden, or any
|
||||||
|
resolved address is private/loopback/reserved.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
validate_webhook_url(url)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if _allow_private():
|
||||||
|
return True
|
||||||
|
parsed = urlparse(url)
|
||||||
|
host = parsed.hostname or ""
|
||||||
|
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||||
|
try:
|
||||||
|
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
||||||
|
except socket.gaierror:
|
||||||
|
logger.warning(f"Webhook target host could not be resolved: {host}")
|
||||||
|
return False
|
||||||
|
for info in infos:
|
||||||
|
addr = str(info[4][0])
|
||||||
|
if not _is_public_ip(addr):
|
||||||
|
logger.warning(f"Webhook target resolves to a non-public address ({addr}); blocked")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _read() -> list:
|
def _read() -> list:
|
||||||
if not WEBHOOKS_FILE.exists():
|
if not WEBHOOKS_FILE.exists():
|
||||||
return []
|
return []
|
||||||
@@ -45,35 +135,94 @@ def _write(webhooks: list):
|
|||||||
tmp.replace(WEBHOOKS_FILE)
|
tmp.replace(WEBHOOKS_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_secrets() -> dict:
|
||||||
|
if not WEBHOOK_SECRETS_FILE.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(WEBHOOK_SECRETS_FILE.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_secrets(secrets: dict):
|
||||||
|
WEBHOOK_SECRETS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = WEBHOOK_SECRETS_FILE.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(secrets, indent=2), encoding="utf-8")
|
||||||
|
tmp.replace(WEBHOOK_SECRETS_FILE)
|
||||||
|
try:
|
||||||
|
WEBHOOK_SECRETS_FILE.chmod(0o600)
|
||||||
|
except OSError:
|
||||||
|
pass # Windows doesn't support Unix permissions
|
||||||
|
|
||||||
|
|
||||||
|
def _store_secret(wh_id: str, secret: str | None) -> None:
|
||||||
|
secrets = _read_secrets()
|
||||||
|
if secret:
|
||||||
|
secrets[wh_id] = secret
|
||||||
|
else:
|
||||||
|
secrets.pop(wh_id, None)
|
||||||
|
_write_secrets(secrets)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_secret(wh: dict) -> str | None:
|
||||||
|
"""Resolve a webhook secret from env, dedicated store, or legacy record."""
|
||||||
|
env_key = "OBSIGATE_WEBHOOK_SECRET_" + wh["id"].replace("-", "_").upper()
|
||||||
|
env_val = os.environ.get(env_key)
|
||||||
|
if env_val:
|
||||||
|
return env_val
|
||||||
|
stored = _read_secrets().get(wh["id"])
|
||||||
|
if stored:
|
||||||
|
return stored
|
||||||
|
return wh.get("secret") # legacy inline secret
|
||||||
|
|
||||||
|
|
||||||
|
def _public_view(wh: dict) -> dict:
|
||||||
|
"""Return a webhook record safe to expose through the API."""
|
||||||
|
clean = {k: v for k, v in wh.items() if k != "secret"}
|
||||||
|
clean["has_secret"] = bool(_get_secret(wh))
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
def get_webhooks() -> list:
|
def get_webhooks() -> list:
|
||||||
return _read()
|
return [_public_view(wh) for wh in _read()]
|
||||||
|
|
||||||
|
|
||||||
def create_webhook(name: str, url: str, events: list[str], secret: str | None = None) -> dict:
|
def create_webhook(name: str, url: str, events: list[str], secret: str | None = None) -> dict:
|
||||||
|
validate_webhook_url(url)
|
||||||
webhooks = _read()
|
webhooks = _read()
|
||||||
|
wh_id = str(uuid.uuid4())
|
||||||
wh = {
|
wh = {
|
||||||
"id": str(uuid.uuid4()),
|
"id": wh_id,
|
||||||
"name": name,
|
"name": name,
|
||||||
"url": url,
|
"url": url,
|
||||||
"events": [e for e in events if e in VALID_EVENTS],
|
"events": [e for e in events if e in VALID_EVENTS],
|
||||||
"secret": secret,
|
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"last_fired_at": None,
|
"last_fired_at": None,
|
||||||
}
|
}
|
||||||
webhooks.append(wh)
|
webhooks.append(wh)
|
||||||
_write(webhooks)
|
_write(webhooks)
|
||||||
|
if secret:
|
||||||
|
_store_secret(wh_id, secret)
|
||||||
logger.info(f"Created webhook '{name}' → {url}")
|
logger.info(f"Created webhook '{name}' → {url}")
|
||||||
return wh
|
return _public_view(wh)
|
||||||
|
|
||||||
|
|
||||||
def update_webhook(wh_id: str, updates: dict) -> dict | None:
|
def update_webhook(wh_id: str, updates: dict) -> dict | None:
|
||||||
webhooks = _read()
|
webhooks = _read()
|
||||||
for wh in webhooks:
|
for wh in webhooks:
|
||||||
if wh["id"] == wh_id:
|
if wh["id"] == wh_id:
|
||||||
wh.update({k: v for k, v in updates.items() if k != "id"})
|
if updates.get("url"):
|
||||||
|
validate_webhook_url(updates["url"])
|
||||||
|
if "secret" in updates:
|
||||||
|
_store_secret(wh_id, updates["secret"])
|
||||||
|
safe_updates = {
|
||||||
|
k: v for k, v in updates.items()
|
||||||
|
if k not in ("id", "secret")
|
||||||
|
}
|
||||||
|
wh.update(safe_updates)
|
||||||
_write(webhooks)
|
_write(webhooks)
|
||||||
return wh
|
return _public_view(wh)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -83,6 +232,9 @@ def delete_webhook(wh_id: str) -> bool:
|
|||||||
if len(new_list) == len(webhooks):
|
if len(new_list) == len(webhooks):
|
||||||
return False
|
return False
|
||||||
_write(new_list)
|
_write(new_list)
|
||||||
|
secrets = _read_secrets()
|
||||||
|
if secrets.pop(wh_id, None) is not None:
|
||||||
|
_write_secrets(secrets)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -102,17 +254,24 @@ async def dispatch_webhooks(event_type: str, data: dict):
|
|||||||
|
|
||||||
async def _post(wh):
|
async def _post(wh):
|
||||||
try:
|
try:
|
||||||
|
# BUG-026: re-check the target right before connecting (DNS rebinding).
|
||||||
|
if not is_safe_target(wh["url"]):
|
||||||
|
logger.warning(f"Webhook '{wh['name']}' blocked by SSRF policy")
|
||||||
|
return
|
||||||
headers = {"Content-Type": "application/json", "X-ObsiGate-Event": event_type}
|
headers = {"Content-Type": "application/json", "X-ObsiGate-Event": event_type}
|
||||||
if wh.get("secret"):
|
secret = _get_secret(wh)
|
||||||
sig = hmac.new(wh["secret"].encode(), body.encode(), hashlib.sha256).hexdigest()
|
if secret:
|
||||||
|
sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
|
||||||
headers["X-ObsiGate-Signature"] = f"sha256={sig}"
|
headers["X-ObsiGate-Signature"] = f"sha256={sig}"
|
||||||
|
|
||||||
timeout = aiohttp.ClientTimeout(total=5)
|
timeout = aiohttp.ClientTimeout(total=5)
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as session, session.post(wh["url"], data=body, headers=headers) as resp:
|
async with aiohttp.ClientSession(timeout=timeout) as session, session.post(
|
||||||
if resp.status < 400:
|
wh["url"], data=body, headers=headers, allow_redirects=False
|
||||||
logger.debug(f"Webhook '{wh['name']}' OK ({resp.status})")
|
) as resp:
|
||||||
else:
|
if resp.status < 400:
|
||||||
logger.warning(f"Webhook '{wh['name']}' failed ({resp.status})")
|
logger.debug(f"Webhook '{wh['name']}' OK ({resp.status})")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Webhook '{wh['name']}' failed ({resp.status})")
|
||||||
update_webhook(wh["id"], {"last_fired_at": datetime.now(timezone.utc).isoformat()})
|
update_webhook(wh["id"], {"last_fired_at": datetime.now(timezone.utc).isoformat()})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Webhook '{wh['name']}' error: {e}")
|
logger.warning(f"Webhook '{wh['name']}' error: {e}")
|
||||||
|
|||||||
+28
-1
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
- **Projet** : ObsiGate — Porte d'entrée web pour vaults Obsidian
|
- **Projet** : ObsiGate — Porte d'entrée web pour vaults Obsidian
|
||||||
- **Stack** : Python 3.11+ (backend FastAPI) · JavaScript/Vanilla (frontend) · Tauri/Rust (desktop)
|
- **Stack** : Python 3.11+ (backend FastAPI) · JavaScript/Vanilla (frontend) · Tauri/Rust (desktop)
|
||||||
- **Dernière mise à jour** : 2026-09-12
|
- **Dernière mise à jour** : 2026-09-13
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -126,6 +126,30 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
|||||||
| *BUG-014* | Assistant IA : les flèches ↑/↓ ne naviguent pas dans les menus `/` et `@` | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Ouvrir `/`, puis ↑/↓ (y compris après un clic hors zone de texte) | Navigation gérée au niveau du **panneau en phase de capture** (`panel.addEventListener('keydown', ..., true)`), donc indépendante du focus | Le `keydown` n'était écouté que sur la zone de texte ; dès que le focus changeait, les flèches étaient ignorées. Tests : `tests/frontend/ai.test.mjs` (+1) |
|
| *BUG-014* | Assistant IA : les flèches ↑/↓ ne naviguent pas dans les menus `/` et `@` | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Ouvrir `/`, puis ↑/↓ (y compris après un clic hors zone de texte) | Navigation gérée au niveau du **panneau en phase de capture** (`panel.addEventListener('keydown', ..., true)`), donc indépendante du focus | Le `keydown` n'était écouté que sur la zone de texte ; dès que le focus changeait, les flèches étaient ignorées. Tests : `tests/frontend/ai.test.mjs` (+1) |
|
||||||
| *BUG-015* | Assistant IA : la ligne sélectionnée des menus `/` et `@` est invisible au clavier | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css` | Ouvrir `/` ou `@`, naviguer avec ↑/↓ | État `.active` en `--bg-hover` + barre d'accent à gauche (`inset 3px 0 0 var(--accent)`) au lieu de `--surface2` | `--surface2` est identique à `--bg-primary` (fond du menu) en thème sombre : la sélection ne se voyait pas. Idem pour la liste de modèles |
|
| *BUG-015* | Assistant IA : la ligne sélectionnée des menus `/` et `@` est invisible au clavier | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/style.css` | Ouvrir `/` ou `@`, naviguer avec ↑/↓ | État `.active` en `--bg-hover` + barre d'accent à gauche (`inset 3px 0 0 var(--accent)`) au lieu de `--surface2` | `--surface2` est identique à `--bg-primary` (fond du menu) en thème sombre : la sélection ne se voyait pas. Idem pour la liste de modèles |
|
||||||
| *BUG-016* | Mobile : le bouton « mode lecture » flottant recouvre le bouton d'envoi de l'assistant IA | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/mobile-editor.js`, `frontend/style.css` | Mobile, sans fichier ouvert : ouvrir l'assistant puis constater que `📖` masque `✈️` | `updateReadingButtonVisibility()` n'affiche `#me-reading-btn` que si `state.currentPath` est défini **et** que le panneau assistant est fermé ; resynchronisé à chaque mutation de `#content-area` et sur `bookslm:opened`/`bookslm:closed` ; règle `.me-reading-btn[hidden]{display:none}` | Le bouton flottant (z-index 890) passait au-dessus du panneau plein écran mobile (z-index 100). Tests : `tests/frontend/mobile-editor.test.mjs` (+2) |
|
| *BUG-016* | Mobile : le bouton « mode lecture » flottant recouvre le bouton d'envoi de l'assistant IA | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/mobile-editor.js`, `frontend/style.css` | Mobile, sans fichier ouvert : ouvrir l'assistant puis constater que `📖` masque `✈️` | `updateReadingButtonVisibility()` n'affiche `#me-reading-btn` que si `state.currentPath` est défini **et** que le panneau assistant est fermé ; resynchronisé à chaque mutation de `#content-area` et sur `bookslm:opened`/`bookslm:closed` ; règle `.me-reading-btn[hidden]{display:none}` | Le bouton flottant (z-index 890) passait au-dessus du panneau plein écran mobile (z-index 100). Tests : `tests/frontend/mobile-editor.test.mjs` (+2) |
|
||||||
|
| *BUG-017* | Accueil mobile : les tuiles de « Favoris » et « Récents » ne s'affichent pas correctement en largeur (l'onglet « Partagés » s'affiche correctement) | 🔴 ouvert | P2 | 📱 frontend | IA | `frontend/js/dashboard.js`, `frontend/js/viewer.js`, `frontend/style.css` | Mobile : page d'accueil → onglets Favoris / Récents | — | Grille de tuiles non adaptée au viewport (largeur / nombre de colonnes) ; l'onglet « Partagés » (`.shared-card`) est conforme |
|
||||||
|
| *BUG-018* | Éditeur mobile : mise en page inadaptée — boutons « Annuler » / « Sauvegarder » inaccessibles et barre d'outils IA non optimisée | 🔴 ouvert | P1 | 📱 frontend | IA | `frontend/index.html`, `frontend/style.css`, `frontend/js/ai.js` | Mobile : ouvrir un fichier → barre d'outils de l'éditeur puis panneau assistant | — | Boutons de l'éditeur hors écran / masqués sur petit écran ; barre d'outils IA trop dense ou mal positionnée |
|
||||||
|
| *BUG-019* | Éditeur mobile : la barre d'outils du bas n'est pas toujours visible au-dessus du clavier quand le curseur n'est pas en bas de l'éditeur | 🔴 ouvert | P1 | 📱 frontend | IA | `frontend/js/mobile-editor.js`, `frontend/style.css` | Mobile : ouvrir un fichier, placer le curseur au milieu du document, afficher le clavier | — | La barre ne suit pas la position du curseur / n'est pas ancrée au-dessus du clavier virtuel |
|
||||||
|
| *BUG-020* | Éditeur mobile : deux barres de défilement verticales à droite de la page en mode édition | 🔴 ouvert | P3 | 📱 frontend | IA | `frontend/style.css` | Mobile : ouvrir l'éditeur et observer le bord droit | — | Double scrollbar (conteneur de l'éditeur + `.cm-scroller`) peu naturelle pour un éditeur |
|
||||||
|
| *BUG-021* | [🔴 CRITIQUE] XSS stocké via le rendu markdown (`escape=False`) | 🟢 corrigé | P0 | 🔐 sécurité | IA | `backend/services/sanitizer.py`, `backend/main.py` | Ouvrir une note contenant `<img src=x onerror=alert(1)>` | `backend/services/sanitizer.py` (whitelist stdlib) appliqué après `_add_heading_ids` dans `_render_markdown` ; tests `tests/test_security_hardening.py` | HTML brut d'un vault injecté dans le DOM → JS dans le navigateur de chaque utilisateur. DOMPurify client non ajouté (défense serveur suffisante) |
|
||||||
|
| *BUG-022* | [🔴 CRITIQUE] XSS stocké sur la page publique `/s/{token}` (title + frontmatter non échappés) | 🟢 corrigé | P0 | 🔐 sécurité | IA | `backend/main.py` | Partager une note dont le frontmatter contient un `title` avec `<script>` | `backend/main.py` : `html.escape()` sur `title`/frontmatter + JSON échappé (`\u003c`) pour le bloc `<script>` et `a.download` | `a.download="{title}.md"` se trouvait dans un bloc script → breakout JS. Test d'intégration dans `tests/test_security_hardening.py` |
|
||||||
|
| *BUG-023* | [🔴 CRITIQUE] Brute-force MFA sans rate-limit ni verrouillage | 🟢 corrigé | P0 | 🔐 sécurité | IA | `backend/auth/router.py` | `POST /api/auth/mfa/totp/verify` en boucle avec des codes aléatoires | `_enforce_mfa_rate_limit` + `_record_mfa_failure` sur `totp/verify`, `recovery`, `webauthn/verify` (IP + compte + lockout) | TOTP 6 chiffres brute-forceable ; le login était protégé, pas le second facteur |
|
||||||
|
| *BUG-024* | [🔴 CRITIQUE] Traversal inter-vaults : `startswith()` sans séparateur dans `resolve_safe_path` | 🟢 corrigé | P0 | 🔐 sécurité | IA | `backend/services/paths.py` | Avec les vaults `vault` et `vault-evil`, lire un fichier de `vault-evil` via `vault` | `_is_within()` par segments (`relative_to` + repli casse-insensible) ; tests `tests/test_security_hardening.py::TestPathIsolation` | Rupture d'isolation entre vaults (lecture / écriture / suppression) |
|
||||||
|
| *BUG-025* | [🟡 IMPORTANT] ReDoS : regex utilisateur appliquée au contenu en masse | 🟢 corrigé | P1 | 🔐 sécurité | IA | `backend/services/regex_safety.py`, `backend/search.py`, `backend/services/mutations.py` | Recherche avancée avec `^(a+)+$` sur un gros document | Validation de pattern (longueur ≤500, rejet quantificateurs imbriqués/backrefs), contenu tronqué (200k) et matchs plafonnés ; 400 sur pattern refusé | Le dry-run `replace_in_files` déclenchait la charge ; la validation est faite avant compilation |
|
||||||
|
| *BUG-026* | [🟡 IMPORTANT] Webhooks : SSRF (URL non validée) + secret en clair | 🟢 corrigé | P1 | 🔐 sécurité | IA | `backend/webhooks.py`, `data/webhook_secrets.json` | Configurer une URL vers `http://169.254.169.254` puis déclencher le webhook | Validation d'URL (HTTPS par défaut, IP privées/boucle bloquées), résolution DNS au dispatch, redirections non suivies ; secret dans `webhook_secrets.json` (0600) ou env `OBSIGATE_WEBHOOK_SECRET_<ID>` | `get_webhooks()` n'expose plus le secret (`has_secret`) ; options `OBSIGATE_WEBHOOK_ALLOW_HTTP`/`_ALLOW_PRIVATE` |
|
||||||
|
| *BUG-027* | [🟡 IMPORTANT] Sessions : refresh non rotatif, access token non révoqué au logout | 🟢 corrigé | P1 | 🔐 sécurité | IA | `backend/auth/jwt_handler.py`, `backend/auth/router.py`, `backend/auth/middleware.py` | Logout puis réutilisation de l'ancien access token | Rotation du refresh token (`create_refresh_token(remember=…)`) + révocation de l'ancien JTI ; access token révoqué au logout et vérifié dans le middleware | JTI persistés dans `data/revoked_tokens.json` |
|
||||||
|
| *BUG-028* | [🟡 IMPORTANT] Politique de mot de passe incohérente + sessions non invalidées au changement | 🟢 corrigé | P1 | 🔐 sécurité | IA | `backend/auth/password.py`, `backend/auth/router.py`, `backend/auth/user_store.py`, `backend/auth/middleware.py` | Créer un utilisateur avec `password:""` (admin) ; changer son mot de passe puis réutiliser l'ancien token | `validate_password_strength` (8–128) sur création/modif admin/changement ; `password_changed_at` invalide les jetons émis avant le changement ; `change-password` réémet une paire | Tests `TestPasswordPolicy` + `TestTokenInvalidation` |
|
||||||
|
| *BUG-029* | [🟡 IMPORTANT] Race read-modify-write sur `users.json` (perte de mises à jour) | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/auth/user_store.py` | Activer MFA et changer son mot de passe en parallèle | `threading.RLock` (`_users_lock`) autour de `create_user`, `update_user`, `delete_user`, `record_login_failure` | RLock réentrant car `record_login_failure` appelle `update_user` |
|
||||||
|
| *BUG-030* | [🟡 IMPORTANT] Adresse IP jamais consignée dans les audits (`_request_ip` toujours « unknown ») | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/services/net.py`, `backend/auth/middleware.py` | Lire le journal d'audit après un save/delete | `get_client_ip()` (`request.client.host`, `X-Forwarded-For` si `OBSIGATE_TRUST_PROXY=true`) injecté dans `current_user["_request_ip"]` | Test `TestClientIp` |
|
||||||
|
| *BUG-031* | [🟡 IMPORTANT] Rate-limiter en mémoire, mono-process, budget exclusivement IP | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/ratelimit.py`, `backend/auth/router.py` | Lancer plusieurs workers et observer le partage du compteur | Budget **par compte** (`is_account_rate_limited`/`record_account_*`) en plus de l'IP, intégré login + MFA ; limite mono-process documentée | Rotation d'IP neutralisée ; stockage partagé (Redis) hors périmètre |
|
||||||
|
| *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 |
|
||||||
|
|
||||||
### TODOs techniques (améliorations / nouvelles tâches)
|
### TODOs techniques (améliorations / nouvelles tâches)
|
||||||
|
|
||||||
@@ -158,6 +182,9 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
|||||||
| 2026-09-12 | #82 | Amélioration perf + UX | `backend/services/search.py`, `backend/main.py`, `frontend/js/bookslm.js`, `frontend/js/ui.js`, `frontend/style.css`, `tests/test_api_main.py`, `tests/frontend/ai.test.mjs` | #82 : endpoint `GET /api/vault/{vault}/paths` + préchargement et filtrage client du menu `@` (instantané) ; sélecteurs Fournisseur/Modèle agrandis (0,8 rem / 34 px) ; « 🧠 BooksLM » ajouté au menu contextuel de la racine des vaults. Vérifié : pytest 151 (ciblés) + ruff/mypy, tests frontend 49/49. | ✅ livré (en attente vérif utilisateur) |
|
| 2026-09-12 | #82 | Amélioration perf + UX | `backend/services/search.py`, `backend/main.py`, `frontend/js/bookslm.js`, `frontend/js/ui.js`, `frontend/style.css`, `tests/test_api_main.py`, `tests/frontend/ai.test.mjs` | #82 : endpoint `GET /api/vault/{vault}/paths` + préchargement et filtrage client du menu `@` (instantané) ; sélecteurs Fournisseur/Modèle agrandis (0,8 rem / 34 px) ; « 🧠 BooksLM » ajouté au menu contextuel de la racine des vaults. Vérifié : pytest 151 (ciblés) + ruff/mypy, tests frontend 49/49. | ✅ livré (en attente vérif utilisateur) |
|
||||||
| 2026-09-12 | #82 | UX | `frontend/js/ai.js`, `frontend/style.css`, `tests/frontend/ai.test.mjs` | Sélecteurs Fournisseur/Modèle alignés à droite dans la barre de l'assistant et réordonnés : capacité du modèle (ⓘ) → fournisseur → modèle. Bulle de capacités ouverte vers la droite (`left: 0`). Test JSDOM de l'ordre. Vérifié : tests frontend 49/49. | ✅ livré (en attente vérif utilisateur) |
|
| 2026-09-12 | #82 | UX | `frontend/js/ai.js`, `frontend/style.css`, `tests/frontend/ai.test.mjs` | Sélecteurs Fournisseur/Modèle alignés à droite dans la barre de l'assistant et réordonnés : capacité du modèle (ⓘ) → fournisseur → modèle. Bulle de capacités ouverte vers la droite (`left: 0`). Test JSDOM de l'ordre. Vérifié : tests frontend 49/49. | ✅ livré (en attente vérif utilisateur) |
|
||||||
| 2026-09-12 | BUG-016 | Correction | `frontend/js/mobile-editor.js`, `frontend/style.css`, `tests/frontend/mobile-editor.test.mjs`, `tests/e2e/mobile-editor.spec.js` | BUG-016 : le bouton « mode lecture » n'apparaît que si un fichier est ouvert et que l'assistant est fermé, sinon il recouvrait le bouton d'envoi sur mobile. Vérifié : tests frontend 24/24 (mobile) + suite JSDOM verte. | 🟢 corrigé (en attente vérif utilisateur) |
|
| 2026-09-12 | BUG-016 | Correction | `frontend/js/mobile-editor.js`, `frontend/style.css`, `tests/frontend/mobile-editor.test.mjs`, `tests/e2e/mobile-editor.spec.js` | BUG-016 : le bouton « mode lecture » n'apparaît que si un fichier est ouvert et que l'assistant est fermé, sinon il recouvrait le bouton d'envoi sur mobile. Vérifié : tests frontend 24/24 (mobile) + suite JSDOM verte. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
|
| 2026-09-13 | BUG-017, BUG-018, BUG-019, BUG-020, #83 | Enregistrement | `docs/ISSUES_TODOLIST.md`, `docs/ROADMAP.md` | Signalements mobile consignés : accueil (tuiles Favoris/Récents), éditeur (boutons Annuler/Sauvegarder inaccessibles + barre IA), visibilité de la barre d'outils au-dessus du clavier, double barre de défilement. Feature #83 ajoutée : barre d'outils d'édition mobile style Obsidian Android. | 🔴 ouvert (à traiter) |
|
||||||
|
| 2026-09-13 | Revue sécurité statique (BUG-021 → BUG-040, roadmap #84 → #87) | Enregistrement | `docs/ISSUES_TODOLIST.md`, `docs/ROADMAP.md` | Revue statique 2026-09-13 consignée : XSS markdown (`escape=False`) + page de partage publique, brute-force MFA, traversal par préfixe `paths.py`, ReDoS, SSRF webhooks, cycle de vie des sessions, politique de mot de passe, races `users.json`, audits IP, rate-limit, indexation symlinks, recherche O(N), CSP. BUG-021→BUG-040 ouverts au registre ; roadmap #84→#87 (consolidation/sécurité, refonte architecturale, performance, CI/CD). | 🔴 ouvert (à traiter) |
|
||||||
|
| 2026-09-13 | BUG-021 → BUG-034 (#84 phase 1) | Correction | `backend/services/sanitizer.py`, `backend/services/paths.py`, `backend/services/net.py`, `backend/services/regex_safety.py`, `backend/main.py`, `backend/search.py`, `backend/webhooks.py`, `backend/indexer.py`, `backend/ratelimit.py`, `backend/auth/{router,user_store,password,jwt_handler,middleware}.py`, `frontend/js/{auth,admin,sync}.js`, `tests/test_security_hardening.py`, `tests/test_auth_api.py` | Sanitizer XSS serveur (markdown + page de partage), rate-limit/lockout MFA, isolation vaults par segments, caps regex (ReDoS), SSRF webhooks + secrets externalisés, rotation/révocation des jetons, politique de mot de passe + invalidation des sessions, verrous `users.json`, IP réelle dans les audits, rate-limit par compte, symlinks d'index ignorés, recherche simple via inverted index, token en mémoire + cookie HttpOnly + CSP durcie. Vérifié : pytest 961 passed / 6 skipped, ruff 0, mypy 0, tests frontend 36 modules + 9 JSDOM suites verts. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+71
-2
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# ObsiGate — Roadmap
|
||||||
|
|
||||||
> **Version :** 2.3.0-dev | **Dernière mise à jour :** 2026-09-12
|
> **Version :** 2.3.0-dev | **Dernière mise à jour :** 2026-09-13
|
||||||
> **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)**
|
||||||
@@ -60,6 +60,72 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ⚪ Backlog — Priorité 2 (P2)
|
||||||
|
|
||||||
|
### 83. Barre d'outils d'édition mobile — style Obsidian Android
|
||||||
|
|
||||||
|
- **Effort :** 3-5 jours | **Impact :** 🟡 | **Zone :** frontend (mobile)
|
||||||
|
- **Description :** remplacer la barre de mise en forme Markdown actuelle par un **ruban horizontal
|
||||||
|
défilable** ancré juste au-dessus du clavier virtuel, reprenant l'ergonomie de l'app Android
|
||||||
|
Obsidian : fond anthracite aux coins arrondis, insertion/enrobage de la syntaxe au curseur ou sur
|
||||||
|
la sélection, et personnalisation des commandes via une icône clé à molette.
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [ ] Ruban horizontal défilable (glissement tactile gauche/droite) ancré au-dessus du clavier
|
||||||
|
- [ ] Actions rapides : annuler, refaire, `[[ ]]` (lien interne), modèle/fichiers, tag `#`, pièce jointe
|
||||||
|
- [ ] Formatage : H1–H6, gras, italique, barré (`~~`), surligné (`==`), code en ligne/bloc, citation (`>`)
|
||||||
|
- [ ] Liens externes, listes à puces/numérotées, case à cocher (`- [ ]`), indenter / désindenter
|
||||||
|
- [ ] Personnalisation (clé à molette) : ajouter / supprimer / réordonner les commandes
|
||||||
|
- [ ] i18n FR/EN + tests frontend (helpers purs) + E2E mobile
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚪ Backlog — Sécurité, architecture & performance (P0/P1)
|
||||||
|
|
||||||
|
### 84. Consolidation & sécurité — revue statique 2026-09-13 (phase 1)
|
||||||
|
|
||||||
|
- **Effort :** 6-9 jours | **Impact :** 🔴 | **Zone :** backend + frontend | **Référence :** [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) BUG-021 → BUG-034
|
||||||
|
- **Statut :** 🟢 livré (phase 1) — sanitizer XSS, rate-limit/lockout MFA, isolation vaults, ReDoS, SSRF webhooks, cycle de vie des sessions, politique de mot de passe, verrous `users.json`, audits IP, rate-limit par compte, symlinks, recherche via inverted index, token en cookie HttpOnly. Détail : [archive/COMPLETED_v1-v2.md](./archive/COMPLETED_v1-v2.md) (section #84).
|
||||||
|
- **Description :** traiter toutes les vulnérabilités critiques et importantes issues de la revue statique : XSS markdown (`escape=False`) et page publique de partage, brute-force MFA, isolation des vaults (`resolve_safe_path`), ReDoS, SSRF webhooks, cycle de vie des sessions, politique de mot de passe, races `users.json`, audits IP, rate-limit partagé, indexation symlinks.
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [x] Assainir le rendu markdown (sanitizer serveur en whitelist) et la page de partage (échappement `title`/frontmatter) — *DOMPurify client non ajouté (défense en profondeur serveur suffisante)*
|
||||||
|
- [x] Rate-limit + lockout sur les endpoints MFA (`totp/verify`, `recovery`, `webauthn/verify`)
|
||||||
|
- [x] Corriger `resolve_safe_path` (comparaison de chemin stricte par segment) + test de régression
|
||||||
|
- [x] Rotation du refresh token, révocation de l'access token au logout, persistance des JTI révoqués
|
||||||
|
- [x] Valider la politique de mot de passe à la création ; bloquer le SSRF des webhooks et externaliser les secrets
|
||||||
|
- [x] Verrous sur les mutations `users.json` ; consigner l'adresse IP réelle dans les audits
|
||||||
|
- [x] Ignorer les symlinks de l'index ; caps CPU/timeout regex (ReDoS)
|
||||||
|
- [~] Durcir la CSP — *partiel* : directives `object-src`/`base-uri`/`form-action`/`frame-ancestors` ajoutées et token retiré de `sessionStorage` ; migration **nonce** restante (nécessite la conversion des gestionnaires d'événements inline)
|
||||||
|
|
||||||
|
### 85. Refonte architecturale — découpage du monolithe & persistance d'état (phase 2)
|
||||||
|
|
||||||
|
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Zone :** backend
|
||||||
|
- **Description :** extraire le monolithe `backend/main.py` (~4 260 lignes) en routers FastAPI par domaine et rendre persistant l'état qui ne l'est pas (index de recherche, JTI révoqués, compteurs de rate-limit) pour préparer le multi-nœuds.
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai
|
||||||
|
- [ ] Centraliser le contrat d'outils IA sur `tools/registry.py` (permissions, quotas, redaction)
|
||||||
|
- [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite/Redis)
|
||||||
|
- [ ] Verrous asyncio autour de l'index global et des stores JSON ; service de partage public (expiration, révocation, quotas)
|
||||||
|
|
||||||
|
### 86. Optimisation globale des performances (phase 3)
|
||||||
|
|
||||||
|
- **Effort :** 4-6 jours | **Impact :** 🟡 | **Zone :** backend (`search.py`, `indexer.py`, `mutations.py`)
|
||||||
|
- **Description :** brancher l'inverted index (déjà construit) sur la recherche simple et le tool IA `search_fulltext`, indexation incrémentale, extraction PDF lazy.
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [ ] Recherche simple + tool IA via l'inverted index (suppression du balayage O(N) en mémoire)
|
||||||
|
- [ ] Indexation incrémentale + scan différentiel au démarrage (remplace le `rglob` complet)
|
||||||
|
- [ ] Extraction PDF/excalidraw différée (hors scan) ; caps CPU sur les opérations regex
|
||||||
|
|
||||||
|
### 87. Amélioration continue — tests, CI/CD, revues de sécurité (phase 4)
|
||||||
|
|
||||||
|
- **Effort :** 3-5 jours | **Impact :** 🟡 | **Zone :** `.gitea/workflows/`, `tests/`
|
||||||
|
- **Description :** renforcer le pipeline (`.gitea/workflows/ci.yml`, `desktop-build.yml`) pour le rendre bloquant par défaut et accompagner les phases 1 → 3.
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [ ] Jobs CI sécurité (bandit/semgrep/trivy, audits pip/npm) + tests E2E XSS (page de partage + lecteur markdown)
|
||||||
|
- [ ] Tests de concurrence (`users.json`), fuzzing de timing regex, couverture des composants critiques
|
||||||
|
- [ ] Revue périodique des dépendances ; documentation utilisateur FR/EN synchronisée ; contrôle automatisé de la conformité au DoD
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ✅ Complété — index
|
## ✅ Complété — index
|
||||||
|
|
||||||
> Détail complet dans [docs/archive/COMPLETED_v1-v2.md](./archive/COMPLETED_v1-v2.md) et
|
> Détail complet dans [docs/archive/COMPLETED_v1-v2.md](./archive/COMPLETED_v1-v2.md) et
|
||||||
@@ -99,6 +165,7 @@
|
|||||||
| 69 | Éditeur mobile natif — Interface tactile optimisée | 2.3.0 | [features/mobile-editor.md](./features/mobile-editor.md) |
|
| 69 | Éditeur mobile natif — Interface tactile optimisée | 2.3.0 | [features/mobile-editor.md](./features/mobile-editor.md) |
|
||||||
| 70 | Recherche sémantique — Embeddings vectoriels (hybride TF-IDF + RRF) | 2.3.0 | [features/semantic-search.md](./features/semantic-search.md) |
|
| 70 | Recherche sémantique — Embeddings vectoriels (hybride TF-IDF + RRF) | 2.3.0 | [features/semantic-search.md](./features/semantic-search.md) |
|
||||||
| 77 | Application Desktop native — Tauri | 🔵 en cours | [features/desktop-tauri.md](./features/desktop-tauri.md) |
|
| 77 | Application Desktop native — Tauri | 🔵 en cours | [features/desktop-tauri.md](./features/desktop-tauri.md) |
|
||||||
|
| 84 | Consolidation & sécurité — revue statique 2026-09-13 (phase 1, BUG-021→034) | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -108,8 +175,10 @@
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–82 | ~107 jours réalisés |
|
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–82 | ~107 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 |
|
||||||
|
| ⚪ P2 restant | #83 Barre d'outils d'édition mobile style Obsidian (3-5j) | 3-5 jours |
|
||||||
| ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
|
| ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
|
||||||
| **Total restant** | **2 items + finitions** | **~7-10 jours** |
|
| ⚪ P0/P1 restant | #85-87 Refonte architecturale, performance, CI/CD (issues BUG-035 → BUG-040) | ~15-23 jours |
|
||||||
|
| **Total restant** | **7 items + finitions** | **~31-47 jours** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -317,6 +317,31 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## #84 — Consolidation & sécurité (phase 1) ✅ TERMINÉ
|
||||||
|
|
||||||
|
Traitement des vulnérabilités de la revue statique du 2026-09-13 (BUG-021 → BUG-034).
|
||||||
|
|
||||||
|
- **Sanitizer XSS serveur** (`backend/services/sanitizer.py`, stdlib, liste blanche
|
||||||
|
de balises/attributs/schémas) appliqué au rendu markdown et à la page publique
|
||||||
|
`/s/{token}` (titre, frontmatter et JSON échappés, `</script>` neutralisé).
|
||||||
|
- **Auth durcie** : rate-limit IP + compte et verrouillage sur les endpoints MFA ;
|
||||||
|
rotation du refresh token et révocation de l'access token au logout ; politique de
|
||||||
|
mot de passe centralisée (8–128) ; `password_changed_at` invalide les jetons
|
||||||
|
antérieurs ; verrou `RLock` sur `users.json`.
|
||||||
|
- **Isolation & réseau** : `resolve_safe_path` compare par segments (`vault` ≠
|
||||||
|
`vault-evil`) ; webhooks protégés contre le SSRF (HTTPS, IP privées bloquées,
|
||||||
|
résolution vérifiée, redirections interdites, secret externalisé) ; IP client
|
||||||
|
réelle dans les audits.
|
||||||
|
- **Robustesse** : validation des regex (ReDoS), symlinks hors vault ignorés à
|
||||||
|
l'indexation, recherche simple et `search_fulltext` branchés sur l'inverted index.
|
||||||
|
- **Frontend** : token d'accès en mémoire + cookie `HttpOnly` (plus de
|
||||||
|
`sessionStorage`), CSP durcie (`object-src`, `base-uri`, `form-action`,
|
||||||
|
`frame-ancestors`).
|
||||||
|
- **Tests** : `tests/test_security_hardening.py` (30) ; suite backend 961 passed /
|
||||||
|
6 skipped ; ruff + mypy 0 ; suites frontend vertes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Grosses fonctionnalités — fiches dédiées
|
## Grosses fonctionnalités — fiches dédiées
|
||||||
|
|
||||||
| # | Feature | Version | Fiche |
|
| # | Feature | Version | Fiche |
|
||||||
|
|||||||
@@ -28,17 +28,12 @@ let _auditFilters = { user: "", action: "" };
|
|||||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read the current Bearer token from sessionStorage (same key auth.js uses).
|
* BUG-034: the access token now lives in an HttpOnly cookie (and in memory
|
||||||
* Returns an `{Authorization: "Bearer ..."}` object or null.
|
* inside auth.js), never in sessionStorage. Same-origin requests carry the
|
||||||
|
* cookie automatically, so no Authorization header is needed here.
|
||||||
*/
|
*/
|
||||||
export function getAuthHeaders() {
|
export function getAuthHeaders() {
|
||||||
try {
|
return null;
|
||||||
const token = sessionStorage.getItem("obsigate_access_token");
|
|
||||||
if (!token) return null;
|
|
||||||
return { Authorization: "Bearer " + token };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format a size in MB as a human-readable string. */
|
/** Format a size in MB as a human-readable string. */
|
||||||
|
|||||||
+18
-6
@@ -85,12 +85,18 @@ const AuthManager = {
|
|||||||
TOKEN_EXPIRY_KEY: "obsigate_token_expiry",
|
TOKEN_EXPIRY_KEY: "obsigate_token_expiry",
|
||||||
USER_KEY: "obsigate_user",
|
USER_KEY: "obsigate_user",
|
||||||
_authEnabled: false,
|
_authEnabled: false,
|
||||||
|
// BUG-034: the access token is kept in memory only. The server also sets it
|
||||||
|
// as an HttpOnly cookie, so a page reload re-authenticates via /api/auth/refresh
|
||||||
|
// without ever exposing the token to JavaScript-readable storage.
|
||||||
|
_accessToken: null,
|
||||||
|
|
||||||
// ── Token storage (sessionStorage) ─────────────────────────────
|
// ── Token storage (in-memory + HttpOnly cookie) ────────────────
|
||||||
|
|
||||||
saveToken(tokenData) {
|
saveToken(tokenData) {
|
||||||
const expiresAt = Date.now() + tokenData.expires_in * 1000;
|
const expiresAt = Date.now() + tokenData.expires_in * 1000;
|
||||||
sessionStorage.setItem(this.ACCESS_TOKEN_KEY, tokenData.access_token);
|
this._accessToken = tokenData.access_token;
|
||||||
|
// Clear any token persisted by an older build (XSS-readable).
|
||||||
|
try { sessionStorage.removeItem(this.ACCESS_TOKEN_KEY); } catch (e) { /* ignore */ }
|
||||||
sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiresAt.toString());
|
sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiresAt.toString());
|
||||||
if (tokenData.user) {
|
if (tokenData.user) {
|
||||||
sessionStorage.setItem(this.USER_KEY, JSON.stringify(tokenData.user));
|
sessionStorage.setItem(this.USER_KEY, JSON.stringify(tokenData.user));
|
||||||
@@ -98,7 +104,11 @@ const AuthManager = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getToken() {
|
getToken() {
|
||||||
return sessionStorage.getItem(this.ACCESS_TOKEN_KEY);
|
return this._accessToken;
|
||||||
|
},
|
||||||
|
|
||||||
|
hasSession() {
|
||||||
|
return !!this._accessToken || !!sessionStorage.getItem(this.TOKEN_EXPIRY_KEY);
|
||||||
},
|
},
|
||||||
|
|
||||||
getUser() {
|
getUser() {
|
||||||
@@ -114,7 +124,8 @@ const AuthManager = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearSession() {
|
clearSession() {
|
||||||
sessionStorage.removeItem(this.ACCESS_TOKEN_KEY);
|
this._accessToken = null;
|
||||||
|
try { sessionStorage.removeItem(this.ACCESS_TOKEN_KEY); } catch (e) { /* ignore */ }
|
||||||
sessionStorage.removeItem(this.TOKEN_EXPIRY_KEY);
|
sessionStorage.removeItem(this.TOKEN_EXPIRY_KEY);
|
||||||
sessionStorage.removeItem(this.USER_KEY);
|
sessionStorage.removeItem(this.USER_KEY);
|
||||||
},
|
},
|
||||||
@@ -281,7 +292,8 @@ const AuthManager = {
|
|||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const expiry = Date.now() + data.expires_in * 1000;
|
const expiry = Date.now() + data.expires_in * 1000;
|
||||||
sessionStorage.setItem(this.ACCESS_TOKEN_KEY, data.access_token);
|
this._accessToken = data.access_token;
|
||||||
|
try { sessionStorage.removeItem(this.ACCESS_TOKEN_KEY); } catch (e) { /* ignore */ }
|
||||||
sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiry.toString());
|
sessionStorage.setItem(this.TOKEN_EXPIRY_KEY, expiry.toString());
|
||||||
return data.access_token;
|
return data.access_token;
|
||||||
},
|
},
|
||||||
@@ -359,7 +371,7 @@ const AuthManager = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auth enabled — check for existing session
|
// Auth enabled — check for existing session
|
||||||
if (this.getToken() && !this.isTokenExpired()) {
|
if (this.hasSession() && !this.isTokenExpired()) {
|
||||||
this.showApp();
|
this.showApp();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -612,7 +612,7 @@ export function init() {
|
|||||||
// fresh login, so we only do it here for already-authenticated or
|
// fresh login, so we only do it here for already-authenticated or
|
||||||
// auth-disabled sessions.
|
// auth-disabled sessions.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!AuthManager._authEnabled || AuthManager.getToken()) {
|
if (!AuthManager._authEnabled || AuthManager.hasSession()) {
|
||||||
syncFileIndexFromServer();
|
syncFileIndexFromServer();
|
||||||
}
|
}
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
|||||||
@@ -1168,6 +1168,23 @@ async function main() {
|
|||||||
assert.ok(vaultBranch.includes("BooksLM"), "BooksLM entry present in the vault root context menu");
|
assert.ok(vaultBranch.includes("BooksLM"), "BooksLM entry present in the vault root context menu");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── BUG-034: the access token is not persisted in sessionStorage ──
|
||||||
|
await test("auth token kept in memory, not in sessionStorage", async () => {
|
||||||
|
const authMod = await import(pathToFileURL(path.join(JS_DIR, "auth.js")).href);
|
||||||
|
const { AuthManager } = authMod;
|
||||||
|
sessionStorage.clear();
|
||||||
|
AuthManager.saveToken({
|
||||||
|
access_token: "secret-token",
|
||||||
|
expires_in: 3600,
|
||||||
|
user: { username: "u", role: "user" },
|
||||||
|
});
|
||||||
|
assert.equal(sessionStorage.getItem("obsigate_access_token"), null, "token must not be stored");
|
||||||
|
assert.equal(AuthManager.getToken(), "secret-token", "token available in memory");
|
||||||
|
assert.ok(AuthManager.hasSession(), "session detected");
|
||||||
|
AuthManager.clearSession();
|
||||||
|
assert.equal(AuthManager.getToken(), null, "session cleared");
|
||||||
|
});
|
||||||
|
|
||||||
// ── Summary ──
|
// ── Summary ──
|
||||||
console.log(`\n${passCount}/${testCount} tests passed`);
|
console.log(`\n${passCount}/${testCount} tests passed`);
|
||||||
if (passCount !== testCount) {
|
if (passCount !== testCount) {
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ class TestAdmin:
|
|||||||
"Authorization": f"Bearer {token}",
|
"Authorization": f"Bearer {token}",
|
||||||
}, json={
|
}, json={
|
||||||
"username": "deleteuser",
|
"username": "deleteuser",
|
||||||
"password": "delpass",
|
"password": "delpass123",
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"vaults": ["TestVault"],
|
"vaults": ["TestVault"],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ class TestWebhooks:
|
|||||||
from backend import webhooks
|
from backend import webhooks
|
||||||
self.wh_file = tmp_path / "webhooks.json"
|
self.wh_file = tmp_path / "webhooks.json"
|
||||||
monkeypatch.setattr(webhooks, "WEBHOOKS_FILE", self.wh_file)
|
monkeypatch.setattr(webhooks, "WEBHOOKS_FILE", self.wh_file)
|
||||||
|
monkeypatch.setattr(webhooks, "WEBHOOK_SECRETS_FILE", tmp_path / "webhook_secrets.json")
|
||||||
yield
|
yield
|
||||||
|
|
||||||
def test_get_empty(self):
|
def test_get_empty(self):
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
# tests/test_security_hardening.py — Regression tests for ROADMAP #84
|
||||||
|
# (BUG-021 → BUG-034): sanitizer, path isolation, password policy, regex
|
||||||
|
# safety, webhook SSRF, rate limiting, audit IP and share-page escaping.
|
||||||
|
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.services.errors import ServiceError
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# BUG-021/022 — HTML sanitizer
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestSanitizer:
|
||||||
|
def test_strips_event_handlers(self):
|
||||||
|
from backend.services.sanitizer import sanitize_html
|
||||||
|
out = sanitize_html('<img src="x" onerror="alert(1)">')
|
||||||
|
assert "onerror" not in out
|
||||||
|
assert "alert" not in out
|
||||||
|
|
||||||
|
def test_drops_script_content(self):
|
||||||
|
from backend.services.sanitizer import sanitize_html
|
||||||
|
out = sanitize_html("<p>ok</p><script>alert(1)</script>tail")
|
||||||
|
assert "<script" not in out
|
||||||
|
assert "alert" not in out
|
||||||
|
assert "ok" in out
|
||||||
|
assert "tail" in out
|
||||||
|
|
||||||
|
def test_blocks_javascript_urls(self):
|
||||||
|
from backend.services.sanitizer import sanitize_html
|
||||||
|
out = sanitize_html('<a href="javascript:alert(1)">x</a>')
|
||||||
|
assert "javascript:" not in out
|
||||||
|
assert ">x</a>" in out
|
||||||
|
|
||||||
|
def test_blocks_obfuscated_javascript_url(self):
|
||||||
|
from backend.services.sanitizer import sanitize_html
|
||||||
|
out = sanitize_html('<a href="javascript:alert(1)">x</a>')
|
||||||
|
assert "javascript" not in out.lower()
|
||||||
|
|
||||||
|
def test_keeps_safe_markup(self):
|
||||||
|
from backend.services.sanitizer import sanitize_html
|
||||||
|
out = sanitize_html('<p class="a">hi</p><a href="/x" data-vault="v" data-path="p.md">y</a>')
|
||||||
|
assert '<p class="a">hi</p>' in out
|
||||||
|
assert 'data-vault="v"' in out
|
||||||
|
assert 'href="/x"' in out
|
||||||
|
|
||||||
|
def test_allows_image_data_uri_but_not_html(self):
|
||||||
|
from backend.services.sanitizer import sanitize_html
|
||||||
|
good = sanitize_html('<img src="data:image/png;base64,AAAA">')
|
||||||
|
assert "data:image/png" in good
|
||||||
|
bad = sanitize_html('<img src="data:text/html,<script>alert(1)</script>">')
|
||||||
|
assert "data:text/html" not in bad
|
||||||
|
|
||||||
|
def test_heading_ids_preserved(self):
|
||||||
|
from backend.services.sanitizer import sanitize_html
|
||||||
|
out = sanitize_html('<h2 id="intro">Intro</h2>')
|
||||||
|
assert 'id="intro"' in out
|
||||||
|
|
||||||
|
def test_drops_style_attribute(self):
|
||||||
|
from backend.services.sanitizer import sanitize_html
|
||||||
|
out = sanitize_html('<p style="position:fixed">x</p>')
|
||||||
|
assert "style" not in out
|
||||||
|
|
||||||
|
def test_is_safe_url(self):
|
||||||
|
from backend.services.sanitizer import is_safe_url
|
||||||
|
assert is_safe_url("/relative")
|
||||||
|
assert is_safe_url("https://example.com")
|
||||||
|
assert is_safe_url("#anchor")
|
||||||
|
assert not is_safe_url("javascript:alert(1)")
|
||||||
|
assert not is_safe_url("vbscript:msgbox")
|
||||||
|
assert not is_safe_url("data:text/html,<script>")
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# BUG-024 — Vault path isolation
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestPathIsolation:
|
||||||
|
def test_inside_vault_ok(self, tmp_path):
|
||||||
|
from backend.services.paths import resolve_safe_path
|
||||||
|
root = tmp_path / "vault"
|
||||||
|
root.mkdir()
|
||||||
|
resolved = resolve_safe_path(root, "note.md")
|
||||||
|
assert resolved == (root / "note.md").resolve()
|
||||||
|
|
||||||
|
def test_sibling_prefix_is_rejected(self, tmp_path):
|
||||||
|
from backend.services.paths import resolve_safe_path
|
||||||
|
(tmp_path / "vault").mkdir()
|
||||||
|
(tmp_path / "vault-evil").mkdir()
|
||||||
|
with pytest.raises(ServiceError) as exc:
|
||||||
|
resolve_safe_path(tmp_path / "vault", "../vault-evil/secret.md")
|
||||||
|
assert exc.value.code == "path_outside_vault"
|
||||||
|
|
||||||
|
def test_parent_traversal_rejected(self, tmp_path):
|
||||||
|
from backend.services.paths import resolve_safe_path
|
||||||
|
root = tmp_path / "vault"
|
||||||
|
root.mkdir()
|
||||||
|
with pytest.raises(ServiceError) as exc:
|
||||||
|
resolve_safe_path(root, "../../etc/passwd")
|
||||||
|
assert exc.value.code == "path_outside_vault"
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# BUG-028 — Password policy
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestPasswordPolicy:
|
||||||
|
def test_short_rejected(self):
|
||||||
|
from backend.auth.password import validate_password_strength
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_password_strength("short")
|
||||||
|
|
||||||
|
def test_too_long_rejected(self):
|
||||||
|
from backend.auth.password import validate_password_strength
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_password_strength("a" * 200)
|
||||||
|
|
||||||
|
def test_valid_accepted(self):
|
||||||
|
from backend.auth.password import validate_password_strength
|
||||||
|
assert validate_password_strength("ValidPass123") == "ValidPass123"
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# BUG-025 — Regex safety (ReDoS)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestRegexSafety:
|
||||||
|
def test_nested_quantifier_rejected(self):
|
||||||
|
from backend.services.regex_safety import validate_regex
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_regex("(a+)+$")
|
||||||
|
|
||||||
|
def test_long_pattern_rejected(self):
|
||||||
|
from backend.services.regex_safety import validate_regex
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_regex("a" * 600)
|
||||||
|
|
||||||
|
def test_simple_pattern_accepted(self):
|
||||||
|
from backend.services.regex_safety import validate_regex
|
||||||
|
assert validate_regex(r"\bpython\b") == r"\bpython\b"
|
||||||
|
|
||||||
|
def test_truncate_for_regex(self):
|
||||||
|
from backend.services.regex_safety import truncate_for_regex
|
||||||
|
assert len(truncate_for_regex("x" * 1000, limit=10)) == 10
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# BUG-026 — Webhook SSRF
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestWebhookUrlValidation:
|
||||||
|
def test_https_public_ok(self):
|
||||||
|
from backend.webhooks import validate_webhook_url
|
||||||
|
assert validate_webhook_url("https://example.com/hook") == "https://example.com/hook"
|
||||||
|
|
||||||
|
def test_http_rejected_by_default(self, monkeypatch):
|
||||||
|
from backend.webhooks import validate_webhook_url
|
||||||
|
monkeypatch.delenv("OBSIGATE_WEBHOOK_ALLOW_HTTP", raising=False)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_webhook_url("http://example.com/hook")
|
||||||
|
|
||||||
|
def test_private_ip_rejected(self, monkeypatch):
|
||||||
|
from backend.webhooks import validate_webhook_url
|
||||||
|
monkeypatch.delenv("OBSIGATE_WEBHOOK_ALLOW_PRIVATE", raising=False)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_webhook_url("https://127.0.0.1/hook")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_webhook_url("https://169.254.169.254/latest/meta-data")
|
||||||
|
|
||||||
|
def test_bad_scheme_rejected(self):
|
||||||
|
from backend.webhooks import validate_webhook_url
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_webhook_url("ftp://example.com")
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# BUG-031 — Per-account rate limiting
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestAccountRateLimit:
|
||||||
|
def test_account_limited_after_threshold(self, monkeypatch):
|
||||||
|
from backend import ratelimit
|
||||||
|
monkeypatch.setattr(ratelimit, "ACCOUNT_MAX_ATTEMPTS", 3)
|
||||||
|
account = "ratelimit-account@test"
|
||||||
|
assert not ratelimit.is_account_rate_limited(account)
|
||||||
|
for _ in range(3):
|
||||||
|
ratelimit.record_account_failure(account)
|
||||||
|
assert ratelimit.is_account_rate_limited(account)
|
||||||
|
ratelimit.record_account_success(account)
|
||||||
|
assert not ratelimit.is_account_rate_limited(account)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# BUG-030 — Client IP resolution
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestClientIp:
|
||||||
|
def _request(self, headers=None, client=("1.2.3.4", 1234)):
|
||||||
|
from starlette.requests import Request
|
||||||
|
raw_headers = [(k.lower().encode(), v.encode()) for k, v in (headers or {}).items()]
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/",
|
||||||
|
"query_string": b"",
|
||||||
|
"headers": raw_headers,
|
||||||
|
"client": client,
|
||||||
|
"scheme": "http",
|
||||||
|
"server": ("test", 80),
|
||||||
|
}
|
||||||
|
return Request(scope)
|
||||||
|
|
||||||
|
def test_socket_peer_used_by_default(self, monkeypatch):
|
||||||
|
from backend.services.net import get_client_ip
|
||||||
|
monkeypatch.delenv("OBSIGATE_TRUST_PROXY", raising=False)
|
||||||
|
assert get_client_ip(self._request()) == "1.2.3.4"
|
||||||
|
|
||||||
|
def test_forwarded_header_used_when_trusted(self, monkeypatch):
|
||||||
|
from backend.services.net import get_client_ip
|
||||||
|
monkeypatch.setenv("OBSIGATE_TRUST_PROXY", "true")
|
||||||
|
req = self._request({"X-Forwarded-For": "9.8.7.6, 10.0.0.1"})
|
||||||
|
assert get_client_ip(req) == "9.8.7.6"
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# BUG-032 — Indexing must not follow symlinks outside the vault
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestSymlinkIndexing:
|
||||||
|
def test_external_symlink_is_skipped(self, tmp_path):
|
||||||
|
import os
|
||||||
|
|
||||||
|
from backend.indexer import _scan_vault
|
||||||
|
|
||||||
|
vault = tmp_path / "vault"
|
||||||
|
vault.mkdir()
|
||||||
|
(vault / "inside.md").write_text("# Inside\n", encoding="utf-8")
|
||||||
|
|
||||||
|
outside = tmp_path / "outside"
|
||||||
|
outside.mkdir()
|
||||||
|
(outside / "secret.md").write_text("# Secret\n", encoding="utf-8")
|
||||||
|
|
||||||
|
link = vault / "link.md"
|
||||||
|
try:
|
||||||
|
os.symlink(outside / "secret.md", link)
|
||||||
|
except (OSError, NotImplementedError):
|
||||||
|
pytest.skip("Symlinks are not supported in this environment")
|
||||||
|
|
||||||
|
result = _scan_vault("vault", str(vault))
|
||||||
|
paths = {f["path"] for f in result["files"]}
|
||||||
|
assert "inside.md" in paths
|
||||||
|
assert "link.md" not in paths
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# BUG-021/022 — End-to-end rendering & share page escaping
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestRenderingAndShare:
|
||||||
|
def test_markdown_rendering_is_sanitized(self, client, test_vault_dir):
|
||||||
|
payload = (
|
||||||
|
"---\ntitle: Safe\n---\n"
|
||||||
|
"# Hello\n\n"
|
||||||
|
"<img src=x onerror=alert(1)>\n\n"
|
||||||
|
"<script>alert(2)</script>\n\n"
|
||||||
|
"Normal **text**.\n"
|
||||||
|
)
|
||||||
|
(Path(test_vault_dir) / "xss.md").write_text(payload, encoding="utf-8")
|
||||||
|
resp = client.get("/api/file/TestVault", params={"path": "xss.md"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = resp.json()["html"]
|
||||||
|
assert "onerror" not in html
|
||||||
|
assert "<script" not in html
|
||||||
|
assert "<strong>text</strong>" in html
|
||||||
|
|
||||||
|
def test_share_page_escapes_title_and_frontmatter(self, client, test_vault_dir):
|
||||||
|
payload = (
|
||||||
|
'---\ntitle: "<script>alert(1)</script>"\n'
|
||||||
|
'author: "<img src=x onerror=alert(9)>"\n---\n'
|
||||||
|
"# Body\ncontent\n"
|
||||||
|
)
|
||||||
|
(Path(test_vault_dir) / "share_xss.md").write_text(payload, encoding="utf-8")
|
||||||
|
create = client.post("/api/share/TestVault", json={"path": "share_xss.md"})
|
||||||
|
assert create.status_code == 200
|
||||||
|
token = create.json()["token"]
|
||||||
|
|
||||||
|
page = client.get(f"/s/{token}")
|
||||||
|
assert page.status_code == 200
|
||||||
|
body = page.text
|
||||||
|
assert "<script>alert(1)</script>" not in body
|
||||||
|
assert "<script>alert(1)</script>" in body
|
||||||
|
# The img tag must be escaped, so no live attribute is emitted.
|
||||||
|
assert "<img src=x onerror" not in body
|
||||||
|
assert "<img" in body
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# BUG-027/028 — Token invalidation on password change
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class TestTokenInvalidation:
|
||||||
|
def test_access_token_rejected_after_password_change(self, admin_client):
|
||||||
|
login = admin_client.post(
|
||||||
|
"/api/auth/login", json={"username": "admin", "password": "chab30"}
|
||||||
|
)
|
||||||
|
assert login.status_code == 200
|
||||||
|
token = login.json()["access_token"]
|
||||||
|
|
||||||
|
# Simulate a password change happening in the future relative to the token.
|
||||||
|
from backend.auth.user_store import update_user
|
||||||
|
update_user("admin", {"password_changed_at": time.time() + 10})
|
||||||
|
|
||||||
|
me = admin_client.get(
|
||||||
|
"/api/auth/me", headers={"Authorization": f"Bearer {token}"}
|
||||||
|
)
|
||||||
|
assert me.status_code == 401
|
||||||
Reference in New Issue
Block a user