mirror of
https://github.com/rookiestar28/ComfyUI-OpenClaw.git
synced 2026-08-14 08:52:45 +00:00
576 lines
17 KiB
Python
576 lines
17 KiB
Python
"""
|
|
S32 — Connector Security Profile.
|
|
|
|
Centralised ingress/auth/scope/allowlist security decisions for
|
|
internet-exposed connector deployments.
|
|
|
|
Provides:
|
|
- auth header verification (Bearer / HMAC-SHA256 signature),
|
|
- replay/dedupe window checks,
|
|
- scope and service-user allowlist enforcement,
|
|
- fail-closed error envelope mapping,
|
|
- reusable runtime primitives wrapping R75 transport contract.
|
|
|
|
All defaults are **fail-closed** — missing or invalid auth rejects the
|
|
request. Permissive modes require explicit operator opt-in.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64 as base64_mod
|
|
import hashlib
|
|
import hmac
|
|
import logging
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from typing import Any, Dict, FrozenSet, List, Optional, Set
|
|
|
|
from .transport_contract import (
|
|
CallbackContract,
|
|
CallbackError,
|
|
CallbackRecord,
|
|
ReconnectPolicy,
|
|
TokenContract,
|
|
TokenError,
|
|
TokenResult,
|
|
TokenSource,
|
|
TransportError,
|
|
)
|
|
|
|
logger = logging.getLogger("connector.security_profile")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auth verification
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class AuthScheme(str, Enum):
|
|
"""Supported ingress auth schemes."""
|
|
|
|
BEARER = "bearer"
|
|
HMAC_SHA256 = "hmac_sha256"
|
|
NONE = "none"
|
|
|
|
|
|
@dataclass
|
|
class AuthVerifyResult:
|
|
"""Result of an ingress auth verification."""
|
|
|
|
ok: bool = False
|
|
scheme: str = AuthScheme.NONE.value
|
|
identity: str = ""
|
|
error: str = ""
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
d: Dict[str, Any] = {"ok": self.ok, "scheme": self.scheme}
|
|
if self.identity:
|
|
d["identity"] = self.identity
|
|
if self.error:
|
|
d["error"] = self.error
|
|
return d
|
|
|
|
|
|
def verify_bearer_token(
|
|
header_value: str,
|
|
*,
|
|
expected_token: str,
|
|
) -> AuthVerifyResult:
|
|
"""
|
|
Verify a ``Bearer <token>`` auth header.
|
|
|
|
Fail-closed: empty or mismatched token → reject.
|
|
"""
|
|
if not header_value or not expected_token:
|
|
return AuthVerifyResult(
|
|
ok=False,
|
|
scheme=AuthScheme.BEARER.value,
|
|
error="missing_token" if not expected_token else "missing_header",
|
|
)
|
|
|
|
# Accept with or without "Bearer " prefix
|
|
raw = header_value
|
|
if raw.lower().startswith("bearer "):
|
|
raw = raw[7:]
|
|
raw = raw.strip()
|
|
|
|
if not hmac.compare_digest(raw, expected_token):
|
|
return AuthVerifyResult(
|
|
ok=False,
|
|
scheme=AuthScheme.BEARER.value,
|
|
error="token_mismatch",
|
|
)
|
|
|
|
return AuthVerifyResult(
|
|
ok=True,
|
|
scheme=AuthScheme.BEARER.value,
|
|
identity="bearer",
|
|
)
|
|
|
|
|
|
def verify_hmac_signature(
|
|
body: bytes,
|
|
*,
|
|
signature_header: str,
|
|
secret: str,
|
|
algorithm: str = "sha256",
|
|
digest_encoding: str = "hex",
|
|
) -> AuthVerifyResult:
|
|
"""
|
|
Verify an HMAC signature over the raw request body.
|
|
|
|
Used by webhook platforms (WhatsApp, LINE, Kakao) that sign payloads.
|
|
|
|
Args:
|
|
digest_encoding: 'hex' (default, WhatsApp/Kakao) or 'base64' (LINE).
|
|
|
|
Fail-closed: missing secret / header / mismatch → reject.
|
|
"""
|
|
if not secret:
|
|
return AuthVerifyResult(
|
|
ok=False,
|
|
scheme=AuthScheme.HMAC_SHA256.value,
|
|
error="missing_secret",
|
|
)
|
|
if not signature_header:
|
|
return AuthVerifyResult(
|
|
ok=False,
|
|
scheme=AuthScheme.HMAC_SHA256.value,
|
|
error="missing_signature_header",
|
|
)
|
|
|
|
algo_map = {
|
|
"sha256": hashlib.sha256,
|
|
"sha1": hashlib.sha1,
|
|
}
|
|
hash_fn = algo_map.get(algorithm)
|
|
if not hash_fn:
|
|
return AuthVerifyResult(
|
|
ok=False,
|
|
scheme=AuthScheme.HMAC_SHA256.value,
|
|
error=f"unsupported_algorithm:{algorithm}",
|
|
)
|
|
|
|
mac = hmac.new(secret.encode("utf-8"), body, hash_fn)
|
|
|
|
if digest_encoding == "base64":
|
|
expected = base64_mod.b64encode(mac.digest()).decode("utf-8")
|
|
else:
|
|
expected = mac.hexdigest()
|
|
|
|
# Strip common prefixes (e.g. "sha256=")
|
|
sig = signature_header.strip()
|
|
for prefix in (f"{algorithm}=", "sha256=", "sha1="):
|
|
if sig.lower().startswith(prefix):
|
|
sig = sig[len(prefix) :]
|
|
break
|
|
|
|
if not hmac.compare_digest(sig, expected):
|
|
return AuthVerifyResult(
|
|
ok=False,
|
|
scheme=AuthScheme.HMAC_SHA256.value,
|
|
error="signature_mismatch",
|
|
)
|
|
|
|
return AuthVerifyResult(
|
|
ok=True,
|
|
scheme=AuthScheme.HMAC_SHA256.value,
|
|
identity="hmac",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Replay / dedupe window
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Default window = 5 minutes
|
|
DEFAULT_REPLAY_WINDOW_SEC = 300
|
|
# Absolute cap on window entries to prevent memory abuse
|
|
MAX_REPLAY_ENTRIES = 50_000
|
|
|
|
|
|
@dataclass
|
|
class ReplayEntry:
|
|
key: str
|
|
timestamp: float = field(default_factory=time.time)
|
|
|
|
|
|
class ReplayGuard:
|
|
"""
|
|
Sliding-window duplicate/replay detector.
|
|
|
|
Provides O(1) membership check with bounded memory.
|
|
Entries older than ``window_sec`` are evicted lazily on insert.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
window_sec: int = DEFAULT_REPLAY_WINDOW_SEC,
|
|
max_entries: int = MAX_REPLAY_ENTRIES,
|
|
):
|
|
self._window_sec = window_sec
|
|
self._max_entries = max_entries
|
|
self._seen: Dict[str, float] = {}
|
|
|
|
@property
|
|
def window_sec(self) -> int:
|
|
return self._window_sec
|
|
|
|
def check_and_record(self, key: str) -> bool:
|
|
"""
|
|
Returns True if the key is **new** (not a replay).
|
|
Returns False if it is a duplicate within the window.
|
|
"""
|
|
self._evict_expired()
|
|
now = time.time()
|
|
|
|
if key in self._seen:
|
|
ts = self._seen[key]
|
|
if now - ts <= self._window_sec:
|
|
return False # duplicate within window
|
|
# Expired entry — treat as new
|
|
self._seen[key] = now
|
|
# Enforce hard cap after insert
|
|
if len(self._seen) > self._max_entries:
|
|
self._enforce_cap()
|
|
return True
|
|
|
|
def is_duplicate(self, key: str) -> bool:
|
|
"""Inverse of check_and_record — True if replay."""
|
|
return not self.check_and_record(key)
|
|
|
|
def _evict_expired(self) -> None:
|
|
now = time.time()
|
|
cutoff = now - self._window_sec
|
|
# Evict expired keys
|
|
expired = [k for k, ts in self._seen.items() if ts < cutoff]
|
|
for k in expired:
|
|
del self._seen[k]
|
|
self._enforce_cap()
|
|
|
|
def _enforce_cap(self) -> None:
|
|
"""Evict oldest entries to respect max_entries."""
|
|
if len(self._seen) > self._max_entries:
|
|
sorted_items = sorted(self._seen.items(), key=lambda x: x[1])
|
|
excess = len(self._seen) - self._max_entries
|
|
for k, _ in sorted_items[:excess]:
|
|
del self._seen[k]
|
|
|
|
@property
|
|
def size(self) -> int:
|
|
return len(self._seen)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scope / allowlist enforcement
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class ScopeDecision(str, Enum):
|
|
ALLOW = "allow"
|
|
DENY = "deny"
|
|
SKIP = "skip" # No allowlist configured — pass-through
|
|
|
|
|
|
@dataclass
|
|
class ScopeResult:
|
|
decision: str = ScopeDecision.DENY.value
|
|
matched_entry: str = ""
|
|
reason: str = ""
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"decision": self.decision,
|
|
"matched_entry": self.matched_entry,
|
|
"reason": self.reason,
|
|
}
|
|
|
|
|
|
class AllowlistPolicy:
|
|
"""
|
|
Scope / service-user allowlist enforcement.
|
|
|
|
When an allowlist is configured, only entries in the list are permitted.
|
|
When the allowlist is empty **and** ``strict=True`` (default), all
|
|
requests are denied (fail-closed). Set ``strict=False`` to allow-all
|
|
when no list is configured.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
entries: Optional[List[str]] = None,
|
|
*,
|
|
strict: bool = True,
|
|
normalizer: Optional[Any] = None,
|
|
):
|
|
raw = entries or []
|
|
self._normalizer = normalizer or (lambda x: x.strip().lower())
|
|
self._entries: FrozenSet[str] = frozenset(
|
|
self._normalizer(e) for e in raw if e.strip()
|
|
)
|
|
self._strict = strict
|
|
|
|
@property
|
|
def entries(self) -> FrozenSet[str]:
|
|
return self._entries
|
|
|
|
@property
|
|
def strict(self) -> bool:
|
|
return self._strict
|
|
|
|
def evaluate(self, identifier: str) -> ScopeResult:
|
|
"""Check whether *identifier* is allowed under the current policy."""
|
|
normalized = self._normalizer(identifier)
|
|
|
|
if not self._entries:
|
|
if self._strict:
|
|
return ScopeResult(
|
|
decision=ScopeDecision.DENY.value,
|
|
reason="empty_allowlist_strict",
|
|
)
|
|
return ScopeResult(
|
|
decision=ScopeDecision.SKIP.value,
|
|
reason="no_allowlist_configured",
|
|
)
|
|
|
|
if normalized in self._entries:
|
|
return ScopeResult(
|
|
decision=ScopeDecision.ALLOW.value,
|
|
matched_entry=normalized,
|
|
)
|
|
|
|
return ScopeResult(
|
|
decision=ScopeDecision.DENY.value,
|
|
reason="not_in_allowlist",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fail-closed error mapping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def to_transport_error(
|
|
code: str,
|
|
message: str,
|
|
*,
|
|
retryable: bool = False,
|
|
details: Optional[Dict[str, Any]] = None,
|
|
) -> TransportError:
|
|
"""Create a deterministic ``TransportError`` from a security decision."""
|
|
return TransportError(
|
|
code=code,
|
|
message=message,
|
|
retryable=retryable,
|
|
details=details or {},
|
|
)
|
|
|
|
|
|
def auth_failure_error(result: AuthVerifyResult) -> TransportError:
|
|
"""Map an ``AuthVerifyResult`` failure to a ``TransportError``."""
|
|
return to_transport_error(
|
|
code=f"auth_{result.error}",
|
|
message=f"Auth failed ({result.scheme}): {result.error}",
|
|
retryable=False,
|
|
)
|
|
|
|
|
|
def scope_denial_error(result: ScopeResult) -> TransportError:
|
|
"""Map a ``ScopeResult`` denial to a ``TransportError``."""
|
|
return to_transport_error(
|
|
code=f"scope_{result.reason}",
|
|
message=f"Scope denied: {result.reason}",
|
|
retryable=False,
|
|
)
|
|
|
|
|
|
def replay_error(key: str) -> TransportError:
|
|
"""Map a replay detection to a ``TransportError``."""
|
|
return to_transport_error(
|
|
code="replay_detected",
|
|
message="Duplicate request detected within replay window",
|
|
retryable=False,
|
|
details={"key_prefix": key[:8] + "..." if len(key) > 8 else key},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Composite ingress gate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class IngressDecision:
|
|
"""Result of the composite ingress security check."""
|
|
|
|
allowed: bool = False
|
|
auth: Optional[AuthVerifyResult] = None
|
|
scope: Optional[ScopeResult] = None
|
|
replay_ok: bool = True
|
|
error: Optional[TransportError] = None
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
d: Dict[str, Any] = {"allowed": self.allowed}
|
|
if self.auth:
|
|
d["auth"] = self.auth.to_dict()
|
|
if self.scope:
|
|
d["scope"] = self.scope.to_dict()
|
|
d["replay_ok"] = self.replay_ok
|
|
if self.error:
|
|
d["error"] = self.error.to_dict()
|
|
return d
|
|
|
|
|
|
class IngressGate:
|
|
"""
|
|
Composite fail-closed ingress gate.
|
|
|
|
Evaluates auth → replay → scope in order. First failure rejects.
|
|
|
|
Skip semantics:
|
|
- Auth is skipped only when ``require_auth=False``.
|
|
- Replay check is skipped only when no ``replay_guard`` is provided
|
|
at construction time. If a guard **is** configured but ``request_id``
|
|
is not supplied at evaluation time, the gate **rejects** (fail-closed).
|
|
- Scope/allowlist is skipped only when no ``allowlist`` is provided
|
|
at construction time. If an allowlist **is** configured but
|
|
``user_id`` is not supplied, the gate **rejects** (fail-closed).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
expected_token: Optional[str] = None,
|
|
hmac_secret: Optional[str] = None,
|
|
replay_guard: Optional[ReplayGuard] = None,
|
|
allowlist: Optional[AllowlistPolicy] = None,
|
|
require_auth: bool = True,
|
|
):
|
|
self._expected_token = expected_token
|
|
self._hmac_secret = hmac_secret
|
|
self._replay_guard = replay_guard
|
|
self._allowlist = allowlist
|
|
self._require_auth = require_auth
|
|
|
|
def evaluate(
|
|
self,
|
|
*,
|
|
auth_header: str = "",
|
|
body: bytes = b"",
|
|
signature_header: str = "",
|
|
request_id: str = "",
|
|
user_id: str = "",
|
|
) -> IngressDecision:
|
|
"""
|
|
Run all ingress checks in fail-closed order.
|
|
|
|
1. Auth (bearer or HMAC)
|
|
2. Replay guard — rejects if guard configured but ``request_id`` missing
|
|
3. Scope/allowlist — rejects if allowlist configured but ``user_id`` missing
|
|
"""
|
|
decision = IngressDecision()
|
|
|
|
# --- Auth ---
|
|
if self._require_auth:
|
|
if self._hmac_secret and body:
|
|
auth_result = verify_hmac_signature(
|
|
body,
|
|
signature_header=signature_header,
|
|
secret=self._hmac_secret,
|
|
)
|
|
elif self._expected_token:
|
|
auth_result = verify_bearer_token(
|
|
auth_header,
|
|
expected_token=self._expected_token,
|
|
)
|
|
else:
|
|
# No auth configured but required → fail-closed
|
|
auth_result = AuthVerifyResult(
|
|
ok=False,
|
|
scheme=AuthScheme.NONE.value,
|
|
error="no_auth_configured",
|
|
)
|
|
decision.auth = auth_result
|
|
if not auth_result.ok:
|
|
decision.error = auth_failure_error(auth_result)
|
|
return decision
|
|
|
|
# --- Replay ---
|
|
if self._replay_guard:
|
|
if not request_id:
|
|
# Guard configured but no request_id supplied → fail-closed
|
|
decision.replay_ok = False
|
|
decision.error = to_transport_error(
|
|
code="replay_missing_request_id",
|
|
message="Replay guard active but no request_id supplied",
|
|
retryable=False,
|
|
)
|
|
return decision
|
|
if self._replay_guard.is_duplicate(request_id):
|
|
decision.replay_ok = False
|
|
decision.error = replay_error(request_id)
|
|
return decision
|
|
|
|
# --- Scope ---
|
|
if self._allowlist:
|
|
if not user_id:
|
|
# Allowlist configured but no user_id supplied → fail-closed
|
|
decision.scope = ScopeResult(
|
|
decision=ScopeDecision.DENY.value,
|
|
reason="missing_user_id",
|
|
)
|
|
decision.error = to_transport_error(
|
|
code="scope_missing_user_id",
|
|
message="Allowlist active but no user_id supplied",
|
|
retryable=False,
|
|
)
|
|
return decision
|
|
scope_result = self._allowlist.evaluate(user_id)
|
|
decision.scope = scope_result
|
|
if scope_result.decision == ScopeDecision.DENY.value:
|
|
decision.error = scope_denial_error(scope_result)
|
|
return decision
|
|
|
|
decision.allowed = True
|
|
return decision
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Convenience: Security Profile (bundles IngressGate + contract references)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class ConnectorSecurityProfile:
|
|
"""
|
|
High-level security profile for a connector deployment.
|
|
|
|
Bundles ingress gate, token contract, callback contract, and
|
|
reconnect policy into a single auditable configuration.
|
|
"""
|
|
|
|
name: str = ""
|
|
ingress_gate: Optional[IngressGate] = None
|
|
token_contract: Optional[TokenContract] = None
|
|
callback_contract: Optional[CallbackContract] = None
|
|
reconnect_policy: Optional[ReconnectPolicy] = None
|
|
|
|
# Posture flags for diagnostics
|
|
require_auth: bool = True
|
|
require_allowlist: bool = True
|
|
strict_callbacks: bool = True
|
|
|
|
def posture_summary(self) -> Dict[str, Any]:
|
|
"""Return a diagnostics-safe posture summary."""
|
|
return {
|
|
"name": self.name,
|
|
"require_auth": self.require_auth,
|
|
"require_allowlist": self.require_allowlist,
|
|
"strict_callbacks": self.strict_callbacks,
|
|
"has_ingress_gate": self.ingress_gate is not None,
|
|
"has_token_contract": self.token_contract is not None,
|
|
"has_callback_contract": self.callback_contract is not None,
|
|
"has_reconnect_policy": self.reconnect_policy is not None,
|
|
}
|