Coverage for src/lexigram/web/middleware/sanitization.py: 38%
37 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""Input sanitization middleware.
3Strips dangerous characters and patterns from query parameters and path
4parameters before they reach application code. It does **not** inspect
5request bodies — callers should validate/sanitize body payloads at the
6service layer.
8Usage::
10 from lexigram.web.middleware.sanitization import InputSanitizationMiddleware
12 app.add_middleware(InputSanitizationMiddleware)
14Or via the WebProvider middleware stack by adding it to ``extra_middleware``.
15"""
17from __future__ import annotations
19import re
20from typing import Any
21import urllib.parse
23from lexigram.logging import get_logger
25logger = get_logger(__name__)
27# Patterns that are typically dangerous in query/path parameters.
28_NULL_BYTES_RE = re.compile(r"\x00")
30# Very basic XSS / script-injection check: tags and javascript: pseudo-URIs.
31_SCRIPT_TAG_RE = re.compile(r"<\s*script", re.IGNORECASE)
32_JS_URI_RE = re.compile(r"javascript\s*:", re.IGNORECASE)
35def _sanitize_value(value: str) -> str:
36 """Remove null bytes and flag obvious script-injection patterns.
38 Args:
39 value: Raw string value from a query or path parameter.
41 Returns:
42 Sanitized string with null bytes stripped. Returns an empty
43 string when an obvious injection pattern is detected and the
44 request is not forwarded with that parameter intact.
45 """
46 # Strip null bytes unconditionally — they are never legitimate in URLs.
47 value = _NULL_BYTES_RE.sub("", value)
49 # Drop the entire value for obvious script injection.
50 if _SCRIPT_TAG_RE.search(value) or _JS_URI_RE.search(value):
51 logger.warning(
52 "security.input_sanitization.blocked",
53 reason="script_injection",
54 value_prefix=value[:50],
55 )
56 return ""
58 return value
61class InputSanitizationMiddleware:
62 """ASGI middleware that sanitizes query string parameters.
64 Strips null bytes and rejects obvious script-injection patterns from
65 query parameters before they reach request handlers. Path parameters
66 are not modified because they have been parsed and validated by the
67 router before they hit middleware.
69 This is a **defense-in-depth** measure, not a substitute for
70 validation at the service layer.
72 Args:
73 app: The ASGI application to wrap.
74 sanitize_query_params: Whether to sanitize query string values
75 (default: ``True``).
76 """
78 def __init__(
79 self,
80 app: Any,
81 sanitize_query_params: bool = True,
82 ) -> None:
83 self.app = app
84 self.sanitize_query_params = sanitize_query_params
86 async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
87 if scope["type"] != "http" or not self.sanitize_query_params:
88 await self.app(scope, receive, send)
89 return
91 raw_query: bytes = scope.get("query_string", b"")
92 if raw_query:
93 sanitized_scope = dict(scope)
94 sanitized_scope["query_string"] = self._sanitize_query(raw_query)
95 await self.app(sanitized_scope, receive, send)
96 else:
97 await self.app(scope, receive, send)
99 def _sanitize_query(self, raw: bytes) -> bytes:
100 """Sanitize the raw query string bytes.
102 Args:
103 raw: Raw ``QUERY_STRING`` bytes from the ASGI scope.
105 Returns:
106 Sanitized query string bytes with dangerous values replaced.
107 """
108 try:
109 decoded = raw.decode("utf-8", errors="replace")
110 except (UnicodeDecodeError, AttributeError):
111 return raw
113 params = urllib.parse.parse_qsl(decoded, keep_blank_values=True)
114 sanitized = [(k, _sanitize_value(v)) for k, v in params]
115 return urllib.parse.urlencode(sanitized).encode("utf-8")