Skip to content

fix: bind session_id to caller ownership#25

Closed
ionfwsrijan wants to merge 1 commit into
Vishisht16:mainfrom
ionfwsrijan:fix/session-ownership
Closed

fix: bind session_id to caller ownership#25
ionfwsrijan wants to merge 1 commit into
Vishisht16:mainfrom
ionfwsrijan:fix/session-ownership

Conversation

@ionfwsrijan

Copy link
Copy Markdown

Description

Binds each session_id to a stable per-caller owner token on first use and rejects subsequent mismatches to prevent cross-session risk trajectory poisoning (and false-positive/false-negative escalations).

This adds session ownership tracking across SQLite/Redis/Postgres backends, enforces ownership in the /chat interceptor (owner token derived from client IP, HMAC-hardened with HUMANE_PROXY_SESSION_SECRET when set), adds programmatic support via optional owner_token parameters, and includes tests plus security documentation.

Fixes #24.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Checklist

  • I have read the CONTRIBUTING guide
  • My code follows the project's style
  • I have added tests for new or changed behaviour
  • All tests pass (pytest tests/ -v)
  • I have updated documentation if needed
  • Self-harm / safety-related changes have been reviewed for sensitivity

@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

Release Notes

  • New Features

    • Session ownership binding with owner token validation to isolate user sessions in multi-tenant deployments
    • Configurable session secret hardening via environment variable for improved security
  • Documentation

    • Added security policy documenting vulnerability reporting procedures and operator guidance for session ownership configuration

Walkthrough

This PR implements session ownership binding to prevent cross-session trajectory poisoning in multi-tenant deployments. A session_id is now bound to an owner token (derived from client IP or HMAC-hardened) at creation and validated on all subsequent access. Storage backends (SQLite, Redis, Postgres) persist ownership; the HTTP middleware derives tokens and enforces them; and public APIs and MCP tools optionally accept owner tokens for programmatic use.

Changes

Session Ownership Binding for Multi-Tenant Safety

Layer / File(s) Summary
Error definitions and storage interface contract
humane_proxy/errors.py, humane_proxy/storage/base.py
New exception types (HumaneProxyError, SessionOwnershipError) and three abstract methods on EscalationStore: get_session_owner, set_session_owner, assert_session_owner.
SQLite storage ownership implementation
humane_proxy/storage/sqlite.py
Creates sessions table during init; implements ownership methods with INSERT-ON-CONFLICT-DO-NOTHING for first-write binding; delete_session now removes ownership records.
Redis storage ownership implementation
humane_proxy/storage/redis.py
Uses per-session owner:{session_id} keys with SET-NX first-writer-wins pattern; ownership keys are deleted during session cleanup.
Postgres storage ownership implementation
humane_proxy/storage/postgres.py
Adds sessions table (indexed by session_id) for ownership storage; implements lookup, binding, and assertion methods with SessionOwnershipError on mismatch.
Escalation handlers with ownership validation
humane_proxy/escalation/local_db.py
check_rate_limit and log_escalation now accept optional owner_token and validate ownership via store.assert_session_owner before proceeding.
Escalation routing with ownership threading
humane_proxy/escalation/router.py
escalate function signature adds owner_token and threads it through both check_rate_limit and log_escalation calls.
HTTP middleware token derivation and enforcement
humane_proxy/middleware/interceptor.py
New _owner_token_for_request helper computes stable tokens using HMAC-SHA256 over client IP (when HUMANE_PROXY_SESSION_SECRET is set) or IP hash fallback. /chat endpoint derives token, enforces ownership before classification (HTTP 403 on mismatch), and includes token in escalation calls.
Main API entry points
humane_proxy/__init__.py
HumaneProxy.check and check_async methods add optional owner_token keyword parameter; when provided, both validate ownership before running safety classification.
MCP server safety check
humane_proxy/mcp_server.py
check_message_safety tool adds optional owner_token parameter and validates ownership when provided.
Tests and security documentation
SECURITY.md, tests/test_session_ownership.py
SECURITY.md documents the threat and operator configuration; test suite verifies ownership idempotence, mismatch detection, and session rebinding after deletion.

Sequence Diagram

