Skip to content

fix(channels,ssrf): DNS-rebind, chunk loss, journal stall, lag drops#4104

Merged
houko merged 9 commits into
mainfrom
fix/channel-reliability-ssrf
Apr 30, 2026
Merged

fix(channels,ssrf): DNS-rebind, chunk loss, journal stall, lag drops#4104
houko merged 9 commits into
mainfrom
fix/channel-reliability-ssrf

Conversation

@houko

@houko houko commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Four reliability/security fixes for channel + event-bus paths.

  • WebhookStore allows unresolved domains — DNS-rebind / metadata SSRF #3701 — WebhookStore DNS-rebind / metadata SSRF. Added validate_webhook_url_resolved which performs a DNS lookup at fire-time and rejects any resolved address in private / loopback / link-local / cloud-metadata ranges. Wired into the test-webhook fetch site (routes/system.rs). Registration-time check stays as the cheap literal guard. IPv6 ULA + multicast + link-local are now also caught at the literal level (the old is_private_ip(IpAddr::V6) → false arm was a hole).
  • Telegram chunked outbound silently drops chunks 2..N on send failure #3664 — Telegram chunked outbound silently dropping chunks 2..N. api_send_message now propagates Err with the failing chunk index when a chunk POST returns non-2xx (and the plain-text fallback also fails), or when the network call errors. send_streaming's continuation loop returns the failure to its caller instead of breaking and continuing as if delivery succeeded.
  • message_journal holds tokio::Mutex across sync std::fs writes — every channel intake serialises #3646 — message_journal compact() holding tokio::Mutex across sync std::fs. Wrapped File::create + writeln! + flush + sync_all + rename in tokio::task::spawn_blocking so the reactor isn't pinned while the kernel fsyncs the temp file; channel intake no longer serializes behind the compactor. (record and update_status already used spawn_blocking; compact was the missing one.)
  • EventBus broadcast::channel(1024) silently drops trigger-driving events under burst #3630 — EventBus silent lag drops. Added EventBus::record_consumer_lag and a free recv_event_skipping_lag helper. Consumers that receive from subscribe_agent / subscribe_all can now route RecvError::Lagged(n) through record_consumer_lag to get an error! log + dropped_count increment, instead of silently skipping. Capacity bumps from a previous round (1024→4096 global, 256→2048 per-agent) stay in place.

Closes #3701
Closes #3664
Closes #3646
Closes #3630

Test plan

  • Existing webhook_store::tests green; new validate_webhook_url_resolved_* tests cover loopback / metadata literal / localhost hostname / IPv6 ULA literal.
  • Existing telegram tests green; chunk-loss path now returns Err with chunk index — verifiable by stubbing client.post and asserting the error message contains chunk N/M.
  • Existing message_journal tests green; compact still rewrites the file under temp dir.
  • New event_bus::record_consumer_lag_increments_dropped_count covers the counter accounting.

…rops

