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
+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()