Skip to content

v0.45.1: SSRF DNS-rebind closure + HTTP transport/SSE audit fixes#173

Merged
vaaraio merged 10 commits into
mainfrom
fix/audit-findings-20260530
May 30, 2026
Merged

v0.45.1: SSRF DNS-rebind closure + HTTP transport/SSE audit fixes#173
vaaraio merged 10 commits into
mainfrom
fix/audit-findings-20260530

Conversation

@vaaraio
Copy link
Copy Markdown
Owner

@vaaraio vaaraio commented May 30, 2026

v0.45.1: audit-finding fixes on the remote HTTP connector, the HTTP transport, and the public numbers

Security

  • SSRF egress floor on the --upstream-url connector. A hostile or compromised upstream (or an attacker-controlled redirect target) could aim the proxy at the cloud instance-metadata service or an internal host and have it fetch the target with the operator's bearer token. _egress_guard now resolves the host and refuses loopback, link-local, RFC1918, IPv6 ULA, and the cloud-metadata address (including dotless and IPv4-mapped encodings) before any socket opens; a guarded opener caps redirects, re-applies the floor to each hop, and drops the auth header on a cross-origin redirect. Default is SAFE; a trusted internal upstream is opted in via --allow-private-upstream-hosts, the allow_private_hosts constructor arg, or VAARA_MCP_ALLOW_PRIVATE_UPSTREAM. The metadata address stays refused even with the opt-in.
  • DNS-rebind closure on that floor. urllib re-resolved at socket-connect, so a time-split rebind (public address at the check, blocked address a moment later) reached the blocked target with the auth header attached. The connector now validates and pins the address at connect time and dials the IP literal, so the address that passed the floor is the exact address the socket reaches; HTTPS still verifies the certificate against the original hostname. Re-applied on every redirect hop. An absent --allow-private-upstream-hosts flag now leaves the env opt-in live instead of shadowing it with a False.

Fixed

  • HTTP transport no longer serialises concurrent requests. POST /mcp ran the blocking _handle_request inline on the event loop (real concurrency 1). It now runs on a worker thread via asyncio.to_thread, with per-request ContextVars preserved across the hop.
  • SSE reconnect race that dropped notifications for the live session. On reconnect under the same Mcp-Session-Id, the old stream's teardown unregistered the new session. unregister_session is now identity-checked.
  • README/llms.txt public-numbers corrections (rule-scorer vs classifier latency labelling, cross-model holdout disclosed).

Verification

  • 1067 passed, 12 skipped, ruff clean
  • 52 dedicated rebind/egress/metadata/SSRF tests pass

Full detail in CHANGELOG.md under [0.45.1].

Summary by CodeRabbit

  • New Features

    • Added security protections for remote HTTP connections against unauthorized access attempts
    • Added option to allow connections to private/internal upstream hosts
    • Improved handling of concurrent requests
  • Bug Fixes

    • Fixed issue where notifications could be lost during reconnection scenarios
    • Fixed redirect handling to prevent authentication credentials from being sent to unintended hosts
  • Documentation

    • Updated performance benchmarks and configuration guidance
    • Refreshed project description to reflect security and attestation capabilities

Review Change Stack

vaaraio and others added 10 commits May 30, 2026 15:27
The dashboard subcommand imported SQLiteAuditTrail, a class the
sqlite_backend module never exported, so `vaara compliance dashboard`
raised ImportError on every invocation. Mirror the working report
subcommand: open SQLiteAuditBackend, load_trail(), and pass the
resulting AuditTrail into the engine. Add CLI-level smoke tests that
exercise the full DB-open-to-HTML-write path, which the existing
render_html-only tests never covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…itignore hardening)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The remote HTTP connector handed a user-supplied upstream URL straight to
urllib.request.urlopen and followed redirects with the static Authorization
header attached. A hostile or compromised upstream, or an attacker who
controls a redirect target, could point the proxy at the cloud
instance-metadata service or an internal RFC1918 host and have it fetch the
target with the operator's bearer token.