sequenceDiagram
  participant Client as Client
  participant Middleware as HTTP Middleware
  participant Store as Session Store
  participant Escalation as Escalation Router
  
  Client->>Middleware: POST /chat (session_id, message)
  Middleware->>Middleware: Derive owner_token from client IP + secret
  Middleware->>Store: assert_session_owner(session_id, owner_token)
  alt First write
    Store->>Store: set_session_owner(session_id, owner_token)
  else Ownership mismatch
    Store-->>Middleware: SessionOwnershipError
    Middleware-->>Client: HTTP 403
  end
  Middleware->>Escalation: check_rate_limit(session_id, owner_token)
  Escalation->>Store: assert_session_owner(session_id, owner_token)
  Escalation->>Store: check_rate_limit(session_id)
  Middleware->>Middleware: classify_sync(message)
  alt High risk detected
    Middleware->>Escalation: escalate(..., owner_token)
    Escalation->>Store: assert_session_owner(session_id, owner_token)
    Escalation->>Store: log_escalation(session_id, ..., owner_token)
  end
  Middleware-->>Client: classification result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 Behold! A session now knows its keeper—
No thief shall claim another's deep-weighted heap.
With HMAC-guarded tokens and storage aligned,
Cross-session hijacks fall to the design.
Multi-tenant trust blooms, at last, secure. 🔐

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 24.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: binding session_id to caller ownership for security. It directly relates to the core objective of preventing cross-session risk trajectory poisoning.
Description check ✅ Passed The description comprehensively explains the security fix, implementation scope, and testing. It clearly relates to the changeset and linked issue #24.
Linked Issues check ✅ Passed All coding requirements from issue #24 are met: session ownership binding [#24], storage backend support (SQLite/Redis/Postgres) [#24], interceptor enforcement [#24], programmatic owner_token support [#24], and documentation [#24].
Out of Scope Changes check ✅ Passed All changes are directly aligned with issue #24's requirements: session ownership tracking, storage layer modifications, ownership validation, and documentation. No unrelated changes detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

