diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..de90721 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Reporting a vulnerability + +Please open a private report via GitHub Security Advisories (preferred), or file an issue with minimal details if private reporting is not available. + +## Notes for operators + +### `session_id` ownership binding + +HumaneProxy supports caller-provided `session_id` values for trajectory tracking and escalation auditing. In multi-tenant deployments, a user who can guess another user’s `session_id` could otherwise poison their risk trajectory or generate false escalations. + +To mitigate this, HumaneProxy binds each `session_id` to a per-caller **owner token** on first use. Subsequent writes to the same `session_id` must match the original owner token. + +For the built-in HTTP proxy (`POST /chat`), the owner token is derived from the client IP address and **hardened** with `HUMANE_PROXY_SESSION_SECRET` when set. + +#### Recommended configuration + +- Set `HUMANE_PROXY_SESSION_SECRET` to a long random value (and keep it stable across deploys). +- Avoid predictable `session_id` values (usernames, emails, sequential IDs). Prefer random IDs. + diff --git a/humane_proxy/__init__.py b/humane_proxy/__init__.py index a9518b9..4300f49 100644 --- a/humane_proxy/__init__.py +++ b/humane_proxy/__init__.py @@ -89,7 +89,13 @@ def pipeline(self): """Return the underlying SafetyPipeline instance.""" return self._pipeline - def check(self, text: str, session_id: str = "programmatic") -> dict: + def check( + self, + text: str, + session_id: str = "programmatic", + *, + owner_token: str | None = None, + ) -> dict: """Run the synchronous safety pipeline on *text* (Stages 1+2). Returns @@ -98,10 +104,19 @@ def check(self, text: str, session_id: str = "programmatic") -> dict: ``{"safe": bool, "category": str, "score": float, "triggers": list, "stage_reached": int, ...}`` """ + if owner_token is not None: + from humane_proxy.storage.factory import get_store + get_store().assert_session_owner(session_id, owner_token) result = self._pipeline.classify_sync(text, session_id) return result.to_dict() - async def check_async(self, text: str, session_id: str = "programmatic") -> dict: + async def check_async( + self, + text: str, + session_id: str = "programmatic", + *, + owner_token: str | None = None, + ) -> dict: """Run the full async safety pipeline on *text* (all 3 stages). Returns @@ -110,6 +125,9 @@ async def check_async(self, text: str, session_id: str = "programmatic") -> dict Same as :meth:`check`, but potentially enriched with Stage-3 reasoning and higher accuracy. """ + if owner_token is not None: + from humane_proxy.storage.factory import get_store + get_store().assert_session_owner(session_id, owner_token) result = await self._pipeline.classify(text, session_id) return result.to_dict() diff --git a/humane_proxy/errors.py b/humane_proxy/errors.py new file mode 100644 index 0000000..0667ffe --- /dev/null +++ b/humane_proxy/errors.py @@ -0,0 +1,12 @@ +"""Project-wide exception types.""" + +from __future__ import annotations + + +class HumaneProxyError(Exception): + """Base exception for HumaneProxy.""" + + +class SessionOwnershipError(HumaneProxyError): + """Raised when a session_id is used by a different caller/owner.""" + diff --git a/humane_proxy/escalation/local_db.py b/humane_proxy/escalation/local_db.py index 29420dc..91d331f 100644 --- a/humane_proxy/escalation/local_db.py +++ b/humane_proxy/escalation/local_db.py @@ -40,10 +40,13 @@ def init_db() -> None: logger.info("Escalation storage initialised (backend: %s)", type(store).__name__) -def check_rate_limit(session_id: str) -> bool: +def check_rate_limit(session_id: str, owner_token: str | None = None) -> bool: """Return ``True`` if the session is **within** its allowed quota.""" from humane_proxy.storage.factory import get_store - return get_store().check_rate_limit(session_id) + store = get_store() + if owner_token is not None: + store.assert_session_owner(session_id, owner_token) + return store.check_rate_limit(session_id) def log_escalation( @@ -54,10 +57,14 @@ def log_escalation( message_hash: str | None = None, stage_reached: int = 1, reasoning: str | None = None, + owner_token: str | None = None, ) -> None: """Persist an escalation event to the configured backend.""" from humane_proxy.storage.factory import get_store - get_store().log( + store = get_store() + if owner_token is not None: + store.assert_session_owner(session_id, owner_token) + store.log( session_id=session_id, category=category, risk_score=risk_score, diff --git a/humane_proxy/escalation/router.py b/humane_proxy/escalation/router.py index 92e9af2..92a2777 100644 --- a/humane_proxy/escalation/router.py +++ b/humane_proxy/escalation/router.py @@ -133,6 +133,7 @@ def escalate( message_hash: str | None = None, stage_reached: int = 1, reasoning: str | None = None, + owner_token: str | None = None, ) -> dict: """Handle a flagged interaction. @@ -167,7 +168,7 @@ def escalate( triggers = triggers or [] # --- Rate-limit gate --- - if not check_rate_limit(session_id): + if not check_rate_limit(session_id, owner_token=owner_token): logger.warning( "[RATE-LIMITED] session=%s category=%s risk_score=%.2f — suppressed (quota exhausted)", session_id, category, risk_score, @@ -186,6 +187,7 @@ def escalate( message_hash=message_hash, stage_reached=stage_reached, reasoning=reasoning, + owner_token=owner_token, ) except Exception: logger.exception("Failed to write escalation to DB for session=%s", session_id) diff --git a/humane_proxy/mcp_server.py b/humane_proxy/mcp_server.py index c6f8101..0a11abe 100644 --- a/humane_proxy/mcp_server.py +++ b/humane_proxy/mcp_server.py @@ -77,6 +77,7 @@ def _get_mcp_auth_provider(): async def check_message_safety( message: str, session_id: str = "mcp-default", + owner_token: str | None = None, ) -> dict: """Classify a message for self-harm or criminal intent. @@ -98,6 +99,9 @@ async def check_message_safety( config = get_config() pipeline = SafetyPipeline(config) + if owner_token is not None: + from humane_proxy.storage.factory import get_store + get_store().assert_session_owner(session_id, owner_token) result = await pipeline.classify(message, session_id) return result.to_dict() diff --git a/humane_proxy/middleware/interceptor.py b/humane_proxy/middleware/interceptor.py index 613ac62..1f1c1f2 100644 --- a/humane_proxy/middleware/interceptor.py +++ b/humane_proxy/middleware/interceptor.py @@ -4,6 +4,8 @@ import logging import os +import hashlib +import hmac from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from typing import Any @@ -12,6 +14,7 @@ from fastapi import FastAPI, Request from fastapi.responses import JSONResponse +from humane_proxy.errors import SessionOwnershipError from humane_proxy.escalation.local_db import init_db from humane_proxy.escalation.router import escalate, get_self_harm_response @@ -62,6 +65,22 @@ def _resolve_session_id(payload: dict[str, Any], request: Request) -> str: request.client.host if request.client else "unknown" ) +def _owner_token_for_request(request: Request) -> str: + """Derive a stable per-caller owner token for session binding. + + Uses ``HUMANE_PROXY_SESSION_SECRET`` when set for HMAC hardening. + Falls back to a deterministic hash of the client IP to avoid storing + raw IPs in the session ownership table. + """ + ip = request.client.host if request.client else "unknown" + secret = os.environ.get("HUMANE_PROXY_SESSION_SECRET", "").encode("utf-8") + ip_bytes = ip.encode("utf-8") + if secret: + digest = hmac.new(secret, ip_bytes, hashlib.sha256).hexdigest() + return f"hmac:{digest}" + digest = hashlib.sha256(ip_bytes).hexdigest() + return f"ip:{digest}" + def _extract_last_user_message(payload: dict[str, Any]) -> str: messages: list[dict[str, str]] = payload.get("messages", []) @@ -77,6 +96,7 @@ async def chat(request: Request) -> JSONResponse: payload: dict[str, Any] = await request.json() session_id = _resolve_session_id(payload, request) + owner_token = _owner_token_for_request(request) user_message = _extract_last_user_message(payload) if not user_message: @@ -85,6 +105,16 @@ async def chat(request: Request) -> JSONResponse: content={"status": "error", "message": "No user message found in payload."}, ) + from humane_proxy.storage.factory import get_store + + try: + get_store().assert_session_owner(session_id, owner_token) + except SessionOwnershipError as exc: + return JSONResponse( + status_code=403, + content={"status": "error", "message": str(exc), "session_id": session_id}, + ) + pipeline = _get_pipeline() result = await pipeline.classify(user_message, session_id) @@ -99,6 +129,7 @@ async def chat(request: Request) -> JSONResponse: message_hash=result.message_hash, stage_reached=cls.stage, reasoning=cls.reasoning, + owner_token=owner_token, ) # Self-harm: return care response instead of generic flagged message. diff --git a/humane_proxy/storage/base.py b/humane_proxy/storage/base.py index 40ae79e..135dc9a 100644 --- a/humane_proxy/storage/base.py +++ b/humane_proxy/storage/base.py @@ -18,6 +18,25 @@ def init(self) -> None: """Initialise the storage backend (create tables, ensure indexes, etc.).""" ... + @abstractmethod + def get_session_owner(self, session_id: str) -> str | None: + """Return the owner token for a session, or ``None`` if unknown.""" + ... + + @abstractmethod + def set_session_owner(self, session_id: str, owner_token: str) -> None: + """Bind a session to an owner token (first write wins).""" + ... + + @abstractmethod + def assert_session_owner(self, session_id: str, owner_token: str) -> None: + """Ensure ``session_id`` is owned by ``owner_token``. + + Implementations should persist the first seen owner token for a new + session and reject subsequent mismatches. + """ + ... + @abstractmethod def log( self, diff --git a/humane_proxy/storage/postgres.py b/humane_proxy/storage/postgres.py index eb802dc..e4191dd 100644 --- a/humane_proxy/storage/postgres.py +++ b/humane_proxy/storage/postgres.py @@ -10,6 +10,7 @@ from datetime import datetime, timedelta, timezone from typing import Any +from humane_proxy.errors import SessionOwnershipError from humane_proxy.storage.base import EscalationStore logger = logging.getLogger("humane_proxy.storage.postgres") @@ -59,6 +60,15 @@ def _conn(self): def init(self) -> None: with self._conn() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + owner_token TEXT NOT NULL, + created_at DOUBLE PRECISION NOT NULL + ) + """ + ) conn.execute( """ CREATE TABLE IF NOT EXISTS escalations ( @@ -83,6 +93,51 @@ def init(self) -> None: conn.commit() logger.info("PostgreSQL store initialised: %s", self._dsn.split("@")[-1] if "@" in self._dsn else "(local)") + def get_session_owner(self, session_id: str) -> str | None: + with self._conn() as conn: + row = conn.execute( + "SELECT owner_token FROM sessions WHERE session_id = %s", + (session_id,), + ).fetchone() + return row["owner_token"] if row else None + + def set_session_owner(self, session_id: str, owner_token: str) -> None: + ts = datetime.now(timezone.utc).timestamp() + with self._conn() as conn: + conn.execute( + """ + INSERT INTO sessions (session_id, owner_token, created_at) + VALUES (%s, %s, %s) + ON CONFLICT (session_id) DO NOTHING + """, + (session_id, owner_token, ts), + ) + conn.commit() + + def assert_session_owner(self, session_id: str, owner_token: str) -> None: + ts = datetime.now(timezone.utc).timestamp() + with self._conn() as conn: + conn.execute( + """ + INSERT INTO sessions (session_id, owner_token, created_at) + VALUES (%s, %s, %s) + ON CONFLICT (session_id) DO NOTHING + """, + (session_id, owner_token, ts), + ) + row = conn.execute( + "SELECT owner_token FROM sessions WHERE session_id = %s", + (session_id,), + ).fetchone() + conn.commit() + existing = row["owner_token"] if row else None + if existing is None: + return + if existing != owner_token: + raise SessionOwnershipError( + f"session_id '{session_id}' belongs to a different caller" + ) + def log( self, session_id: str, @@ -158,6 +213,7 @@ def delete_session(self, session_id: str) -> int: cur = conn.execute( "DELETE FROM escalations WHERE session_id = %s", (session_id,) ) + conn.execute("DELETE FROM sessions WHERE session_id = %s", (session_id,)) conn.commit() return cur.rowcount diff --git a/humane_proxy/storage/redis.py b/humane_proxy/storage/redis.py index 545b20d..44a23c5 100644 --- a/humane_proxy/storage/redis.py +++ b/humane_proxy/storage/redis.py @@ -11,6 +11,7 @@ from datetime import datetime, timedelta, timezone from typing import Any +from humane_proxy.errors import SessionOwnershipError from humane_proxy.storage.base import EscalationStore logger = logging.getLogger("humane_proxy.storage.redis") @@ -69,6 +70,24 @@ def init(self) -> None: self._client.ping() logger.info("Redis store connected: %s", self._client.connection_pool.connection_kwargs.get("host", "")) + def get_session_owner(self, session_id: str) -> str | None: + value = self._client.get(self._key("owner", session_id)) + return value or None + + def set_session_owner(self, session_id: str, owner_token: str) -> None: + # First writer wins. + self._client.set(self._key("owner", session_id), owner_token, nx=True) + + def assert_session_owner(self, session_id: str, owner_token: str) -> None: + owner_key = self._key("owner", session_id) + # First writer wins (SET NX). If it already exists, we verify. + self._client.set(owner_key, owner_token, nx=True) + existing = self._client.get(owner_key) + if existing and existing != owner_token: + raise SessionOwnershipError( + f"session_id '{session_id}' belongs to a different caller" + ) + def log( self, session_id: str, @@ -144,12 +163,14 @@ def get_by_id(self, escalation_id: int) -> dict[str, Any] | None: def delete_session(self, session_id: str) -> int: ids = self._client.zrange(self._key("session", session_id), 0, -1) if not ids: + self._client.delete(self._key("owner", session_id)) return 0 pipe = self._client.pipeline() for esc_id in ids: pipe.delete(self._key("esc", esc_id)) pipe.zrem(self._key("esc_timeline"), esc_id) pipe.delete(self._key("session", session_id)) + pipe.delete(self._key("owner", session_id)) pipe.execute() return len(ids) diff --git a/humane_proxy/storage/sqlite.py b/humane_proxy/storage/sqlite.py index d088dd1..9af17bf 100644 --- a/humane_proxy/storage/sqlite.py +++ b/humane_proxy/storage/sqlite.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import Any +from humane_proxy.errors import SessionOwnershipError from humane_proxy.storage.base import EscalationStore logger = logging.getLogger("humane_proxy.storage.sqlite") @@ -56,6 +57,15 @@ def init(self) -> None: conn = self._conn() try: with conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + owner_token TEXT NOT NULL, + created_at REAL NOT NULL + ) + """ + ) conn.execute( """ CREATE TABLE IF NOT EXISTS escalations ( @@ -91,6 +101,58 @@ def init(self) -> None: conn.close() logger.info("SQLite store initialised at %s", self._db_path) + def get_session_owner(self, session_id: str) -> str | None: + conn = self._conn() + try: + row = conn.execute( + "SELECT owner_token FROM sessions WHERE session_id = ?", + (session_id,), + ).fetchone() + finally: + conn.close() + return row[0] if row else None + + def set_session_owner(self, session_id: str, owner_token: str) -> None: + conn = self._conn() + try: + with conn: + conn.execute( + """ + INSERT INTO sessions (session_id, owner_token, created_at) + VALUES (?, ?, ?) + ON CONFLICT(session_id) DO NOTHING + """, + (session_id, owner_token, datetime.now(timezone.utc).timestamp()), + ) + finally: + conn.close() + + def assert_session_owner(self, session_id: str, owner_token: str) -> None: + conn = self._conn() + try: + with conn: + conn.execute( + """ + INSERT INTO sessions (session_id, owner_token, created_at) + VALUES (?, ?, ?) + ON CONFLICT(session_id) DO NOTHING + """, + (session_id, owner_token, datetime.now(timezone.utc).timestamp()), + ) + row = conn.execute( + "SELECT owner_token FROM sessions WHERE session_id = ?", + (session_id,), + ).fetchone() + existing = row[0] if row else None + if existing is None: + return + if existing != owner_token: + raise SessionOwnershipError( + f"session_id '{session_id}' belongs to a different caller" + ) + finally: + conn.close() + def log( self, session_id: str, @@ -176,9 +238,11 @@ def delete_session(self, session_id: str) -> int: conn = self._conn() try: with conn: - return conn.execute( + deleted = conn.execute( "DELETE FROM escalations WHERE session_id = ?", (session_id,) ).rowcount + conn.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,)) + return deleted finally: conn.close() diff --git a/tests/test_session_ownership.py b/tests/test_session_ownership.py new file mode 100644 index 0000000..3be0c22 --- /dev/null +++ b/tests/test_session_ownership.py @@ -0,0 +1,34 @@ +"""Tests for session ownership binding (Issue #24).""" + +import pytest + +from humane_proxy.errors import SessionOwnershipError +from humane_proxy.storage.factory import _create_store + + +def test_sqlite_session_owner_binding(tmp_path): + db_path = tmp_path / "owner.db" + config = {"storage": {"backend": "sqlite", "sqlite": {"path": str(db_path)}}} + store = _create_store(config) + store.init() + + store.assert_session_owner("sid-1", "owner-a") + store.assert_session_owner("sid-1", "owner-a") # idempotent + + with pytest.raises(SessionOwnershipError): + store.assert_session_owner("sid-1", "owner-b") + + +def test_delete_session_clears_owner(tmp_path): + db_path = tmp_path / "owner-delete.db" + config = {"storage": {"backend": "sqlite", "sqlite": {"path": str(db_path)}}} + store = _create_store(config) + store.init() + + store.assert_session_owner("sid-2", "owner-a") + store.log("sid-2", "safe", 0.1, []) + assert store.delete_session("sid-2") == 1 + + # After delete, the session can be re-bound to a new owner. + store.assert_session_owner("sid-2", "owner-b") +