Add _egress_guard: a host-resolution floor that refuses loopback, link-local
(IPv4 169.254/16 and IPv6 fe80::/10), RFC1918, IPv6 ULA (fc00::/7), the
cloud-metadata address (incl. its dotless decimal/hex and IPv4-mapped
encodings) before any socket opens. The metadata address is refused
unconditionally. HttpUpstreamClient checks the URL at construction and routes
every request through a guarded OpenerDirector whose redirect handler caps
hops at 3, re-applies the floor to each target, and drops Authorization /
Cookie on a cross-origin redirect.

Default posture is SAFE. A trusted internal upstream is opted in explicitly
via --allow-private-upstream-hosts, the allow_private_hosts constructor arg,
or the VAARA_MCP_ALLOW_PRIVATE_UPSTREAM env flag; the opt-in never reopens the
metadata address or the cross-origin auth drop.

Tests: blocked metadata-IP upstream, blocked private-IP upstream, redirect to
metadata refused mid-flight, Authorization not carried cross-origin, dotless
and IPv4-mapped metadata encodings, opt-in path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The HTTP POST /mcp endpoint called proxy._handle_request(payload) inline. That
is a blocking sync call that waits on the upstream up to its request timeout,
so it parked the event loop for the whole call: real concurrency was 1, and
one slow upstream stalled every other POST /mcp, GET /mcp SSE drain, and
/health.

Run it on a worker thread with asyncio.to_thread. The per-request ContextVars
(_REQUEST_UPSTREAM / _REQUEST_TENANT / _REQUEST_SESSION / _REQUEST_INTENT) are
set on the endpoint task's context, which a bare to_thread target would not
inherit, so capture contextvars.copy_context() after the sets and run the
handler with ctx.run on the worker thread. Upstream selection and tenant
tagging keep working across the hop.

Test: two concurrent POSTs against a deliberately slow upstream overlap in
wall-clock instead of serialising, and each still routes to its own slot,
proving the context survived the thread hop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
unregister_session popped the session map entry by id alone. register_session
replaces the entry with a fresh _SessionState on reconnect and closes the old
one; the old stream's finally then ran unregister_session and popped the NEW
state, silently dropping notifications for the just-reconnected live session.

unregister_session now takes an optional expected state and pops only when the
map entry is still that object. The GET /mcp SSE handler keeps its own
my_state from register_session and passes it as the guard, so a stale teardown
is a no-op and the reconnected session keeps receiving notifications.

Test: register, reconnect under the same id, run the old stream's
identity-checked unregister, and confirm the live session stays registered and
still receives a targeted notification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s-model holdout

The README reported '140 us / 210 us inference latency (excluding one-time
embedding model load)', which implied the MiniLM classifier sits in the
measured path. The only latency artifact (bench/latency_results.json) times
InterceptionPipeline.intercept() with the default rule scorer; the ML
classifier is opt-in via vaara[ml] and is not loaded there. Relabel the figure
as the hot-path rule scorer and note the classifier is out of that path.

The headline also showed only the in-distribution TEST recall. Add the
cross-model held-out number that bench/vaara-bench-v0.37 already publishes:
66.8% over n=2,277 with no eval-set attacker model in TRAIN, and the weakest
sub-cell (data_exfil against a closed-weight model) at 38.9%. The easier
in-distribution denominator stays, now next to the harder one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… evidence

The Numbers block still advertised the v7-era classifier: a 5,955-entry corpus,
97.1% recall at threshold 0.55, and PAIR ASR only against Qwen2.5-32B. None of
that matches the shipped v9 bundle. Regenerate from the current README and
bench docs: 12,155-entry corpus, v9 at threshold 0.9150, held-out TEST recall
84.7% at FPR 4.1%, the 66.8% cross-model held-out number with its 38.9%
weakest sub-cell, BIPIA-pressure FPR 1.2%, and multi-attacker PAIR ASR 0/25
across three attacker models. Latency is relabelled as the hot-path rule
scorer, with the MiniLM classifier marked opt-in and out of that path.

Switch the lede and position line to the tamper-evident runtime evidence
framing the README and pyproject already use. OVERT references stay under
Integrations and Optional as conformance surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…bind safe)

The SSRF egress floor resolved the upstream host and validated its
addresses, then handed the hostname back to urllib, which re-resolved at
socket-connect. A time-split DNS rebind (a name answering with a public
address at the check and a blocked one a moment later) reached the blocked
target with the operator's Authorization header attached.