- WebhookStore: add validate_webhook_url_resolved that DNS-resolves the
  host and rejects any A/AAAA pointing at private/loopback/link-local /
  metadata, called at fire-time so DNS rebind (#3701) cannot reach IMDS
  or RFC 1918 even after registration. IPv6 ULA + multicast + link-local
  are now also caught at the literal level. Closes #3701.

- Telegram api_send_message: chunk N failure now propagates as Err with
  chunk index in the message instead of silently sending only chunk 1
  and dropping the rest; the streaming continuation loop also surfaces
  the failure to its caller so retry / alerting can react. Closes #3664.

- message_journal compact(): wrap sync File::create + flush + sync_all
  + rename in spawn_blocking so the inner tokio::Mutex doesn't pin the
  reactor while the kernel fsyncs — channel intake no longer serializes
  behind the compactor. Closes #3646.

- event_bus: add record_consumer_lag + recv_event_skipping_lag helper
  that turns broadcast RecvError::Lagged into an error! log routed
  through dropped_count, so lag-induced trigger misses stop being
  silent. Closes #3630.
@github-actions github-actions Bot added area/channels Messaging channel adapters area/kernel Core kernel (scheduling, RBAC, workflows) size/L 250-999 lines changed labels Apr 29, 2026
houko added a commit that referenced this pull request Apr 29, 2026
Three independent inbound-side / outbound-side input-safety bypasses
batched into one PR per request, following PR-K #4099 / #4100 / #4104.

### MCP OAuth — token/registration endpoints not SSRF-validated (#3623)

`parse_authorization_server_metadata` already ran a literal
`is_ssrf_blocked_host` check against discovered endpoints, but:

* `is_ssrf_blocked_url` did not reject userinfo-bearing URLs, so
  `http://[email protected]/` would pass the host check
  while reqwest connected to the IMDS literal — same shape as #3527.
* `KernelOAuthProvider::register_client` POSTed to the
  registration endpoint with no SSRF re-check; the parser's check
  could be bypassed if the value reached `register_client` from a
  cached vault entry written before policy tightened.
* The API-layer auth-code exchange in `routes/mcp_auth.rs` POSTed
  to the stored `token_endpoint` from the vault with no re-check —
  the kernel `try_refresh` already had the matching guard.

Fix: tighten `is_ssrf_blocked_url` to also reject non-http/https
schemes and any URL with userinfo, then re-validate at every actual
outbound site (parser, register_client, auth-code exchange) so the
guard is uniform whether the value came from discovery or vault.

### Channels — attachment URLs fetched server-side without SSRF (#3442)

`bridge::download_file_to_blocks` and `download_image_to_blocks`
both validated only the URL scheme.  A forged inbound message could
smuggle `http://169.254.169.254/latest/meta-data/...` or
`http://127.0.0.1:4545/api/agents` and have the response base64'd
into the agent's LLM context.

Fix: route both paths through the existing
`http_client::validate_url_for_fetch` SSRF guard (same one the
generic webhook adapter uses), which rejects loopback / private /
link-local / unique-local / multicast / IPv4-mapped IPv6 / NAT64 /
internal hostnames.

### DingTalk — replay window too wide + silent-empty-secret (#3441)

The non-numeric-timestamp bypass was already closed (parser early
returns 400). Two remaining issues:

* Replay window was 1 hour — DingTalk signs with current millis,
  so anything wider than a few minutes is pure replay surface.
  Tightened to ±5 min.
* `read_token(secret_env).unwrap_or_default()` accepted an empty
  signing secret silently — verification would still reject (HMAC
  with empty key vs. a real signature never matches), but the
  operator got no signal.  Now refuses to register the adapter
  with a loud `error!` log when the secret env is unset or empty,
  matching the Teams default-deny shape from #4100.

Closes #3623
Closes #3442
Closes #3441
github-actions Bot and others added 6 commits April 29, 2026 19:05
Two follow-ups from review of #4104:

* validate_webhook_url_resolved now returns the validated SocketAddr
  alongside the host, and test_webhook pins reqwest to that address via
  ClientBuilder::resolve. Without pinning, reqwest performs its own DNS
  lookup before connecting, and a low-TTL record can flip between our
  validate call and reqwest's lookup — the canonical DNS-rebind bypass
  that #3701 exists to close. Pinning forces the eventual connection to
  the IP we already proved safe.

* message_journal::compact() now drops the inner mutex *before* the
  spawn_blocking await. The lock was previously held across the entire
  fsync window, so channel intake still serialized behind the
  compactor — same stall as #3646, just on the blocking thread instead
  of the runtime thread. Cloning path + entries out and dropping the
  guard is the actual fix.

Note: the review also flagged dispatch-path coverage and the
recv_event_skipping_lag helper as dead code. The webhook subscription
store has no separate dispatcher in this codebase — test_webhook is
the only call site that actually POSTs to a webhook URL — so the
"dispatch path" concern was misdirected. Migrating Lagged consumers
is broader than this PR's scope and is left to a follow-up.
After the prior #3646 follow-up dropped the inner mutex before the
spawn_blocking fsync, compact() and record() could interleave:

  1. compact() snapshots pending = {A,B,C} and drops the lock.
  2. compact() begins writing A,B,C to tmp.jsonl.
  3. record(D) takes the lock, appends D to journal.jsonl on disk,
     inserts D into pending, drops the lock.
  4. compact() finishes the slow fsync and rename(tmp, journal.jsonl)
     — overwriting D's appended line on disk while D remains in memory.
  5. Crash before D is re-appended: D is permanently lost on recovery.

This was exactly the truncation race the #3967 audit established
record()'s "lock-held-across-disk-write" invariant to prevent.

Fix: keep the slow work (File::create + flush + sync_all) off the
mutex so intake is not stalled (the goal of #3646), but re-acquire
the lock before the rename and check whether any new ids were
appended to pending since the snapshot. If so, abort this compaction
(retry next tick) and clean up the stale tmp file. Only when no
record() interleaved do we proceed with rename — and the lock then
held also serializes against any record() that arrives during the
rename itself.

Adds compact_does_not_lose_entries_appended_during_window regression
test that exercises the abort path.
forward_kernel_events used to swallow RecvError::Lagged with just a
warn! and an internal `n` counter that was per-listener and never
surfaced anywhere. The new EventBus::record_consumer_lag (added in
this PR) gives us a global counter + rate-limited error! log, which
is what #3630 wanted.

Migrate forward_kernel_events to use recv_event_skipping_lag, threading
&Arc<LibreFangKernel> through both call sites (lib.rs direct-boot path
and connection.rs local-server path) so the helper has the EventBus
reference it needs.

The other three Lagged handlers in the codebase don't fit this helper:
- bridge.rs uses KernelHandle (trait); plumbing EventBus through the
  trait would leak kernel internals the trait was created to hide.
- context_engine.rs uses runtime's PluginEventBus, not kernel EventBus.
- agents.rs:2485 uses StreamEvent from SessionStreamHub, different bus.
@houko
houko merged commit aa14100 into main Apr 30, 2026
13 checks passed
@houko
houko deleted the fix/channel-reliability-ssrf branch April 30, 2026 11:29
houko added a commit that referenced this pull request Apr 30, 2026
…count (#3630 follow-up)

PR #4104 added EventBus::record_consumer_lag + recv_event_skipping_lag
but only migrated the desktop notification listener — the bridge approval
listener and the runtime on_event hook still swallowed RecvError::Lagged
into per-listener warn! logs that never surfaced in any global counter.

This change finishes the migration for the two remaining sites that
have a sensible bus to record into:

- ChannelBridgeHandle gets a default-no-op `record_consumer_lag` method.
  KernelBridgeAdapter overrides it to forward to EventBus, so bridge.rs
  approval listener lag now contributes to EventBus::dropped_count and
  triggers the rate-limited error! log. Test mocks inherit the no-op
  default, so none of the existing ChannelBridgeHandle impls in tests
  needed changes.

- PluginEventBus gets its own dropped_count + record_consumer_lag,
  mirroring EventBus's design. context_engine on_event listener routes
  Lagged through it instead of the previous per-listener warn!.

Intentionally NOT migrated:

- agents.rs:2485 SSE attach uses StreamEvent from SessionStreamHub which
  is documented as intentionally lossy; routing those drops to error!
  would over-log normal SSE backpressure on slow clients.
houko added a commit that referenced this pull request Apr 30, 2026
* fix(ssrf,channels,mcp): close 3 followup safety gaps

Three independent inbound-side / outbound-side input-safety bypasses
batched into one PR per request, following PR-K #4099 / #4100 / #4104.

### MCP OAuth — token/registration endpoints not SSRF-validated (#3623)

`parse_authorization_server_metadata` already ran a literal
`is_ssrf_blocked_host` check against discovered endpoints, but:

* `is_ssrf_blocked_url` did not reject userinfo-bearing URLs, so
  `http://[email protected]/` would pass the host check
  while reqwest connected to the IMDS literal — same shape as #3527.
* `KernelOAuthProvider::register_client` POSTed to the
  registration endpoint with no SSRF re-check; the parser's check
  could be bypassed if the value reached `register_client` from a
  cached vault entry written before policy tightened.
* The API-layer auth-code exchange in `routes/mcp_auth.rs` POSTed
  to the stored `token_endpoint` from the vault with no re-check —
  the kernel `try_refresh` already had the matching guard.

Fix: tighten `is_ssrf_blocked_url` to also reject non-http/https
schemes and any URL with userinfo, then re-validate at every actual
outbound site (parser, register_client, auth-code exchange) so the
guard is uniform whether the value came from discovery or vault.

### Channels — attachment URLs fetched server-side without SSRF (#3442)

`bridge::download_file_to_blocks` and `download_image_to_blocks`
both validated only the URL scheme.  A forged inbound message could
smuggle `http://169.254.169.254/latest/meta-data/...` or
`http://127.0.0.1:4545/api/agents` and have the response base64'd
into the agent's LLM context.

Fix: route both paths through the existing
`http_client::validate_url_for_fetch` SSRF guard (same one the
generic webhook adapter uses), which rejects loopback / private /
link-local / unique-local / multicast / IPv4-mapped IPv6 / NAT64 /
internal hostnames.

### DingTalk — replay window too wide + silent-empty-secret (#3441)

The non-numeric-timestamp bypass was already closed (parser early
returns 400). Two remaining issues:

* Replay window was 1 hour — DingTalk signs with current millis,
  so anything wider than a few minutes is pure replay surface.
  Tightened to ±5 min.
* `read_token(secret_env).unwrap_or_default()` accepted an empty
  signing secret silently — verification would still reject (HMAC
  with empty key vs. a real signature never matches), but the
  operator got no signal.  Now refuses to register the adapter
  with a loud `error!` log when the secret env is unset or empty,
  matching the Teams default-deny shape from #4100.

Closes #3623
Closes #3442
Closes #3441

* fix(quality): cargo fmt #4110 mcp_oauth_provider.rs + mcp_oauth.rs

* docs(mcp-oauth): correct userinfo-guard rationale

The original doc/test comments claimed `host_str()` returns "allowed.com"
for `http://[email protected]/`, implying the host check was
bypassed.  Verified with the `url` crate (RFC 3986 compliant): host_str()
returns `169.254.169.254` — the IMDS literal — so that URL is already
caught by `is_ssrf_blocked_host`.

The userinfo guard is still worth keeping, but for a different reason:
phishing-shape inputs like `http://[email protected]/` where the
post-`@` host is legitimate and only the userinfo guard rejects.  That
case is also the unique regression point for the new test — flagged
inline so a future refactor that drops the guard will see exactly which
assertion fails.

* fix(ssrf,dingtalk): address review comments from #4110

- mcp_oauth_provider: replace bare reqwest::Client::new() with
  librefang_extensions::http_client::new_client() in both try_refresh
  and register_client so redirect limits and timeouts apply on the
  post-SSRF-check outbound requests.
- dingtalk: change REPLAY_WINDOW_MS to u64 and use saturating_sub
  before unsigned_abs to prevent i64 overflow on a forged extreme
  timestamp; cast eliminated since both sides are now u64.
- channel_bridge: remove duplicate secret_env from the error! format
  string — the value is already captured as a structured field via
  env = %dt_config.secret_env.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
houko added a commit that referenced this pull request Apr 30, 2026
…count (#3630 follow-up)

PR #4104 added EventBus::record_consumer_lag + recv_event_skipping_lag
but only migrated the desktop notification listener — the bridge approval
listener and the runtime on_event hook still swallowed RecvError::Lagged
into per-listener warn! logs that never surfaced in any global counter.

This change finishes the migration for the two remaining sites that
have a sensible bus to record into:

- ChannelBridgeHandle gets a default-no-op `record_consumer_lag` method.
  KernelBridgeAdapter overrides it to forward to EventBus, so bridge.rs
  approval listener lag now contributes to EventBus::dropped_count and
  triggers the rate-limited error! log. Test mocks inherit the no-op
  default, so none of the existing ChannelBridgeHandle impls in tests
  needed changes.

- PluginEventBus gets its own dropped_count + record_consumer_lag,
  mirroring EventBus's design. context_engine on_event listener routes
  Lagged through it instead of the previous per-listener warn!.

Intentionally NOT migrated:

- agents.rs:2485 SSE attach uses StreamEvent from SessionStreamHub which
  is documented as intentionally lossy; routing those drops to error!
  would over-log normal SSE backpressure on slow clients.
houko added a commit that referenced this pull request Apr 30, 2026
* fix(channels,runtime): route remaining broadcast lag through dropped_count (#3630 follow-up)

PR #4104 added EventBus::record_consumer_lag + recv_event_skipping_lag
but only migrated the desktop notification listener — the bridge approval
listener and the runtime on_event hook still swallowed RecvError::Lagged
into per-listener warn! logs that never surfaced in any global counter.

This change finishes the migration for the two remaining sites that
have a sensible bus to record into:

- ChannelBridgeHandle gets a default-no-op `record_consumer_lag` method.
  KernelBridgeAdapter overrides it to forward to EventBus, so bridge.rs
  approval listener lag now contributes to EventBus::dropped_count and
  triggers the rate-limited error! log. Test mocks inherit the no-op
  default, so none of the existing ChannelBridgeHandle impls in tests
  needed changes.

- PluginEventBus gets its own dropped_count + record_consumer_lag,
  mirroring EventBus's design. context_engine on_event listener routes
  Lagged through it instead of the previous per-listener warn!.

Intentionally NOT migrated:

- agents.rs:2485 SSE attach uses StreamEvent from SessionStreamHub which
  is documented as intentionally lossy; routing those drops to error!
  would over-log normal SSE backpressure on slow clients.

* fix(channels,runtime,kernel): plug review gaps in #4152

Self-review of #4152 turned up three issues:

* No test on PluginEventBus::record_consumer_lag — the kernel EventBus
  has one (record_consumer_lag_increments_dropped_count) but the new
  PluginEventBus copy did not, so silent drift between the two impls
  was not caught. Mirror the kernel test.

* last_drop_warn was initialised to Instant::now(), so the FIRST lag
  burst after process start would only bump dropped_count and stay
  silent for the first 10 s — defeating the "make lag visible" goal
  of #3630. Backdate the timestamp by (interval + 1)s so the first
  burst is always logged. Apply the same fix to the kernel EventBus
  for parity (otherwise the two buses behave differently on cold
  start).

* The default no-op record_consumer_lag on ChannelBridgeHandle is
  fine for test mocks but a future production impl would silently
  inherit it. Add a "Production implementations MUST override" line
  to the trait doc, since there is no compiler signal.

Also extracts LAG_WARN_INTERVAL_SECS in PluginEventBus so the rate-
limit window is named in the runtime side as well.

* fix(event-bus): clarify LAG_WARN_INTERVAL_SECS sync + add dropped_count forwarding test
@houko houko mentioned this pull request May 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/channels Messaging channel adapters area/kernel Core kernel (scheduling, RBAC, workflows) size/L 250-999 lines changed

Projects

None yet

1 participant