fix(channels): close 3 inbound-safety holes (LINE/Teams/email)#4100
Merged
Conversation
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
…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.
4 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-Signatureheader (handler short-circuits to400), but added averifier-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_envwas unset (security_token_key = None→ handlerskips the verify branch entirely). An attacker on the internet could
forge
Activityenvelopes with arbitraryfrom.aadObjectIdandtenant, bypassingallowed_tenants.TeamsConfig.signature_required: bool, defaulttrue.channel_bridge.rsnow refuses to register the adapterwhen the flag is on and no token is decoded — fails loudly in logs
rather than silently accepting forged inbound activities.
signature_required = falsefordev, but the daemon logs an
errorand the schema description marksit dev-only.
regress on first start.
Email — exact-match allowlist (#3463)
allowed_senders.iter().any(|s| from_addr.contains(s))lets an attackerregister a sibling domain and bypass:
[email protected]contains the substring
@example.com. Replaced with a parse-and-anchormatcher:
@example.commatches if the parsed sender domain equalsexample.com(case-insensitive).[email protected]matches only if the full address is identical.malformed,trailing@) and the documentedspoofs are rejected by tests.
Test plan
cargo test -p librefang-channels(unit tests forsender_matches_allowlist, LINE empty-sig regression, existingTeams/Viber sig tests)
cargo test -p librefang-types(TeamsConfig default invariant)imported Teams configs
Closes #3439
Closes #3440
Closes #3463