The guarded opener now installs pinned HTTP/HTTPS handlers that re-resolve,
re-validate, and pin the address at connect time, then dial the IP literal
so no second DNS lookup can occur. HTTPS verifies the certificate against
the original hostname via SNI. The pin runs on the POST path, the standing
GET SSE path, and every redirect hop.

Also: --allow-private-upstream-hosts defaulted to False, which shadowed the
VAARA_MCP_ALLOW_PRIVATE_UPSTREAM env opt-in on the CLI path. It now defaults
to None, so an absent flag leaves the env opt-in live.

Adds time-split rebind regression tests asserting the connector refuses the
rebound address before opening a socket and dials the validated IP literal
rather than the hostname.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nd SSE audit fixes

Stamps the audit-finding fix bundle as 0.45.1: the DNS-rebind closure on
the --upstream-url egress floor (pin the validated IP at connect time),
the HTTP transport concurrency fix, the identity-checked SSE unregister,
and the public-numbers corrections. Full suite 1067 passed, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 30, 2026

📝 Walkthrough

Walkthrough

Version 0.45.1 introduces SSRF egress protections for remote HTTP MCP connectors with DNS rebind defense, fixes concurrent HTTP request serialization via worker threads, resolves SSE reconnect notification loss through identity-guarded session teardown, refactors compliance dashboard to use a backend factory pattern, and publishes updated release documentation with tamper-evidence framing and refreshed evaluation metrics.

Changes

HTTP Remote Upstream Security & Concurrency Fixes

Layer / File(s) Summary
SSRF Egress Guard & DNS Rebind Protection
src/vaara/integrations/_egress_guard.py
New module implements safe HTTP URL validation blocking instance-metadata, loopback, link-local, and private/ULA targets by default. Supports DNS resolution re-checking and dotless-integer decoding detection. pick_egress_ip() returns a single pinned IP to prevent TOCTOU DNS rebinding. Custom _PinnedHTTPConnection/_PinnedHTTPSConnection dial the validated IP while HTTPS preserves hostname for TLS SNI. _GuardedRedirectHandler re-validates each redirect hop and strips auth/cookie headers on cross-origin redirects. Opt-in allow_private flag permits private/loopback but never metadata.
SSE Session Reconnect Identity Guard
src/vaara/integrations/_mcp_notify.py, tests/test_mcp_notify.py
HttpRouter.unregister_session now accepts optional expected parameter for identity-checked teardown. Prevents stale SSE stream cleanup from closing a newly reconnected session on the same session_id. Unit test covers reconnect race: re-registering creates new state, old stream teardown with expected identity does not remove newer state, delivery reaches reconnected session.
HTTP Upstream Client with Egress Guards
src/vaara/integrations/_mcp_upstream_http.py, tests/test_mcp_egress_guard.py, tests/test_mcp_upstream_rebind.py, tests/test_mcp_upstream_http.py
HttpUpstreamClient validates upstream URLs at construction via assert_url_egress_allowed(), routes all HTTP requests (POST RPC and SSE GET) through guarded urllib opener, converts EgressBlocked to ProxyError. Adds allow_private_hosts parameter for opt-in private/loopback access. Comprehensive tests cover metadata/private blocking, opt-in behavior, redirect re-checking, auth stripping, and DNS rebind defense via socket mocking. Loopback fixture enables in-process test servers.
Proxy Concurrency & SSE Teardown Fixes
src/vaara/integrations/mcp_proxy.py, tests/test_http_concurrency.py
VaaraMCPProxy adds allow_private_upstream_hosts option forwarded to HttpUpstreamClient. HTTP POST /mcp handler now runs blocking _handle_request in worker thread via asyncio.to_thread() with contextvars.copy_context() to preserve per-request routing vars, enabling concurrent requests to overlap on event loop. SSE GET /mcp endpoint tracks session state for identity-guarded teardown preventing reconnect races. CLI adds --allow-private-upstream-hosts flag and catches ProxyError for SSRF refusals. Concurrency test verifies parallel timing and correct per-request context routing.
Compliance Dashboard Backend Factory
src/vaara/cli.py, tests/test_compliance_dashboard.py
_cmd_compliance_dashboard refactored to use SQLiteAuditBackend with create_default_engine() factory instead of direct ComplianceEngine() instantiation, loading trail via backend.load_trail() in try/except. End-to-end CLI tests verify HTML report generation with expected markers and clean error exit code for missing database.
Version 0.45.1 Release Notes
CHANGELOG.md, README.md, llms.txt, src/vaara/__init__.py, clients/ts/package.json, pyproject.toml, server.json, .gitignore
Bumped package version across all manifests. CHANGELOG.md documents SSRF guard, HTTP concurrency fix, SSE reconnect fix, and docs corrections. README.md clarified hot-path rule scorer latency and MiniLM classifier opt-in/exclusion from measurement, expanded MCP proxy upstream transport selection docs. llms.txt reframed Vaara as tamper-evident evidence layer with signed attestation/receipts, updated evaluation metrics with new adversarial corpus size, classifier version/threshold, cross-model recall, and benign tool-call FPR. .gitignore expanded to exclude private scratch and local editor configs.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • vaaraio/vaara#172: Directly builds on the Streamable HTTP remote upstream feature by adding SSRF egress guarding and fixing SSE notification routing in the same HTTP proxy codepaths.
  • vaaraio/vaara#165: Modifies the HttpRouter session lifecycle in _mcp_notify.py, providing the foundational SSE notification routing that this PR adds identity guards to.
  • vaaraio/vaara#97: Added the llms.txt file that this PR updates with tamper-evidence framing and refreshed evaluation metrics.