except SessionOwnershipError as exc:
return JSONResponse(
status_code=403,
content={"status": "error", "message": str(exc), "session_id": session_id},

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
humane_proxy/mcp_server.py (1)

82-96: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Document the owner_token parameter.

The docstring describes message and session_id but omits the new owner_token parameter. MCP tool parameters are exposed to clients, so documenting this optional ownership token would improve discoverability.

📝 Proposed docstring addition
         Parameters
         ----------
         message:
             The user message to classify.
         session_id:
             Optional session identifier for trajectory tracking.
+        owner_token:
+            Optional token to validate session ownership. When provided,
+            the session must be bound to this token or the call will fail.
 
         Returns
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@humane_proxy/mcp_server.py` around lines 82 - 96, Add documentation for the
new owner_token parameter to the method that classifies messages (the "Classify
a message for self-harm or criminal intent." docstring—likely the
classify_message function/method). Under the Parameters section, add an entry
for owner_token (e.g., owner_token: Optional[str]) describing that it is an
optional ownership/auth token used for tracking or access control, that it
defaults to None, and any privacy/validation notes needed for clients; keep the
rest of the docstring format consistent with existing entries so the MCP tool
exposes this parameter to clients.
🧹 Nitpick comments (1)
tests/test_session_ownership.py (1)

32-34: ⚡ Quick win

Add a post-rebind mismatch assertion to harden the regression signal.

After Line 33, assert that owner-a is rejected for sid-2 again. This verifies “delete then rebind” still restores strict mismatch enforcement, not just successful rebinding.

Suggested test addition
 def test_delete_session_clears_owner(tmp_path):
@@
     # After delete, the session can be re-bound to a new owner.
     store.assert_session_owner("sid-2", "owner-b")
+    with pytest.raises(SessionOwnershipError):
+        store.assert_session_owner("sid-2", "owner-a")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_session_ownership.py` around lines 32 - 34, Add a post-rebind
mismatch assertion to ensure "owner-a" is rejected for "sid-2" after the
delete+rebind sequence: after the existing store.assert_session_owner("sid-2",
"owner-b") call, add a call to the test helper that asserts a mismatch for the
previous owner (e.g. store.assert_session_mismatch("sid-2", "owner-a") or the
equivalent helper in your test suite) so the test verifies strict mismatch
enforcement is restored; locate this near the existing
store.assert_session_owner assertion in tests/test_session_ownership.py.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@humane_proxy/middleware/interceptor.py`:
- Around line 113-116: The JSONResponse in interceptor.py currently returns the
raw exception string (str(exc)) which can leak sensitive internals; change the
response to use a generic error message (e.g., "Internal server error" or
"Access denied") instead of str(exc) and ensure the real exception is recorded
only in server logs by calling the module logger (e.g., logger.exception or
logger.error with exc_info) including session_id for correlation; update the
code path that constructs the JSONResponse (the return that references
JSONResponse, exc and session_id) to remove exposure of exc to clients and log
the full exception internally.

In `@humane_proxy/storage/base.py`:
- Around line 27-37: Update the storage interface to enforce a strict,
non-overwrite ownership contract: ensure set_session_owner(session_id,
owner_token) is specified to persist the owner only if the session has no owner
yet (first-write wins, must be idempotent) and that
assert_session_owner(session_id, owner_token) raises a single, well-defined
exception type (SessionOwnershipError) on any mismatch; document that backends
must persist the first owner and never replace it, and must throw
SessionOwnershipError for mismatches so all implementations behave identically.

In `@SECURITY.md`:
- Around line 15-20: Update the SECURITY.md section that describes the
IP-derived owner token (used for the built-in HTTP proxy POST /chat and hardened
with HUMANE_PROXY_SESSION_SECRET) to explicitly document its limitations: note
that deriving owner tokens from client IPs can collapse distinct users behind
shared IPs/NAT and weaken isolation, recommend using explicit/app-level owner
tokens or random session_id values for stronger identity, and add guidance to
only trust vetted proxy headers (and document how to configure trusted proxies)
when deriving client IPs so operators don’t rely on untrusted headers.

---

Outside diff comments:
In `@humane_proxy/mcp_server.py`:
- Around line 82-96: Add documentation for the new owner_token parameter to the
method that classifies messages (the "Classify a message for self-harm or
criminal intent." docstring—likely the classify_message function/method). Under
the Parameters section, add an entry for owner_token (e.g., owner_token:
Optional[str]) describing that it is an optional ownership/auth token used for
tracking or access control, that it defaults to None, and any privacy/validation
notes needed for clients; keep the rest of the docstring format consistent with
existing entries so the MCP tool exposes this parameter to clients.

---

Nitpick comments:
In `@tests/test_session_ownership.py`:
- Around line 32-34: Add a post-rebind mismatch assertion to ensure "owner-a" is
rejected for "sid-2" after the delete+rebind sequence: after the existing
store.assert_session_owner("sid-2", "owner-b") call, add a call to the test
helper that asserts a mismatch for the previous owner (e.g.
store.assert_session_mismatch("sid-2", "owner-a") or the equivalent helper in
your test suite) so the test verifies strict mismatch enforcement is restored;
locate this near the existing store.assert_session_owner assertion in
tests/test_session_ownership.py.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6bcd3d91-c1bc-485f-a363-38086e8426be

📥 Commits

Reviewing files that changed from the base of the PR and between c2e0825 and 9340c15.

📒 Files selected for processing (12)
  • SECURITY.md
  • humane_proxy/__init__.py
  • humane_proxy/errors.py
  • humane_proxy/escalation/local_db.py
  • humane_proxy/escalation/router.py
  • humane_proxy/mcp_server.py
  • humane_proxy/middleware/interceptor.py
  • humane_proxy/storage/base.py
  • humane_proxy/storage/postgres.py
  • humane_proxy/storage/redis.py
  • humane_proxy/storage/sqlite.py
  • tests/test_session_ownership.py

Comment on lines +113 to +116
return JSONResponse(
status_code=403,
content={"status": "error", "message": str(exc), "session_id": session_id},
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid exposing exception details to external users.

The CodeQL warning is valid. Returning str(exc) directly can leak internal error details (e.g., owner token values, session internals) to external callers. Use a generic message instead.

🛡️ Proposed fix
     except SessionOwnershipError as exc:
+        logger.warning("Session ownership mismatch for %s: %s", session_id, exc)
         return JSONResponse(
             status_code=403,
-            content={"status": "error", "message": str(exc), "session_id": session_id},
+            content={"status": "error", "message": "Session ownership mismatch.", "session_id": session_id},
         )
🧰 Tools
🪛 GitHub Check: CodeQL

[warning] 115-115: Information exposure through an exception
Stack trace information flows to this location and may be exposed to an external user.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@humane_proxy/middleware/interceptor.py` around lines 113 - 116, The
JSONResponse in interceptor.py currently returns the raw exception string
(str(exc)) which can leak sensitive internals; change the response to use a
generic error message (e.g., "Internal server error" or "Access denied") instead
of str(exc) and ensure the real exception is recorded only in server logs by
calling the module logger (e.g., logger.exception or logger.error with exc_info)
including session_id for correlation; update the code path that constructs the
JSONResponse (the return that references JSONResponse, exc and session_id) to
remove exposure of exc to clients and log the full exception internally.

Comment on lines +27 to +37
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.
"""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Tighten the ownership contract to prevent backend divergence.

Line 27 and Line 32 define intent, but the interface should explicitly require non-overwrite semantics and a single mismatch exception type (e.g., SessionOwnershipError) so backends cannot drift in behavior.

Proposed contract-doc clarification
     `@abstractmethod`
     def set_session_owner(self, session_id: str, owner_token: str) -> None:
-        """Bind a session to an owner token (first write wins)."""
+        """Bind a session to an owner token (first write wins).
+
+        Must not overwrite an existing different owner token.
+        """
         ...

     `@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.
+        Implementations must atomically check-and-bind ownership:
+        - if no owner exists, persist ``owner_token``
+        - if owner matches, no-op
+        - if owner differs, raise ``SessionOwnershipError``
         """
         ...
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@humane_proxy/storage/base.py` around lines 27 - 37, Update the storage
interface to enforce a strict, non-overwrite ownership contract: ensure
set_session_owner(session_id, owner_token) is specified to persist the owner
only if the session has no owner yet (first-write wins, must be idempotent) and
that assert_session_owner(session_id, owner_token) raises a single, well-defined
exception type (SessionOwnershipError) on any mismatch; document that backends
must persist the first owner and never replace it, and must throw
SessionOwnershipError for mismatches so all implementations behave identically.

Comment thread SECURITY.md
Comment on lines +15 to +20
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Document IP-derived owner token limitations and proxy trust assumptions.

Line 15 currently reads as if IP-based binding is broadly strong. In shared-IP/NAT environments this can collapse multiple callers into one owner token and weaken isolation. Add explicit guidance to use explicit/app-level owner tokens for stronger identity and to only trust vetted proxy headers for client IP derivation.

Suggested doc patch
-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.
+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.
+IP-based ownership is a coarse identity signal: users behind the same NAT/egress IP can share an owner token.
+For stronger per-user isolation, prefer supplying an explicit application-level `owner_token` (for example, stable API key/user identity).
+If running behind a reverse proxy/load balancer, ensure client IP extraction uses only trusted headers/sources.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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.
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.
IP-based ownership is a coarse identity signal: users behind the same NAT/egress IP can share an owner token.
For stronger per-user isolation, prefer supplying an explicit application-level `owner_token` (for example, stable API key/user identity).
If running behind a reverse proxy/load balancer, ensure client IP extraction uses only trusted headers/sources.
#### 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.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SECURITY.md` around lines 15 - 20, Update the SECURITY.md section that
describes the IP-derived owner token (used for the built-in HTTP proxy POST
/chat and hardened with HUMANE_PROXY_SESSION_SECRET) to explicitly document its
limitations: note that deriving owner tokens from client IPs can collapse
distinct users behind shared IPs/NAT and weaken isolation, recommend using
explicit/app-level owner tokens or random session_id values for stronger
identity, and add guidance to only trust vetted proxy headers (and document how
to configure trusted proxies) when deriving client IPs so operators don’t rely
on untrusted headers.

@Vishisht16

Copy link
Copy Markdown
Owner

@ionfwsrijan I have some comments regarding this specific issue. First of all, I'll be labelling both this PR and issue #24 as gssoc:ai-slop. I'm providing a few reasons for doing so:

  1. Session ownership isolation can be a valid enhancement in case of multi-tenant deployments but session_id has to be set by the integrating application. Further, the scenarios you provided in the related issue don't exactly work and the threat model is narrow. What you described is a feature you wanted to implement and not a bug.
  2. You raised a PR for an issue I never assigned to you. I often try to understand the whole scenario and the contributor's approach before providing pointers and assigning. You didn't give me time to assign it so I am in no obligation to review a stray PR, but I still did.
  3. Your PR modifies base.py and adds abstract methods to EscalationStore interface, which I had explicitly told another contributor in [Backend] Upgrade Redis Backend for Multi-Process / Distributed Environments #5 to not touch. This is a change that breaks the interface and I wouldn't have approved it.
  4. Your PR introduces a new security vulnerability. CodeQL flagged interceptor.py for returning str(exc) directly to the client.
  5. You didn't address CodeRabbit or CodeQL review comments on the PR even once since raising it but kept pushing me on GitHub and Discord to quickly merge the PR to get 'good points'.
  6. Your issue states documenting the setup in SECURITY.md, which already lies in .github/. But you still added another generic SECURITY.md at root-level without explicit permission. Project governance files should not be touched without prior discussion with the maintainer.

These points make me come to the understanding that you don't understand the codebase and were not making legitimate contributions. I've concluded that the contribution was made using AI agents without any human supervision. For this reason, the PR, issue and your username have been forwarded to GSSoC. You may take up the matter with their team if you wish to do so.

@Vishisht16 Vishisht16 closed this May 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gssoc:ai-slop AI slop in PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Security/Bug] Session Risk Trajectory Poisoning via Cross-Session session_id Collision

3 participants