fix(security): consolidation & securite phase 1 (#84, BUG-021 a BUG-034)
CI / lint (push) Successful in 1m20s
CI / security (push) Successful in 47s
CI / test (push) Successful in 2m21s
CI / build (push) Successful in 43s
CI / e2e (push) Successful in 10m48s

- 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:
2026-09-13 10:51:42 -04:00
parent d47fcbf2be
commit 162a5b4acc
29 changed files with 1690 additions and 281 deletions
+8 -3
View File
@@ -62,16 +62,21 @@ def create_access_token(user: dict) -> str:
return jwt.encode(payload, get_secret_key(), algorithm=ALGORITHM)
def create_refresh_token(username: str) -> tuple:
"""Create a JWT refresh token. Returns (token_string, jti)."""
def create_refresh_token(username: str, remember: bool = False) -> tuple:
"""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())
jti = str(uuid.uuid4())
payload = {
"sub": username,
"jti": jti,
"iat": now,
"exp": now + REFRESH_TOKEN_EXPIRE_SECONDS,
"exp": now + (2592000 if remember else REFRESH_TOKEN_EXPIRE_SECONDS),
"type": "refresh",
"remember": remember,
}
return jwt.encode(payload, get_secret_key(), algorithm=ALGORITHM), jti
+21 -1
View File
@@ -8,7 +8,9 @@ import os
from fastapi import Depends, HTTPException, Request
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
logger = logging.getLogger("obsigate.auth.middleware")
@@ -42,6 +44,7 @@ def get_current_user(
"vaults": ["*"],
"active": True,
"_token_vaults": ["*"],
"_request_ip": get_client_ip(request),
}
token = None
@@ -57,14 +60,31 @@ def get_current_user(
if not payload or payload.get("type") != "access":
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"])
if not user or not user.get("active"):
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)
user["_token_vaults"] = payload.get("vaults", [])
# Attach the token id for per-token rate limiting (AI tool layer).
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
+26
View File
@@ -13,6 +13,32 @@ ph = PasswordHasher(
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:
"""Hash a password with Argon2id."""
+116 -16
View File
@@ -8,9 +8,12 @@ import re
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
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_success as rl_record_success
from backend.services.net import get_client_ip
from .jwt_handler import (
ACCESS_TOKEN_EXPIRE_SECONDS,
@@ -29,7 +32,7 @@ from .mfa import (
verify_totp,
)
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 (
create_user,
delete_user,
@@ -61,9 +64,7 @@ class ChangePasswordRequest(BaseModel):
@validator("new_password")
def password_strength(cls, v):
if len(v) < 8:
raise ValueError("Minimum 8 caractères")
return v
return validate_password_strength(v)
class CreateUserRequest(BaseModel):
@@ -73,6 +74,10 @@ class CreateUserRequest(BaseModel):
role: str = "user"
vaults: list[str] = []
@validator("password")
def password_valid(cls, v):
return validate_password_strength(v)
@validator("username")
def username_valid(cls, v):
if not re.match(r"^[a-zA-Z0-9_-]{2,32}$", v):
@@ -93,6 +98,12 @@ class UpdateUserRequest(BaseModel):
password: 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 ──────────────────────────────────────────────────
@@ -128,16 +139,21 @@ async def login(body: LoginRequest, response: Response, request: Request):
raise HTTPException(403, "Compte désactivé")
# 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):
raise HTTPException(429, "Trop de tentatives depuis cette adresse IP (15min)")
# BUG-031: per-account budget still applies when the attacker rotates IPs.
if is_account_rate_limited(body.username):
raise HTTPException(429, "Trop de tentatives sur ce compte (15min)")
if is_locked(body.username):
raise HTTPException(429, "Compte temporairement verrouillé (15min)")
if not verify_password(body.password, user["password_hash"]):
attempts = record_login_failure(body.username)
rl_attempts, rl_remaining = rl_record_failure(client_ip)
rl_record_account_failure(body.username)
remaining = max(0, 5 - attempts)
detail = "Identifiants invalides"
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:
"""Issue JWT tokens after successful authentication (password or MFA verified)."""
record_login_success(username)
rl_record_account_success(username)
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
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.
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")
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"):
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)
# Update cookies
import os
secure = os.environ.get("OBSIGATE_SECURE_COOKIES", "false").lower() == "true"
response.set_cookie(
key="access_token",
value=new_access_token,
@@ -251,7 +297,7 @@ async def logout(
request: Request,
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")
if refresh_tok:
payload = decode_token(refresh_tok)
@@ -261,6 +307,21 @@ async def logout(
except Exception:
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("access_token", path="/")
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")
async def change_password(
req: ChangePasswordRequest,
response: Response,
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"])
assert user is not None, f"User {current_user['username']} not found"
if not verify_password(req.current_password, user["password_hash"]):
raise HTTPException(400, "Mot de passe actuel incorrect")
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 ────────────────────────────────────────────────────
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):
username: str
code: str
@@ -601,6 +694,8 @@ async def mfa_webauthn_verify(
from .user_store import get_user, update_user
from .webauthn_mfa import complete_authentication
client_ip = _enforce_mfa_rate_limit(request, body.username)
user = get_user(body.username)
if not user:
hash_password("dummy_timing_protection")
@@ -616,8 +711,10 @@ async def mfa_webauthn_verify(
raise ValueError("Credential non enregistré")
new_count = complete_authentication(body.username, body.credential, stored)
except ValueError as e:
_record_mfa_failure(client_ip, body.username)
raise HTTPException(401, str(e))
except Exception as e:
_record_mfa_failure(client_ip, body.username)
logger.warning(f"WebAuthn verification failed for {body.username}: {e}")
raise HTTPException(401, "Vérification WebAuthn échouée")
@@ -627,7 +724,6 @@ async def mfa_webauthn_verify(
c["sign_count"] = new_count
update_user(body.username, {"webauthn_credentials": updated})
client_ip = request.client.host if request.client else "unknown"
rl_record_success(client_ip)
logger.info(f"User '{body.username}' logged in via WebAuthn")
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
client_ip = _enforce_mfa_rate_limit(request, body.username)
user = get_user(body.username)
if not user:
# 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")
if not verify_totp(user["mfa_secret"], body.code):
_record_mfa_failure(client_ip, body.username)
raise HTTPException(401, "Code TOTP invalide")
# Clear IP rate limit on success
client_ip = request.client.host if request.client else "unknown"
rl_record_success(client_ip)
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
client_ip = _enforce_mfa_rate_limit(request, body.username)
user = get_user(body.username)
if not user:
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)
if idx is None:
_record_mfa_failure(client_ip, body.username)
raise HTTPException(401, "Code de récupération invalide")
# 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})
# Clear IP rate limit
client_ip = request.client.host if request.client else "unknown"
rl_record_success(client_ip)
logger.info(f"User '{body.username}' logged in via recovery code")
+64 -49
View File
@@ -6,6 +6,7 @@
import json
import logging
import shutil
import threading
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -16,6 +17,12 @@ logger = logging.getLogger("obsigate.auth.users")
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:
"""Read users.json. Returns empty structure if file doesn't exist."""
@@ -75,26 +82,28 @@ def create_user(
display_name: str | None = None,
) -> dict:
"""Create a new user. Raises ValueError if username already taken."""
data = _read()
if username in data["users"]:
raise ValueError(f"User '{username}' already exists")
with _users_lock:
data = _read()
if username in data["users"]:
raise ValueError(f"User '{username}' already exists")
user = {
"id": str(uuid.uuid4()),
"username": username,
"display_name": display_name or username,
"password_hash": hash_password(password),
"role": role,
"vaults": vaults or [],
"active": True,
"language": "fr", # default UI language
"created_at": datetime.now(timezone.utc).isoformat(),
"last_login": None,
"failed_attempts": 0,
"locked_until": None,
}
data["users"][username] = user
_write(data)
user = {
"id": str(uuid.uuid4()),
"username": username,
"display_name": display_name or username,
"password_hash": hash_password(password),
"role": role,
"vaults": vaults or [],
"active": True,
"language": "fr", # default UI language
"created_at": datetime.now(timezone.utc).isoformat(),
"password_changed_at": datetime.now(timezone.utc).timestamp(),
"last_login": None,
"failed_attempts": 0,
"locked_until": None,
}
data["users"][username] = user
_write(data)
logger.info(f"Created user '{username}' (role={role})")
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.
If 'password' is in updates, it's hashed and stored as password_hash.
"""
data = _read()
if username not in data["users"]:
raise ValueError(f"User '{username}' not found")
with _users_lock:
data = _read()
if username not in data["users"]:
raise ValueError(f"User '{username}' not found")
forbidden = {"id", "username", "created_at"}
safe_updates = {k: v for k, v in updates.items() if k not in forbidden}
forbidden = {"id", "username", "created_at"}
safe_updates = {k: v for k, v in updates.items() if k not in forbidden}
if "password" in safe_updates:
safe_updates["password_hash"] = hash_password(safe_updates.pop("password"))
if "password" in safe_updates:
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)
_write(data)
return {k: v for k, v in data["users"][username].items() if k != "password_hash"}
data["users"][username].update(safe_updates)
_write(data)
result = {k: v for k, v in data["users"][username].items() if k != "password_hash"}
return result
def delete_user(username: str):
"""Delete a user. Raises ValueError if not found."""
data = _read()
if username not in data["users"]:
raise ValueError(f"User '{username}' not found")
del data["users"][username]
_write(data)
with _users_lock:
data = _read()
if username not in data["users"]:
raise ValueError(f"User '{username}' not found")
del data["users"][username]
_write(data)
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.
"""
data = _read()
user = data["users"].get(username)
if not user:
return 0
with _users_lock:
data = _read()
user = data["users"].get(username)
if not user:
return 0
attempts = user.get("failed_attempts", 0) + 1
updates = {"failed_attempts": attempts}
attempts = user.get("failed_attempts", 0) + 1
updates = {"failed_attempts": attempts}
# Lock after 5 failed attempts (15 minutes)
if attempts >= 5:
locked_until = (
datetime.now(timezone.utc) + timedelta(minutes=15)
).isoformat()
updates["locked_until"] = locked_until
logger.warning(f"Account '{username}' locked after {attempts} failed attempts")
# Lock after 5 failed attempts (15 minutes)
if attempts >= 5:
locked_until = (
datetime.now(timezone.utc) + timedelta(minutes=15)
).isoformat()
updates["locked_until"] = locked_until
logger.warning(f"Account '{username}' locked after {attempts} failed attempts")
update_user(username, updates)
return attempts
update_user(username, updates)
return attempts
def is_locked(username: str) -> bool:
+95 -76
View File
@@ -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}")
return {"files": [], "tags": {}, "path": vault_path, "paths": []}
for fpath in vault_root.rglob("*"):
# Skip ignored directories
if any(part in IGNORED_DIRS for part in fpath.relative_to(vault_root).parts):
continue
root_resolved = vault_root.resolve(strict=False)
rel_path_str = str(fpath.relative_to(vault_root)).replace("\\", "/")
# Add all paths (files and directories) to path index
if fpath.is_dir():
# BUG-032: walk without following symlinks and refuse any symlink that
# escapes the vault root, so external data can never be indexed/exposed.
for dirpath, dirnames, filenames in os.walk(vault_root, followlinks=False):
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({
"path": rel_path_str,
"name": fpath.name,
"name": d,
"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)
tags: list[str] = []
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()
for fname in filenames:
fpath = current_dir / fname
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()
if fpath.is_symlink():
try:
target = fpath.resolve(strict=True)
except OSError:
continue
try:
target.relative_to(root_resolved)
except ValueError:
logger.warning(f"Skipping symlink outside vault: {fpath}")
continue
_extract_wikilinks_for_backlinks(
vault_name, str(relative).replace("\\", "/"),
title, post.content
)
rel_path_str = str(fpath.relative_to(vault_root)).replace("\\", "/")
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,
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": fname,
"type": "file"
})
for tag in tags:
tag_counts[tag] = tag_counts.get(tag, 0) + 1
try:
relative = fpath.relative_to(vault_root)
stat = fpath.stat()
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
except PermissionError:
logger.debug(f"Permission denied, skipping {fpath}")
continue
except Exception as e:
logger.error(f"Error indexing {fpath}: {e}")
continue
# PDF handling — special path (binary, uses pdf_reader)
tags: list[str] = []
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":
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")
return {"files": files, "tags": tag_counts, "path": vault_path, "paths": paths, "config": {}}
+40 -14
View File
@@ -136,6 +136,7 @@ from backend.services.mutations import (
restore_backup as service_restore_backup,
)
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 list_tags as service_list_tags
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; "
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
"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:
# ``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
rendered = _add_heading_ids(rendered)
# Sanitize: raw HTML in vault content must never reach the DOM (BUG-021).
rendered = sanitize_html(rendered)
return rendered
@@ -2657,14 +2665,15 @@ async def api_advanced_search(
Results include ``<mark>``-highlighted snippets and faceted tag/vault counts.
"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
_search_executor,
partial(advanced_search_vaults, q, vault=vault, tag=tag,
search_fn = partial(advanced_search_vaults, q, vault=vault, tag=tag,
limit=limit, offset=offset, sort=sort,
case_sensitive=case_sensitive, whole_word=whole_word, regex=regex,
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)
@@ -4012,9 +4021,23 @@ async def public_share_view(token: str):
title = post.metadata.get("title", file_path.stem)
# JSON-escape raw content for embedding in HTML
import json as _json
raw_json = _json.dumps(raw)
# Escape everything user-controlled before embedding in HTML/JS (BUG-022).
title_esc = html_mod.escape(str(title))
# 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 = ""
if post.metadata:
fm_items = []
@@ -4028,12 +4051,15 @@ async def public_share_view(token: str):
v = "✓" if v else "✗"
elif v is None:
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:
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">
<title>{title} — ObsiGate Share</title>
<title>{title_esc} — ObsiGate Share</title>
<style>
: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; }}
@@ -4075,7 +4101,7 @@ body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:
Document partagé via ObsiGate
</div>
<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">
<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>
@@ -4094,7 +4120,7 @@ body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:
<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(){{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>""")
+64 -15
View File
@@ -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
cleanup of expired entries. Complements the per-account lockout
in user_store.py.
Tracks failed attempts per IP **and** per account with automatic cleanup of
expired entries. The per-IP budget stops a single source; the per-account
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:
OBSIGATE_LOGIN_MAX_ATTEMPTS Max failures per IP (default: 10)
OBSIGATE_LOGIN_WINDOW_SECONDS Lockout window in seconds (default: 900 = 15min)
OBSIGATE_LOGIN_MAX_ATTEMPTS Max failures per IP (default: 10)
OBSIGATE_ACCOUNT_MAX_ATTEMPTS Max failures per account (default: 10)
OBSIGATE_LOGIN_WINDOW_SECONDS Lockout window in seconds (default: 900)
"""
import logging
@@ -19,29 +27,37 @@ logger = logging.getLogger("obsigate.ratelimit")
# --- Configuration ---
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
# --- In-memory store: {ip: [(timestamp, success_bool), ...]} ---
# --- In-memory stores: {key: [(timestamp, success_bool), ...]} ---
_ip_attempts: dict[str, list] = defaultdict(list)
_account_attempts: dict[str, list] = defaultdict(list)
_last_cleanup = time.time()
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():
"""Remove entries older than the window."""
"""Remove entries older than the window from both stores."""
global _last_cleanup
now = time.time()
if now - _last_cleanup < CLEANUP_INTERVAL:
return
_last_cleanup = now
cutoff = now - WINDOW_SECONDS
expired_ips = []
for ip, attempts in _ip_attempts.items():
_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]
_prune(_ip_attempts, cutoff)
_prune(_account_attempts, cutoff)
def record_failure(ip: str) -> tuple[int, int]:
@@ -72,6 +88,37 @@ def is_rate_limited(ip: str) -> bool:
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:
"""Get rate limit status for an IP (for diagnostics)."""
_cleanup_expired()
@@ -87,7 +134,9 @@ def get_status(ip: str | None = None) -> dict:
}
return {
"tracked_ips": len(_ip_attempts),
"tracked_accounts": len(_account_attempts),
"max_attempts": MAX_ATTEMPTS,
"account_max_attempts": ACCOUNT_MAX_ATTEMPTS,
"window_seconds": WINDOW_SECONDS,
"limited_ips": sum(
1 for ip_addr in _ip_attempts
+103 -50
View File
@@ -13,6 +13,11 @@ from sortedcontainers import SortedList
from backend import indexer as _indexer
from backend import semantic_search as _semantic
from backend.indexer import index
from backend.services.regex_safety import (
MAX_REGEX_MATCHES,
truncate_for_regex,
validate_regex,
)
logger = logging.getLogger("obsigate.search")
@@ -226,12 +231,16 @@ def _extract_regex_snippet(
if not content or not pattern_text:
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:
validate_regex(pattern_text)
pattern = re.compile(pattern_text, re.IGNORECASE)
except re.error:
except (re.error, ValueError):
return _escape_html(content[:200].strip())
matches = list(pattern.finditer(content))
matches = list(pattern.finditer(content))[:MAX_REGEX_MATCHES]
if not matches:
return _escape_html(content[:200].strip())
@@ -729,60 +738,97 @@ def search(
query_lower = query.lower()
results: list[dict[str, Any]] = []
for vault_name, vault_data in index.items():
if vault_filter != "all" and vault_name != vault_filter:
inv = get_inverted_index()
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
for file_info in vault_data["files"]:
# 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
score = 0
snippet = file_info.get("content_preview", "")
score = 0
snippet = file_info.get("content_preview", "")
if has_query:
title_lower = file_info["title"].lower()
if has_query:
title_lower = file_info["title"].lower()
# Exact title match (highest weight)
if query_lower == title_lower:
score += 20
# Partial title match
elif query_lower in title_lower:
score += 10
# Exact title match (highest weight)
if query_lower == title_lower:
score += 20
# Partial title match
elif query_lower in title_lower:
score += 10
# Path match (folder/filename relevance)
if query_lower in file_info["path"].lower():
score += 5
# Path match (folder/filename relevance)
if query_lower in file_info["path"].lower():
score += 5
# Tag name match
for tag in file_info.get("tags", []):
if query_lower in tag.lower():
score += 3
break # count once per file
# Tag name match
for tag in file_info.get("tags", []):
if query_lower in tag.lower():
score += 3
break # count once per file
# Content match — use cached content (no disk I/O)
content = file_info.get("content", "")
content_lower = content.lower()
if query_lower in content_lower:
# 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)
content = file_info.get("content", "")
content_lower = content.lower()
if query_lower in content_lower:
# 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
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"],
})
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"])
return results[:limit]
@@ -908,12 +954,14 @@ def _passes_search_filters(
title = file_info.get("title", "")
content = file_info.get("content", "")
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)
# --- Regex mode ---
if regex and raw_query:
try:
validate_regex(raw_query)
flags = 0 if case_sensitive else re.IGNORECASE
if whole_word:
pattern = re.compile(rf"\b{raw_query}\b", flags)
@@ -921,7 +969,7 @@ def _passes_search_filters(
pattern = re.compile(raw_query, flags)
if not pattern.search(search_text):
return False
except re.error:
except (re.error, ValueError):
return False
return _passes_path_filters(path, include_paths, exclude_paths)
@@ -1138,6 +1186,11 @@ def advanced_search(
"""
t0 = time.monotonic()
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)
# Merge explicit tag_filter with parsed tag: operators
+23 -12
View File
@@ -433,22 +433,33 @@ def replace_in_files(
"""
import re as re_mod
from backend.services.regex_safety import MAX_REGEX_MATCHES, validate_regex
from backend.services.search import advanced_search_vaults
if not find:
raise ServiceError("Query is required", code="invalid_arguments", status=400)
search_results = advanced_search_vaults(
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",
)
# BUG-025: validate the pattern before it is compiled / applied in bulk.
if regex:
try:
validate_regex(find)
except ValueError as e:
raise ServiceError(str(e), code="invalid_arguments", status=400) from e
try:
search_results = advanced_search_vaults(
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"]:
return {"matches": [], "total_matches": 0, "dry_run": dry_run}
@@ -480,7 +491,7 @@ def replace_in_files(
except OSError:
continue
occurrences = list(pattern.finditer(original))
occurrences = list(pattern.finditer(original))[:MAX_REGEX_MATCHES]
if not occurrences:
continue
+34
View File
@@ -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
View File
@@ -15,6 +15,27 @@ from backend.services.errors import ServiceError
logger = logging.getLogger("obsigate.services.paths")
def _is_within(resolved: Path, root: Path) -> bool:
"""Return True when *resolved* is *root* or lives below it.
The comparison is segment-aware so that a sibling directory whose name
merely shares a prefix (``vault`` vs ``vault-evil``) is rejected. A
case-insensitive fallback preserves the Windows / Docker behaviour where
the resolved casing can differ from the configured root.
"""
try:
resolved.relative_to(root)
return True
except ValueError:
pass
try:
resolved_parts = tuple(part.lower() for part in resolved.parts)
root_parts = tuple(part.lower() for part in root.parts)
except Exception:
return False
return resolved_parts[: len(root_parts)] == root_parts
def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
"""Resolve a vault-relative path, rejecting traversal outside the vault.
@@ -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}")
raise ServiceError(f"Path resolution error: {e!s}", code="path_error", status=500) from e
try:
resolved.relative_to(root)
except ValueError:
# Case-insensitive fallback for Windows / Docker path casing.
if not str(resolved).lower().startswith(str(root).lower()):
logger.warning(f"Path outside vault - vault: {root}, requested: {relative_path}, resolved: {resolved}")
raise ServiceError(
"Access denied: path outside vault",
code="path_outside_vault",
status=403,
details={"path": relative_path},
) from None
if not _is_within(resolved, root):
logger.warning(f"Path outside vault - vault: {root}, requested: {relative_path}, resolved: {resolved}")
raise ServiceError(
"Access denied: path outside vault",
code="path_outside_vault",
status=403,
details={"path": relative_path},
)
return resolved
+67
View File
@@ -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
+286
View File
@@ -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
View File
@@ -4,6 +4,16 @@ Webhook management and dispatch for ObsiGate.
Webhooks are HTTP POST callbacks triggered on file/directory events.
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,
directory_created, directory_deleted, directory_renamed
"""
@@ -11,17 +21,22 @@ Events: file_created, file_deleted, file_modified, file_renamed,
import asyncio
import hashlib
import hmac
import ipaddress
import json
import logging
import os
import socket
import uuid
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
import aiohttp
logger = logging.getLogger("obsigate.webhooks")
WEBHOOKS_FILE = Path("data/webhooks.json")
WEBHOOK_SECRETS_FILE = Path("data/webhook_secrets.json")
VALID_EVENTS = {
"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:
if not WEBHOOKS_FILE.exists():
return []
@@ -45,35 +135,94 @@ def _write(webhooks: list):
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:
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:
validate_webhook_url(url)
webhooks = _read()
wh_id = str(uuid.uuid4())
wh = {
"id": str(uuid.uuid4()),
"id": wh_id,
"name": name,
"url": url,
"events": [e for e in events if e in VALID_EVENTS],
"secret": secret,
"enabled": True,
"created_at": datetime.now(timezone.utc).isoformat(),
"last_fired_at": None,
}
webhooks.append(wh)
_write(webhooks)
if secret:
_store_secret(wh_id, secret)
logger.info(f"Created webhook '{name}' → {url}")
return wh
return _public_view(wh)
def update_webhook(wh_id: str, updates: dict) -> dict | None:
webhooks = _read()
for wh in webhooks:
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)
return wh
return _public_view(wh)
return None
@@ -83,6 +232,9 @@ def delete_webhook(wh_id: str) -> bool:
if len(new_list) == len(webhooks):
return False
_write(new_list)
secrets = _read_secrets()
if secrets.pop(wh_id, None) is not None:
_write_secrets(secrets)
return True
@@ -102,17 +254,24 @@ async def dispatch_webhooks(event_type: str, data: dict):
async def _post(wh):
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}
if wh.get("secret"):
sig = hmac.new(wh["secret"].encode(), body.encode(), hashlib.sha256).hexdigest()
secret = _get_secret(wh)
if secret:
sig = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
headers["X-ObsiGate-Signature"] = f"sha256={sig}"
timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout) as session, session.post(wh["url"], data=body, headers=headers) as resp:
if resp.status < 400:
logger.debug(f"Webhook '{wh['name']}' OK ({resp.status})")
else:
logger.warning(f"Webhook '{wh['name']}' failed ({resp.status})")
async with aiohttp.ClientSession(timeout=timeout) as session, session.post(
wh["url"], data=body, headers=headers, allow_redirects=False
) as resp:
if resp.status < 400:
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()})
except Exception as e:
logger.warning(f"Webhook '{wh['name']}' error: {e}")