Poem

A rabbit hops through the tunnel of trust,
Checking each wall for DNS dust.
Sessions reconnect without fear of a fall,
Threads race in parallel down the hall. 🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 29.17% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the main changes: SSRF/DNS-rebind security fixes, HTTP transport concurrency fix, and SSE audit fixes, matching the comprehensive changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/audit-findings-20260530

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.

for record in trail._records:
backend.write_record(record)
finally:
backend.close()
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

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

⚠️ Outside diff range comments (1)
src/vaara/integrations/mcp_proxy.py (1)

607-624: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Offload JSON-RPC notifications too.

The new to_thread handoff only covers requests with an id. Notifications still call proxy._handle_client_notification(payload) inline, so a slow upstream notify() can still block the event loop and stall other HTTP traffic on this worker.

Proposed fix
                 if isinstance(payload, dict) and "id" not in payload:
                     try:
-                        proxy._handle_client_notification(payload)
+                        ctx = contextvars.copy_context()
+                        await asyncio.to_thread(
+                            ctx.run, proxy._handle_client_notification, payload,
+                        )
                     except ProxyError:
                         logger.exception("Failed to forward HTTP notification")
                     return Response(status_code=202)
🤖 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 `@src/vaara/integrations/mcp_proxy.py` around lines 607 - 624, The JSON-RPC
notification branch currently calls proxy._handle_client_notification(payload)
inline and can block the event loop; change it to run inside the same
context-backed worker thread pattern used for _handle_request: capture ctx =
contextvars.copy_context() and call await asyncio.to_thread(ctx.run,
proxy._handle_client_notification, payload) instead of the direct call,
preserving the existing ProxyError exception handling and still returning
Response(status_code=202) after scheduling/completing the offloaded call.
🤖 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 `@src/vaara/cli.py`:
- Around line 722-727: The SQLiteAuditBackend instance created by
SQLiteAuditBackend(str(db_path)) is never closed, leaking the DB connection;
update the code to ensure backend is closed after load_trail() whether it
succeeds or raises (e.g., use a with-statement if SQLiteAuditBackend supports
context management or call backend.close() in a finally block after calling
backend.load_trail()), referencing SQLiteAuditBackend and load_trail so the
connection is always released on both success and in the except path.

In `@src/vaara/integrations/_egress_guard.py`:
- Around line 68-86: The current logic lets allow_private bypass the entire
_ip_is_blocked() set; change it so addresses matched by _ip_is_never_allowed(ip)
are rejected unconditionally, and only the private-ish predicate
(_ip_is_privateish or the private/link-local/loopback pieces) is gated by
allow_private. Concretely: wherever allow_private short-circuits calling
_ip_is_blocked, instead first call _ip_is_never_allowed(ip) and return True if
it matches; if not matched then, if allow_private is True, only apply
_ip_is_privateish(ip) to decide allow/deny, otherwise call the full
_ip_is_blocked(ip) (or keep the original checks in _ip_is_blocked). Ensure
_ip_is_blocked itself still contains the full set of checks (including
_is_metadata, is_reserved, is_multicast, is_unspecified) so non-private ranges
remain blocked even when allow_private is set.

