mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-07-31 07:49:23 +00:00
_check_address now canonicalizes IPv4-mapped IPv6 addresses (e.g. ::ffff:169.254.169.254) to their embedded IPv4 target before the metadata and non-unicast checks. Previously the mapped form was a distinct address object absent from _METADATA_ADDRESSES with is_reserved=False/is_global=False, so under allow_private=True it slipped past every check and the socket layer still dialed the mapped IPv4 metadata endpoint. test_allow_private_does_not_ allow_metadata is parametrized over the plain and mapped spellings. Also cast SafeAiohttpResolver.resolve's ResolveResult host/port to str/int to satisfy aiohttp's TypedDict (fixes the two mypy errors in the strict-overrides CI gate). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
814 lines
29 KiB
Python
814 lines
29 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Security Utilities for MeshCore Bot
|
|
Provides centralized security validation functions to prevent common attacks
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import ipaddress
|
|
import logging
|
|
import os
|
|
import platform
|
|
import re
|
|
import socket
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any, Optional
|
|
from urllib.parse import urljoin, urlsplit
|
|
|
|
import aiohttp
|
|
import requests
|
|
from aiohttp.abc import AbstractResolver
|
|
from requests.adapters import HTTPAdapter
|
|
from urllib3 import PoolManager
|
|
from urllib3.connection import HTTPConnection, HTTPSConnection
|
|
from urllib3.connectionpool import HTTPConnectionPool, HTTPSConnectionPool
|
|
from urllib3.exceptions import NewConnectionError
|
|
from urllib3.util.connection import create_connection
|
|
|
|
if TYPE_CHECKING:
|
|
from aiohttp.abc import ResolveResult
|
|
|
|
logger = logging.getLogger('MeshCoreBot.Security')
|
|
|
|
# CGN (Carrier-Grade NAT) network 100.64.0.0/10 - RFC 6598
|
|
_CGN_NETWORK = ipaddress.ip_network("100.64.0.0/10")
|
|
|
|
# Well-known instance/task metadata addresses must never receive feed requests,
|
|
# even when an administrator explicitly enables ordinary private-network feeds.
|
|
_METADATA_ADDRESSES = {
|
|
ipaddress.ip_address("169.254.169.254"), # AWS, Azure, GCP, and others
|
|
ipaddress.ip_address("169.254.170.2"), # AWS ECS task credentials
|
|
ipaddress.ip_address("100.100.100.200"), # Alibaba Cloud
|
|
ipaddress.ip_address("fd00:ec2::254"), # AWS IPv6 metadata
|
|
}
|
|
_METADATA_HOSTNAMES = {
|
|
"metadata.google.internal",
|
|
"metadata.goog",
|
|
}
|
|
_REDIRECT_STATUSES = {301, 302, 303, 307, 308}
|
|
_SAFE_REDIRECT_HEADERS = {"accept", "accept-encoding", "user-agent"}
|
|
|
|
|
|
class UnsafeUrlError(ValueError):
|
|
"""Raised when a URL or any of its resolved destinations violates policy."""
|
|
|
|
|
|
class SafeUrlPolicy:
|
|
"""Reusable SSRF policy for parsing, DNS resolution, redirects, and connects."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
allow_private: bool = False,
|
|
allow_loopback: bool | None = None,
|
|
timeout: float = 2.0,
|
|
max_redirects: int = 5,
|
|
) -> None:
|
|
self.allow_private = allow_private
|
|
self.allow_loopback = allow_loopback
|
|
# Portable synchronous DNS has no per-call timeout. Async callers and
|
|
# the aiohttp connect-time resolver enforce this value with wait_for().
|
|
self.timeout = timeout
|
|
self.max_redirects = max_redirects
|
|
|
|
def parse(self, url: str) -> tuple[str, str, int]:
|
|
"""Strictly parse an HTTP(S) URL and return scheme, hostname, and port."""
|
|
if not isinstance(url, str) or not url:
|
|
raise UnsafeUrlError("URL must be a non-empty string")
|
|
if re.search(r"[\x00-\x20\x7f]", url):
|
|
raise UnsafeUrlError("URL contains control characters or whitespace")
|
|
# WHATWG-style parsers may treat backslashes as separators while
|
|
# urllib.parse does not. Reject them so every layer sees one authority.
|
|
if "\\" in url:
|
|
raise UnsafeUrlError("URL contains an ambiguous backslash")
|
|
|
|
try:
|
|
parsed = urlsplit(url)
|
|
port = parsed.port
|
|
except ValueError as exc:
|
|
raise UnsafeUrlError(f"Invalid URL authority: {exc}") from exc
|
|
|
|
scheme = parsed.scheme.lower()
|
|
if scheme not in {"http", "https"}:
|
|
raise UnsafeUrlError(f"URL scheme not allowed: {parsed.scheme}")
|
|
if not parsed.netloc or not parsed.hostname:
|
|
raise UnsafeUrlError("URL is missing a hostname")
|
|
if parsed.username is not None or parsed.password is not None:
|
|
raise UnsafeUrlError("Embedded URL credentials are not allowed")
|
|
|
|
raw_hostname = parsed.hostname.lower()
|
|
if raw_hostname.endswith(".."):
|
|
raise UnsafeUrlError("URL contains an ambiguous hostname")
|
|
hostname = raw_hostname.rstrip(".")
|
|
if not hostname or ".." in hostname or "%" in hostname:
|
|
raise UnsafeUrlError("URL contains an ambiguous hostname")
|
|
if hostname in _METADATA_HOSTNAMES:
|
|
raise UnsafeUrlError("Cloud metadata hostnames are not allowed")
|
|
|
|
# IPv6 literals must be bracketed. Numeric-looking IPv4 hosts must use
|
|
# canonical dotted decimal; this rejects octal, hexadecimal, and dword
|
|
# spellings accepted inconsistently by DNS/socket implementations.
|
|
if ":" in hostname and not parsed.netloc.startswith("["):
|
|
raise UnsafeUrlError("IPv6 URL literals must be bracketed")
|
|
numeric_ipv4 = bool(re.fullmatch(r"(?:0[xX][0-9a-fA-F]+|[0-9.]+)", hostname))
|
|
if numeric_ipv4:
|
|
try:
|
|
address = ipaddress.ip_address(hostname)
|
|
except ValueError as exc:
|
|
raise UnsafeUrlError("Non-canonical numeric IP address") from exc
|
|
if not isinstance(address, ipaddress.IPv4Address) or str(address) != hostname:
|
|
raise UnsafeUrlError("Non-canonical numeric IP address")
|
|
|
|
return scheme, hostname, port or (443 if scheme == "https" else 80)
|
|
|
|
def _check_address(self, address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> None:
|
|
# Canonicalize IPv4-mapped IPv6 (e.g. ``::ffff:169.254.169.254``) to the
|
|
# IPv4 target the socket layer will actually dial. The mapped form is a
|
|
# distinct address object that is absent from _METADATA_ADDRESSES and
|
|
# reports is_reserved=False/is_global=False, so without this it would
|
|
# bypass the metadata and non-unicast checks under allow_private=True.
|
|
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None:
|
|
address = address.ipv4_mapped
|
|
if address in _METADATA_ADDRESSES:
|
|
raise UnsafeUrlError(f"Cloud metadata address is not allowed: {address}")
|
|
# These cannot be meaningful unicast HTTP server destinations. They
|
|
# remain blocked even under the explicit private-network exception.
|
|
if address.is_unspecified or address.is_multicast or address.is_reserved:
|
|
raise UnsafeUrlError(f"Non-unicast destination is not allowed: {address}")
|
|
|
|
if self.allow_loopback is True:
|
|
if not address.is_loopback:
|
|
raise UnsafeUrlError(f"Non-loopback destination is not allowed: {address}")
|
|
return
|
|
if self.allow_private:
|
|
return
|
|
if not address.is_global or address in _CGN_NETWORK:
|
|
raise UnsafeUrlError(f"Private or non-global destination is not allowed: {address}")
|
|
|
|
def validate_records(self, records: list[tuple[Any, ...]]) -> list[tuple[Any, ...]]:
|
|
"""Validate every A/AAAA answer before any one of them can be used."""
|
|
if not records:
|
|
raise UnsafeUrlError("Hostname did not resolve to an address")
|
|
validated: list[tuple[Any, ...]] = []
|
|
seen: set[tuple[int, str]] = set()
|
|
for record in records:
|
|
family, _socktype, _proto, _canonname, sockaddr = record
|
|
if family not in {socket.AF_INET, socket.AF_INET6}:
|
|
continue
|
|
address = ipaddress.ip_address(sockaddr[0])
|
|
self._check_address(address)
|
|
key = (family, str(address))
|
|
if key not in seen:
|
|
seen.add(key)
|
|
validated.append(record)
|
|
if not validated:
|
|
raise UnsafeUrlError("Hostname did not resolve to an A or AAAA address")
|
|
return validated
|
|
|
|
def resolve(
|
|
self,
|
|
hostname: str,
|
|
port: int,
|
|
*,
|
|
family: socket.AddressFamily = socket.AF_UNSPEC,
|
|
) -> list[tuple[Any, ...]]:
|
|
"""Resolve and validate all socket addresses for a host."""
|
|
try:
|
|
records = socket.getaddrinfo(
|
|
hostname,
|
|
port,
|
|
family=family,
|
|
type=socket.SOCK_STREAM,
|
|
)
|
|
except (socket.gaierror, socket.timeout, OSError) as exc:
|
|
raise UnsafeUrlError(f"Failed to resolve hostname {hostname}: {exc}") from exc
|
|
return self.validate_records(records)
|
|
|
|
def validate(self, url: str) -> bool:
|
|
"""Parse and resolve a URL, raising UnsafeUrlError on policy failure."""
|
|
_scheme, hostname, port = self.parse(url)
|
|
self.resolve(hostname, port)
|
|
return True
|
|
|
|
async def validate_async(self, url: str) -> bool:
|
|
"""Asynchronously parse and resolve a URL without blocking the event loop."""
|
|
_scheme, hostname, port = self.parse(url)
|
|
loop = asyncio.get_running_loop()
|
|
try:
|
|
records = await asyncio.wait_for(
|
|
loop.getaddrinfo(
|
|
hostname,
|
|
port,
|
|
family=socket.AF_UNSPEC,
|
|
type=socket.SOCK_STREAM,
|
|
),
|
|
timeout=self.timeout,
|
|
)
|
|
except (asyncio.TimeoutError, socket.gaierror, socket.timeout, OSError) as exc:
|
|
raise UnsafeUrlError(f"Failed to resolve hostname {hostname}: {exc}") from exc
|
|
self.validate_records(records)
|
|
return True
|
|
|
|
|
|
class SafeAiohttpResolver(AbstractResolver):
|
|
"""aiohttp resolver that validates the exact addresses used to connect."""
|
|
|
|
def __init__(self, policy: SafeUrlPolicy) -> None:
|
|
self.policy = policy
|
|
|
|
async def resolve(
|
|
self,
|
|
host: str,
|
|
port: int = 0,
|
|
family: socket.AddressFamily = socket.AF_INET,
|
|
) -> list[ResolveResult]:
|
|
loop = asyncio.get_running_loop()
|
|
try:
|
|
records = await asyncio.wait_for(
|
|
loop.getaddrinfo(
|
|
host,
|
|
port,
|
|
family=family,
|
|
type=socket.SOCK_STREAM,
|
|
),
|
|
timeout=self.policy.timeout,
|
|
)
|
|
records = self.policy.validate_records(records)
|
|
except (
|
|
UnsafeUrlError,
|
|
asyncio.TimeoutError,
|
|
socket.gaierror,
|
|
socket.timeout,
|
|
OSError,
|
|
) as exc:
|
|
raise OSError(f"Failed to resolve safe destination {host}: {exc}") from exc
|
|
return [
|
|
{
|
|
"hostname": host,
|
|
"host": str(record[4][0]),
|
|
"port": int(record[4][1]),
|
|
"family": record[0],
|
|
"proto": record[2],
|
|
"flags": socket.AI_NUMERICHOST,
|
|
}
|
|
for record in records
|
|
]
|
|
|
|
async def close(self) -> None:
|
|
return None
|
|
|
|
|
|
def _redirect_target(current_url: str, location: str, policy: SafeUrlPolicy) -> str:
|
|
target = urljoin(current_url, location)
|
|
policy.validate(target)
|
|
return target
|
|
|
|
|
|
def _same_origin(left: str, right: str, policy: SafeUrlPolicy) -> bool:
|
|
return policy.parse(left) == policy.parse(right)
|
|
|
|
|
|
def _headers_for_redirect(
|
|
headers: dict[str, str] | None,
|
|
*,
|
|
same_origin: bool,
|
|
) -> dict[str, str] | None:
|
|
if headers is None or same_origin:
|
|
return headers
|
|
# Arbitrary API headers often contain tokens under provider-specific names.
|
|
# On a cross-origin redirect retain only headers known not to be credentials.
|
|
return {key: value for key, value in headers.items() if key.lower() in _SAFE_REDIRECT_HEADERS}
|
|
|
|
|
|
async def safe_aiohttp_request(
|
|
session: aiohttp.ClientSession,
|
|
method: str,
|
|
url: str,
|
|
*,
|
|
policy: SafeUrlPolicy,
|
|
headers: dict[str, str] | None = None,
|
|
**kwargs: Any,
|
|
) -> aiohttp.ClientResponse:
|
|
"""Issue a request with bounded, policy-checked manual redirects."""
|
|
current_url = url
|
|
current_method = method.upper()
|
|
current_headers = headers
|
|
for redirect_count in range(policy.max_redirects + 1):
|
|
await policy.validate_async(current_url)
|
|
response = await session.request(
|
|
current_method,
|
|
current_url,
|
|
headers=current_headers,
|
|
allow_redirects=False,
|
|
**kwargs,
|
|
)
|
|
if response.status not in _REDIRECT_STATUSES or not response.headers.get("Location"):
|
|
return response
|
|
if redirect_count >= policy.max_redirects:
|
|
response.release()
|
|
raise UnsafeUrlError("Too many redirects")
|
|
|
|
try:
|
|
target = urljoin(current_url, response.headers["Location"])
|
|
await policy.validate_async(target)
|
|
same_origin = _same_origin(current_url, target, policy)
|
|
current_headers = _headers_for_redirect(current_headers, same_origin=same_origin)
|
|
if not same_origin and (
|
|
current_method not in {"GET", "HEAD"}
|
|
or kwargs.get("json") is not None
|
|
or kwargs.get("data") is not None
|
|
):
|
|
raise UnsafeUrlError("Cross-origin redirects cannot forward request bodies")
|
|
# Query parameters belong only to the originally configured endpoint.
|
|
# A redirect's Location carries its own query string.
|
|
kwargs.pop("params", None)
|
|
if response.status == 303 or (
|
|
response.status in {301, 302} and current_method == "POST"
|
|
):
|
|
current_method = "GET"
|
|
kwargs.pop("json", None)
|
|
kwargs.pop("data", None)
|
|
finally:
|
|
response.release()
|
|
current_url = target
|
|
raise UnsafeUrlError("Too many redirects")
|
|
|
|
|
|
def _pinned_connection_classes(
|
|
policy: SafeUrlPolicy,
|
|
) -> tuple[type[HTTPConnectionPool], type[HTTPSConnectionPool]]:
|
|
def _new_connection(connection: HTTPConnection) -> socket.socket:
|
|
hostname = connection.host
|
|
port = connection.port or connection.default_port
|
|
if port is None:
|
|
raise NewConnectionError(connection, "Destination has no port")
|
|
try:
|
|
records = policy.resolve(hostname, port)
|
|
last_error: OSError | None = None
|
|
for record in records:
|
|
try:
|
|
return create_connection(
|
|
(record[4][0], port),
|
|
timeout=connection.timeout,
|
|
source_address=connection.source_address,
|
|
socket_options=connection.socket_options,
|
|
)
|
|
except OSError as exc:
|
|
last_error = exc
|
|
raise last_error or OSError("No validated destination was connectable")
|
|
except (UnsafeUrlError, OSError) as exc:
|
|
raise NewConnectionError(connection, f"Blocked or failed destination {hostname}: {exc}") from exc
|
|
|
|
class PinnedHTTPConnection(HTTPConnection):
|
|
def _new_conn(self) -> socket.socket:
|
|
return _new_connection(self)
|
|
|
|
class PinnedHTTPSConnection(HTTPSConnection):
|
|
def _new_conn(self) -> socket.socket:
|
|
return _new_connection(self)
|
|
|
|
class PinnedHTTPConnectionPool(HTTPConnectionPool):
|
|
ConnectionCls = PinnedHTTPConnection
|
|
|
|
class PinnedHTTPSConnectionPool(HTTPSConnectionPool):
|
|
ConnectionCls = PinnedHTTPSConnection
|
|
|
|
return PinnedHTTPConnectionPool, PinnedHTTPSConnectionPool
|
|
|
|
|
|
class SafeRequestsAdapter(HTTPAdapter):
|
|
"""requests adapter that validates DNS answers at socket creation time."""
|
|
|
|
def __init__(self, policy: SafeUrlPolicy, *args: Any, **kwargs: Any) -> None:
|
|
self.policy = policy
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def init_poolmanager(
|
|
self,
|
|
connections: int,
|
|
maxsize: int,
|
|
block: bool = False,
|
|
**pool_kwargs: Any,
|
|
) -> None:
|
|
manager = PoolManager(num_pools=connections, maxsize=maxsize, block=block, **pool_kwargs)
|
|
http_pool, https_pool = _pinned_connection_classes(self.policy)
|
|
manager.pool_classes_by_scheme = {"http": http_pool, "https": https_pool}
|
|
self.poolmanager = manager
|
|
|
|
|
|
def create_safe_requests_session(policy: SafeUrlPolicy) -> requests.Session:
|
|
"""Create a no-proxy requests session with connect-time destination enforcement."""
|
|
session = requests.Session()
|
|
session.trust_env = False
|
|
adapter = SafeRequestsAdapter(policy)
|
|
session.mount("http://", adapter)
|
|
session.mount("https://", adapter)
|
|
return session
|
|
|
|
|
|
def safe_requests_request(
|
|
session: requests.Session,
|
|
method: str,
|
|
url: str,
|
|
*,
|
|
policy: SafeUrlPolicy,
|
|
headers: dict[str, str] | None = None,
|
|
**kwargs: Any,
|
|
) -> requests.Response:
|
|
"""Issue a requests call with bounded, policy-checked manual redirects."""
|
|
current_url = url
|
|
current_method = method.upper()
|
|
current_headers = headers
|
|
for redirect_count in range(policy.max_redirects + 1):
|
|
policy.validate(current_url)
|
|
response = session.request(
|
|
current_method,
|
|
current_url,
|
|
headers=current_headers,
|
|
allow_redirects=False,
|
|
**kwargs,
|
|
)
|
|
if response.status_code not in _REDIRECT_STATUSES or not response.headers.get("Location"):
|
|
return response
|
|
if redirect_count >= policy.max_redirects:
|
|
response.close()
|
|
raise UnsafeUrlError("Too many redirects")
|
|
|
|
try:
|
|
target = _redirect_target(current_url, response.headers["Location"], policy)
|
|
same_origin = _same_origin(current_url, target, policy)
|
|
current_headers = _headers_for_redirect(current_headers, same_origin=same_origin)
|
|
if not same_origin and (
|
|
current_method not in {"GET", "HEAD"}
|
|
or kwargs.get("json") is not None
|
|
or kwargs.get("data") is not None
|
|
):
|
|
raise UnsafeUrlError("Cross-origin redirects cannot forward request bodies")
|
|
kwargs.pop("params", None)
|
|
if response.status_code == 303 or (
|
|
response.status_code in {301, 302} and current_method == "POST"
|
|
):
|
|
current_method = "GET"
|
|
kwargs.pop("json", None)
|
|
kwargs.pop("data", None)
|
|
finally:
|
|
response.close()
|
|
current_url = target
|
|
raise UnsafeUrlError("Too many redirects")
|
|
|
|
|
|
def _is_nix_environment() -> bool:
|
|
"""
|
|
Detect if running in a Nix environment
|
|
|
|
Returns:
|
|
True if running in Nix build or NixOS
|
|
"""
|
|
# Check for Nix store path (most reliable indicator)
|
|
if 'NIX_STORE' in os.environ:
|
|
return True
|
|
|
|
# Check if we're in a Nix store path
|
|
try:
|
|
current_path = Path.cwd().resolve()
|
|
# Nix store paths typically look like /nix/store/<hash>-<name>
|
|
if '/nix/store/' in str(current_path):
|
|
return True
|
|
except Exception:
|
|
pass
|
|
|
|
# Check for Nix-related environment variables
|
|
nix_env_vars = ['NIX_PATH', 'NIX_REMOTE', 'IN_NIX_SHELL']
|
|
return bool(any(var in os.environ for var in nix_env_vars))
|
|
|
|
|
|
def validate_external_url(
|
|
url: str,
|
|
allow_private: bool = False,
|
|
allow_loopback: bool | None = None, # Deprecated: use allow_private=True instead
|
|
timeout: float = 2.0,
|
|
) -> bool:
|
|
"""
|
|
Validate that URL points to safe external resource (SSRF protection)
|
|
|
|
Args:
|
|
url: URL to validate
|
|
allow_private: Whether to allow private/internal IPs (default: False)
|
|
allow_loopback: If True, only loopback addresses are permitted. Deprecated for
|
|
broad internal access; use allow_private=True instead.
|
|
timeout: Retained for API compatibility. The system resolver does not
|
|
provide a portable per-call DNS timeout.
|
|
|
|
Returns:
|
|
True if URL is safe, False otherwise
|
|
|
|
Raises:
|
|
ValueError: If URL is invalid or unsafe
|
|
|
|
Note:
|
|
- allow_loopback=True only permits loopback addresses (127.0.0.1, ::1)
|
|
- allow_private=True permits all internal ranges (loopback, RFC1918, CGN, link-local)
|
|
"""
|
|
try:
|
|
return SafeUrlPolicy(
|
|
allow_private=allow_private,
|
|
allow_loopback=allow_loopback,
|
|
timeout=timeout,
|
|
).validate(url)
|
|
except (UnsafeUrlError, ValueError) as e:
|
|
logger.warning(f"URL validation failed: {e}")
|
|
return False
|
|
|
|
|
|
def validate_safe_path(file_path: str, base_dir: str = '.', allow_absolute: bool = False) -> Path:
|
|
"""
|
|
Validate that path is safe with configurable restrictions
|
|
|
|
When allow_absolute=False (default):
|
|
- Paths must be within base_dir (prevents path traversal)
|
|
- Blocks dangerous system directories
|
|
|
|
When allow_absolute=True:
|
|
- Allows paths outside base_dir (for user-configured database/log locations)
|
|
- Still blocks dangerous system directories
|
|
- In Nix environments, dangerous directory checks are relaxed (Nix provides isolation)
|
|
|
|
Args:
|
|
file_path: Path to validate
|
|
base_dir: Base directory that path must be within (when allow_absolute=False)
|
|
allow_absolute: Whether to allow absolute paths outside base_dir
|
|
|
|
Returns:
|
|
Resolved Path object if safe
|
|
|
|
Raises:
|
|
ValueError: If path is unsafe or attempts traversal
|
|
"""
|
|
try:
|
|
# Resolve base directory to absolute path
|
|
base = Path(base_dir).resolve()
|
|
|
|
# Resolve target path relative to base_dir (not current working directory)
|
|
# If file_path is absolute, use it directly; otherwise join with base_dir
|
|
if Path(file_path).is_absolute():
|
|
target = Path(file_path).resolve()
|
|
else:
|
|
# Join with base_dir first, then resolve to handle relative paths correctly
|
|
target = (base / file_path).resolve()
|
|
|
|
# If absolute paths are not allowed, ensure target is within base
|
|
if not allow_absolute:
|
|
# Check if target is within base directory
|
|
try:
|
|
target.relative_to(base)
|
|
except ValueError:
|
|
raise ValueError(
|
|
f"Path traversal detected: {file_path} is not within {base_dir}"
|
|
)
|
|
|
|
# Check for dangerous system paths (OS-specific)
|
|
# In Nix environments, skip this check as Nix provides strong isolation
|
|
is_nix = _is_nix_environment()
|
|
|
|
if not is_nix:
|
|
system = platform.system()
|
|
if system == 'Windows':
|
|
dangerous_prefixes = [
|
|
'C:\\Windows\\System32',
|
|
'C:\\Windows\\SysWOW64',
|
|
'C:\\Program Files',
|
|
'C:\\ProgramData',
|
|
'C:\\Windows\\System',
|
|
]
|
|
# Check against both forward and backslash paths
|
|
target_str = str(target).lower()
|
|
dangerous = any(target_str.startswith(prefix.lower()) for prefix in dangerous_prefixes)
|
|
elif system == 'Darwin': # macOS
|
|
dangerous_prefixes = [
|
|
'/System',
|
|
'/Library',
|
|
'/usr/bin',
|
|
'/usr/sbin',
|
|
'/sbin',
|
|
'/bin',
|
|
'/private/etc',
|
|
'/private/var/root',
|
|
'/private/var/db',
|
|
]
|
|
target_str = str(target)
|
|
dangerous = any(target_str.startswith(prefix) for prefix in dangerous_prefixes)
|
|
else: # Linux and other Unix-like systems
|
|
dangerous_prefixes = ['/etc', '/sys', '/proc', '/dev', '/bin', '/sbin', '/boot']
|
|
target_str = str(target)
|
|
dangerous = any(target_str.startswith(prefix) for prefix in dangerous_prefixes)
|
|
|
|
if dangerous:
|
|
raise ValueError(f"Access to system directory denied: {file_path}")
|
|
|
|
return target
|
|
|
|
except ValueError:
|
|
# Re-raise ValueError as-is (these are our validation errors)
|
|
raise
|
|
except Exception as e:
|
|
raise ValueError(f"Invalid or unsafe file path: {file_path} - {e}")
|
|
|
|
|
|
def sanitize_input(content: str, max_length: Optional[int] = 500, strip_controls: bool = True) -> str:
|
|
"""
|
|
Sanitize user input to prevent injection attacks
|
|
|
|
Args:
|
|
content: Input string to sanitize
|
|
max_length: Maximum allowed length (default: 500 chars, None to disable length check)
|
|
strip_controls: Whether to remove control characters (default: True)
|
|
|
|
Returns:
|
|
Sanitized string
|
|
|
|
Raises:
|
|
ValueError: If max_length is negative
|
|
"""
|
|
if not isinstance(content, str):
|
|
content = str(content)
|
|
|
|
# Validate max_length if provided
|
|
if max_length is not None:
|
|
if max_length < 0:
|
|
raise ValueError(f"max_length must be non-negative, got {max_length}")
|
|
# Limit length to prevent DoS
|
|
if len(content) > max_length:
|
|
content = content[:max_length]
|
|
logger.debug(f"Input truncated to {max_length} characters")
|
|
|
|
# Remove control characters except newline, carriage return, tab
|
|
if strip_controls:
|
|
# Keep only printable characters plus common whitespace
|
|
content = ''.join(
|
|
char for char in content
|
|
if ord(char) >= 32 or char in '\n\r\t'
|
|
)
|
|
|
|
# Remove null bytes (can cause issues in C libraries)
|
|
content = content.replace('\x00', '')
|
|
|
|
return content.strip()
|
|
|
|
|
|
def sanitize_name(name: object, max_length: int = 64) -> str:
|
|
"""
|
|
Sanitize a short identifier (node name, channel name, etc.) for safe logging and storage.
|
|
|
|
Strips all control characters including newlines, carriage returns, tabs,
|
|
null bytes, and ANSI escape sequences. Truncates to max_length.
|
|
|
|
Args:
|
|
name: Value to sanitize (coerced to str if not already).
|
|
max_length: Maximum character length (default: 64).
|
|
|
|
Returns:
|
|
Sanitized string.
|
|
|
|
Raises:
|
|
ValueError: If max_length is negative.
|
|
"""
|
|
if max_length < 0:
|
|
raise ValueError(f"max_length must be non-negative, got {max_length}")
|
|
text = str(name) if not isinstance(name, str) else name
|
|
# Strip all control characters (including \n \r \t \x00 and ANSI escapes)
|
|
text = re.sub(r'[\x00-\x1f\x7f]|\x1b\[[0-9;]*[A-Za-z]', '', text)
|
|
return text[:max_length]
|
|
|
|
|
|
# Valid SQLite journal modes for PRAGMA journal_mode validation
|
|
VALID_JOURNAL_MODES = {"DELETE", "TRUNCATE", "PERSIST", "MEMORY", "WAL", "OFF"}
|
|
|
|
|
|
def validate_sql_identifier(identifier: str) -> str:
|
|
"""
|
|
Validate a SQL identifier (table or column name) for safe interpolation.
|
|
|
|
Only allows alphanumeric characters and underscores, must start with
|
|
a letter or underscore. This is intentionally strict to prevent SQL injection
|
|
when parameterized queries cannot be used (e.g., PRAGMA, REINDEX).
|
|
|
|
Args:
|
|
identifier: The SQL identifier to validate
|
|
|
|
Returns:
|
|
The validated identifier string
|
|
|
|
Raises:
|
|
ValueError: If the identifier contains unsafe characters
|
|
"""
|
|
if not re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', identifier):
|
|
raise ValueError(f"Invalid SQL identifier: {identifier!r}")
|
|
return identifier
|
|
|
|
|
|
def validate_api_key_format(api_key: str, min_length: int = 16) -> bool:
|
|
"""
|
|
Validate API key format
|
|
|
|
Args:
|
|
api_key: API key to validate
|
|
min_length: Minimum required length (default: 16)
|
|
|
|
Returns:
|
|
True if format is valid, False otherwise
|
|
"""
|
|
if not isinstance(api_key, str):
|
|
return False
|
|
|
|
# Check minimum length
|
|
if len(api_key) < min_length:
|
|
return False
|
|
|
|
# Check for obviously invalid patterns
|
|
invalid_patterns = [
|
|
'your_api_key_here',
|
|
'placeholder',
|
|
'example',
|
|
'test_key',
|
|
'12345',
|
|
'aaaa',
|
|
]
|
|
|
|
api_key_lower = api_key.lower()
|
|
if any(pattern in api_key_lower for pattern in invalid_patterns):
|
|
return False
|
|
|
|
# Check that it's not all the same character
|
|
return not len(set(api_key)) < 3
|
|
|
|
|
|
def validate_pubkey_format(pubkey: str, expected_length: int = 64) -> bool:
|
|
"""
|
|
Validate public key format (hex string)
|
|
|
|
Args:
|
|
pubkey: Public key to validate
|
|
expected_length: Expected length in characters (default: 64 for ed25519)
|
|
|
|
Returns:
|
|
True if format is valid, False otherwise
|
|
"""
|
|
if not isinstance(pubkey, str):
|
|
return False
|
|
|
|
# Check exact length
|
|
if len(pubkey) != expected_length:
|
|
return False
|
|
|
|
# Check hex format
|
|
return bool(re.match(r'^[0-9a-fA-F]+$', pubkey))
|
|
|
|
|
|
def validate_port_number(port: int, allow_privileged: bool = False) -> bool:
|
|
"""
|
|
Validate port number
|
|
|
|
Args:
|
|
port: Port number to validate
|
|
allow_privileged: Whether to allow privileged ports <1024 (default: False)
|
|
|
|
Returns:
|
|
True if port is valid, False otherwise
|
|
"""
|
|
if not isinstance(port, int):
|
|
return False
|
|
|
|
min_port = 1 if allow_privileged else 1024
|
|
max_port = 65535
|
|
|
|
return min_port <= port <= max_port
|
|
|
|
|
|
def validate_integer_range(value: int, min_value: int, max_value: int, name: str = "value") -> bool:
|
|
"""
|
|
Validate integer is within range
|
|
|
|
Args:
|
|
value: Integer to validate
|
|
min_value: Minimum allowed value (inclusive)
|
|
max_value: Maximum allowed value (inclusive)
|
|
name: Name of the value for error messages
|
|
|
|
Returns:
|
|
True if valid
|
|
|
|
Raises:
|
|
ValueError: If value is out of range
|
|
"""
|
|
if not isinstance(value, int):
|
|
raise ValueError(f"{name} must be an integer, got {type(value).__name__}")
|
|
|
|
if value < min_value or value > max_value:
|
|
raise ValueError(
|
|
f"{name} must be between {min_value} and {max_value}, got {value}"
|
|
)
|
|
|
|
return True
|