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:
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user