---

Outside diff comments:
In `@src/vaara/integrations/mcp_proxy.py`:
- Around line 607-624: The JSON-RPC notification branch currently calls
proxy._handle_client_notification(payload) inline and can block the event loop;
change it to run inside the same context-backed worker thread pattern used for
_handle_request: capture ctx = contextvars.copy_context() and call await
asyncio.to_thread(ctx.run, proxy._handle_client_notification, payload) instead
of the direct call, preserving the existing ProxyError exception handling and
still returning Response(status_code=202) after scheduling/completing the
offloaded call.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 01b1284e-2033-4913-b2a5-3b09f1d5cade

📥 Commits

Reviewing files that changed from the base of the PR and between 0f60949 and c23e904.

📒 Files selected for processing (19)
  • .gitignore
  • CHANGELOG.md
  • README.md
  • clients/ts/package.json
  • llms.txt
  • pyproject.toml
  • server.json
  • src/vaara/__init__.py
  • src/vaara/cli.py
  • src/vaara/integrations/_egress_guard.py
  • src/vaara/integrations/_mcp_notify.py
  • src/vaara/integrations/_mcp_upstream_http.py
  • src/vaara/integrations/mcp_proxy.py
  • tests/test_compliance_dashboard.py
  • tests/test_http_concurrency.py
  • tests/test_mcp_egress_guard.py
  • tests/test_mcp_notify.py
  • tests/test_mcp_upstream_http.py
  • tests/test_mcp_upstream_rebind.py

Comment thread src/vaara/cli.py
Comment on lines +722 to +727
backend = SQLiteAuditBackend(str(db_path))
try:
trail = backend.load_trail()
except Exception as exc:
print(f"failed to load audit trail: {exc}", file=sys.stderr)
return 2
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

Close the SQLite backend after loading the trail.

Line 722 opens SQLiteAuditBackend, but neither the success path nor the load_trail() error path closes it. When main() is invoked in-process, that can leak the connection and leave the SQLite file locked for later operations.

♻️ Proposed fix
-    backend = SQLiteAuditBackend(str(db_path))
-    try:
-        trail = backend.load_trail()
-    except Exception as exc:
-        print(f"failed to load audit trail: {exc}", file=sys.stderr)
-        return 2
+    with SQLiteAuditBackend(str(db_path)) as backend:
+        try:
+            trail = backend.load_trail()
+        except Exception as exc:
+            print(f"failed to load audit trail: {exc}", file=sys.stderr)
+            return 2
📝 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
backend = SQLiteAuditBackend(str(db_path))
try:
trail = backend.load_trail()
except Exception as exc:
print(f"failed to load audit trail: {exc}", file=sys.stderr)
return 2
with SQLiteAuditBackend(str(db_path)) as backend:
try:
trail = backend.load_trail()
except Exception as exc:
print(f"failed to load audit trail: {exc}", file=sys.stderr)
return 2
🤖 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 `@src/vaara/cli.py` around lines 722 - 727, The SQLiteAuditBackend instance
created by SQLiteAuditBackend(str(db_path)) is never closed, leaking the DB
connection; update the code to ensure backend is closed after load_trail()
whether it succeeds or raises (e.g., use a with-statement if SQLiteAuditBackend
supports context management or call backend.close() in a finally block after
calling backend.load_trail()), referencing SQLiteAuditBackend and load_trail so
the connection is always released on both success and in the except path.

Comment on lines +68 to +86
def _ip_is_blocked(ip: ipaddress._BaseAddress) -> bool:
"""True iff this resolved address must never be reached by default.

Covers the metadata addresses plus loopback, link-local (IPv4 169.254/16
and IPv6 fe80::/10), private (RFC1918 and ULA fc00::/7), unspecified,
reserved, and multicast.
"""
mapped = getattr(ip, "ipv4_mapped", None)
if mapped is not None: # ::ffff:a.b.c.d judged on the embedded v4 address
ip = mapped
return (
_is_metadata(ip)
or ip.is_loopback
or ip.is_link_local
or ip.is_private
or ip.is_unspecified
or ip.is_reserved
or ip.is_multicast
)
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

