Skip to content

fix(channels): close 3 inbound-safety holes (LINE/Teams/email)#4100

Merged
houko merged 3 commits into
mainfrom
fix/channel-input-safety
Apr 29, 2026
Merged

fix(channels): close 3 inbound-safety holes (LINE/Teams/email)#4100
houko merged 3 commits into
mainfrom
fix/channel-input-safety

Conversation

@houko

@houko houko commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Three independent inbound-side input-safety bypasses on channel adapters,
batched into one PR per request.

LINE — empty signature defense in depth (#3439)

The webhook handler already rejects requests missing the
X-Line-Signature header (handler short-circuits to 400), but added a
verifier-level regression test that pins the invariant: an empty or
non-base64 signature string MUST NOT satisfy HMAC. This guards against
someone reverting to the historical unwrap_or("") shape.

Teams — default-deny webhook (#3440)

The Teams webhook quietly disabled signature verification when
security_token_env was unset (security_token_key = None → handler
skips the verify branch entirely). An attacker on the internet could
forge Activity envelopes with arbitrary from.aadObjectId and
tenant, bypassing allowed_tenants.

  • New TeamsConfig.signature_required: bool, default true.
  • Wiring in channel_bridge.rs now refuses to register the adapter
    when the flag is on and no token is decoded — fails loudly in logs
    rather than silently accepting forged inbound activities.
  • Backward-compat: operators can set signature_required = false for
    dev, but the daemon logs an error and the schema description marks
    it dev-only.
  • OpenClaw migrate emits a warning so imported configs do not silently
    regress on first start.

The Viber half of #3440 was already addressed by the prior
mandatory webhook HMAC verification PRs (cd3d1ceb, bca800c8):
Viber today demands X-Viber-Content-Signature + HMAC-SHA256(token)
and rejects missing/invalid headers with 400/401 before parsing.

Email — exact-match allowlist (#3463)

allowed_senders.iter().any(|s| from_addr.contains(s)) lets an attacker
register a sibling domain and bypass: [email protected]
contains the substring @example.com. Replaced with a parse-and-anchor
matcher:

  • @example.com matches if the parsed sender domain equals
    example.com (case-insensitive).
  • [email protected] matches only if the full address is identical.
  • Malformed addresses (malformed, trailing@) and the documented
    spoofs are rejected by tests.

Test plan

  • cargo test -p librefang-channels (unit tests for
    sender_matches_allowlist, LINE empty-sig regression, existing
    Teams/Viber sig tests)
  • cargo test -p librefang-types (TeamsConfig default invariant)
  • Reviewer: confirm OpenClaw migration warning appears for
    imported Teams configs
  • Reviewer: confirm new schema field round-trips through golden

Closes #3439
Closes #3440
Closes #3463

Three independent input-side bypasses on the inbound channel surface:

* LINE webhook (#3439): add a verifier-level regression test pinning
  that empty / non-base64 X-Line-Signature values can never satisfy HMAC.
  Defense in depth on top of the existing missing-header reject.

* Teams webhook (#3440): default-deny on missing security_token. New
  `TeamsConfig.signature_required: bool` (default `true`) makes the
  channel_bridge wiring refuse adapter startup when the env var is
  unset. Operators can opt out with `signature_required = false` for
  dev, but it is loud in logs. OpenClaw migrate emits a warning so
  imported configs do not silently regress.

* Email allowed_senders (#3463): replace `from_addr.contains(s)` with
  a parse-and-anchor matcher. Entries beginning with `@` match the
  sender's domain exactly (case-insensitive); other entries are full
  addresses compared case-insensitively. Adds tests covering the
  reported sibling-domain spoof (`@example.com` must reject
  `[email protected]` and `[email protected]`).

Closes #3439
Closes #3440
Closes #3463
@github-actions github-actions Bot added size/M 50-249 lines changed area/channels Messaging channel adapters labels Apr 29, 2026
houko added 2 commits April 29, 2026 20:53
…alid base64

The previous default-deny only checked is_empty(). If the user set the
env var to a non-empty but invalid-base64 string (or whitespace), or to
something that base64-decodes to zero bytes, TeamsAdapter::new logs a
warning and falls back to security_token_key=None. The webhook handler
then skips HMAC verification entirely — exactly the bypass
signature_required=true was meant to close.

Trial-decode in channel_bridge before constructing TeamsAdapter and
refuse adapter registration on any decode failure or empty-key result.
@houko
houko merged commit 80b8bdd into main Apr 29, 2026
19 checks passed
@houko
houko deleted the fix/channel-input-safety branch April 29, 2026 12:25
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
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 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 size/M 50-249 lines changed

Projects

None yet

1 participant