data2 / backend /app /security.py
Arnav Singh
Add universal AI gateway
8f1b6ce
Raw History Blame Contribute Delete
4.93 kB
import asyncio
import hashlib
import ipaddress
import json
import re
import secrets
import socket
from typing import Any
from urllib.parse import urlsplit
import httpcore
import httpx
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
from cryptography.fernet import Fernet, MultiFernet
from httpcore._backends.auto import AutoBackend
from .errors import fail
password_hasher = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=2)
DUMMY_HASH = password_hasher.hash(secrets.token_urlsafe(24))
def token_hash(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()
def new_key() -> str:
return "gw_" + secrets.token_urlsafe(32)
async def hash_password(password: str) -> str:
return await asyncio.to_thread(password_hasher.hash, password)
async def verify_password(stored: str, password: str) -> bool:
try:
return await asyncio.to_thread(password_hasher.verify, stored, password)
except (VerificationError, InvalidHashError):
return False
class Vault:
def __init__(self, keys: str):
self.cipher = MultiFernet([Fernet(k.strip().encode()) for k in keys.split(",")])
def seal(self, value: dict[str, Any]) -> str:
return self.cipher.encrypt(json.dumps(value).encode()).decode()
def open(self, ciphertext: str) -> dict[str, Any]:
return json.loads(self.cipher.decrypt(ciphertext.encode())) # type: ignore[no-any-return]
BLOCKED_HEADERS = {
"host",
"connection",
"content-length",
"transfer-encoding",
"cookie",
"proxy-authorization",
"proxy-connection",
"forwarded",
"x-forwarded-for",
"x-forwarded-host",
"x-forwarded-proto",
"upgrade",
"te",
"trailer",
}
def validate_headers(headers: dict[str, str]) -> dict[str, str]:
if len(headers) > 24:
raise fail(422, "At most 24 custom headers are allowed.")
for key, value in headers.items():
if (
key.lower() in BLOCKED_HEADERS
or not re.fullmatch(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+", key)
or "\r" in value
or "\n" in value
or len(value) > 4096
):
raise fail(422, "Custom headers contain a forbidden name or invalid value.")
return headers
def validate_base_url(value: str, allowed_private: list[str]) -> str:
try:
parsed = urlsplit(value)
_ = parsed.port
except ValueError:
raise fail(422, "Invalid base URL.") from None
host = (parsed.hostname or "").lower()
if (
parsed.scheme not in ("http", "https")
or not host
or parsed.username
or parsed.password
or parsed.query
or parsed.fragment
or "\\" in value
or any(ord(c) < 33 for c in value)
):
raise fail(422, "Base URL must be HTTP(S), without credentials, query, or fragment.")
if parsed.scheme != "https" and host not in allowed_private:
raise fail(
422, "HTTPS is required unless the host is explicitly allowlisted by the operator."
)
return value.rstrip("/")
class SafeNetworkBackend(AutoBackend):
"""Resolve once, validate every address, and connect to the validated IP.
HTTPcore still uses the original origin for Host and TLS SNI/certificate verification.
Validation at connect time prevents DNS rebinding between a preflight and the actual socket.
"""
def __init__(self, allowed_private: list[str]):
super().__init__()
self.allowed_private = set(allowed_private)
async def connect_tcp(
self,
host: str,
port: int,
timeout: float | None = None,
local_address: str | None = None,
socket_options: Any = None,
) -> Any:
loop = asyncio.get_running_loop()
try:
async with asyncio.timeout(timeout or 10):
addresses = await loop.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except (OSError, TimeoutError) as exc:
raise httpcore.ConnectError("Upstream DNS resolution failed") from exc
ips = list(dict.fromkeys(str(item[4][0]) for item in addresses))
if not ips or (
host.lower() not in self.allowed_private
and any(not ipaddress.ip_address(ip).is_global for ip in ips)
):
raise httpcore.ConnectError("Private or reserved upstream address blocked")
return await super().connect_tcp(ips[0], port, timeout, local_address, socket_options)
def secure_transport(allowed_private: list[str]) -> httpx.AsyncHTTPTransport:
transport = httpx.AsyncHTTPTransport(
retries=0, limits=httpx.Limits(max_connections=200, max_keepalive_connections=40)
)
# Version-constrained HTTPcore integration; covered by transport security tests.
transport._pool._network_backend = SafeNetworkBackend(allowed_private)
return transport