Keep non-private address classes blocked even when allow_private is enabled.

allow_private currently bypasses the full _ip_is_blocked() set, so opting into loopback/RFC1918 also re-opens 0.0.0.0, multicast, and reserved ranges. That is broader than the flag name and the PR contract, and it weakens the egress floor for callers that only intended to trust internal hosts.

Suggested direction
-def _ip_is_blocked(ip: ipaddress._BaseAddress) -> bool:
+def _ip_is_never_allowed(ip: ipaddress._BaseAddress) -> bool:
     mapped = getattr(ip, "ipv4_mapped", None)
     if mapped is not None:
         ip = mapped
     return (
         _is_metadata(ip)
-        or ip.is_loopback
-        or ip.is_link_local
-        or ip.is_private
         or ip.is_unspecified
         or ip.is_reserved
         or ip.is_multicast
     )
+
+
+def _ip_is_privateish(ip: ipaddress._BaseAddress) -> bool:
+    mapped = getattr(ip, "ipv4_mapped", None)
+    if mapped is not None:
+        ip = mapped
+    return ip.is_loopback or ip.is_link_local or ip.is_private

Then reject _ip_is_never_allowed(ip) unconditionally and only gate _ip_is_privateish(ip) behind allow_private.

🤖 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 `@src/vaara/integrations/_egress_guard.py` around lines 68 - 86, The current
logic lets allow_private bypass the entire _ip_is_blocked() set; change it so
addresses matched by _ip_is_never_allowed(ip) are rejected unconditionally, and
only the private-ish predicate (_ip_is_privateish or the
private/link-local/loopback pieces) is gated by allow_private. Concretely:
wherever allow_private short-circuits calling _ip_is_blocked, instead first call
_ip_is_never_allowed(ip) and return True if it matches; if not matched then, if
allow_private is True, only apply _ip_is_privateish(ip) to decide allow/deny,
otherwise call the full _ip_is_blocked(ip) (or keep the original checks in
_ip_is_blocked). Ensure _ip_is_blocked itself still contains the full set of
checks (including _is_metadata, is_reserved, is_multicast, is_unspecified) so
non-private ranges remain blocked even when allow_private is set.

@vaaraio vaaraio merged commit 2a6bb40 into main May 30, 2026
12 checks passed
@vaaraio vaaraio deleted the fix/audit-findings-20260530 branch May 30, 2026 14:04
vaaraio added a commit that referenced this pull request May 30, 2026
…ckend close (#174)

* fix(proxy): tighten egress opt-in, offload notifications, close audit backend

Three findings from the #173 review, fixed before cutting the v0.45.1 release:

- _egress_guard: allow_private bypassed the whole block set, so opting into
  private upstream hosts also re-opened 0.0.0.0, multicast, and reserved
  ranges. The never-routable classes (metadata, unspecified, reserved,
  multicast) are now always refused; only loopback/link-local/private are
  gated by the opt-in. Adds a regression test asserting the opt-in still
  refuses those classes.
- mcp_proxy HTTP transport: the JSON-RPC notification branch still called
  _handle_client_notification inline, so a slow upstream notify() could park
  the event loop the request-path fix just freed. Offloaded to a worker
  thread on the same copied context.
- cli: the three SQLiteAuditBackend call sites (compliance dashboard,
  assess, trail receipt) never closed the backend, leaking the connection
  and locking the DB file under in-process invocation. Context-managed.

1072 passed, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(changelog): record the three #174 hardening fixes under [0.45.1]

Egress opt-in narrowed to the private classes, HTTP notification path
offloaded, and the SQLiteAuditBackend leak in the three CLI trail readers,
all under the existing 0.45.1 security theme.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(registry): bump second server manifest to 0.45.1

server-vaara-server.json was left at 0.45.0 in the release bump; both
registry slots must carry 0.45.1 for mcp-publisher to land the right version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: vaaraio <267591518+vaaraio@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants