fix(ssrf,channels,mcp): close 3 followup safety gaps#4110
Merged
Conversation
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
force-pushed
the
fix/ssrf-signature-followup
branch
from
April 29, 2026 14:37
3604771 to
fa1c95e
Compare
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.
- 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.
2 tasks
houko
added a commit
that referenced
this pull request
Apr 30, 2026
…4156) * docs(mcp-oauth): align parser test + SECURITY note with userinfo fix Followup to PR #4110. That PR's first round of comment cleanup landed, but two stragglers in the same file kept the older (and incorrect) explanation of the userinfo guard: * `parse_authorization_server_metadata`'s SECURITY note still pointed at `is_ssrf_blocked_host`, even though the parser now goes through `is_ssrf_blocked_url` (which adds scheme + userinfo on top). * `parse_authorization_server_metadata_rejects_userinfo_endpoint` used `https://[email protected]/authorize`, which is also caught by the host check (loopback after the @) — so removing the userinfo guard would not actually fail the test. Switched to `https://[email protected]/authorize` so the assertion fires ONLY on the userinfo path, matching the regression-isolation shape of `is_ssrf_blocked_url_rejects_userinfo`. No behaviour change. Comments + one test URL only. * docs(mcp-oauth): drop "resolves" from SECURITY note (no DNS check) `is_ssrf_blocked_host` matches the literal host string only — IP literals are parsed and range-checked, hostnames are matched against a fixed list (`localhost`, `metadata.google.internal`). No DNS resolution happens. The earlier wording "any host that resolves into a loopback…" implied the opposite and contradicted the intentional "DNS resolution is out of scope" stance documented elsewhere in the file. Re-worded to make the literal-only behaviour explicit and to call out IPv4-mapped IPv6 / NAT64 / IMDS — those are part of the actual guard but were not mentioned. Added an explicit "DNS resolution out of scope; mitigate rebinding at the network layer" line so the contract is unambiguous. * docs(mcp-oauth): drop "resolves" from SSRF helpers + complete the range list Two more places in this file used "resolves to..." wording for the SSRF helpers, even though neither helper does any DNS resolution: * `is_ssrf_blocked_host`: said "resolves to a network range" — the function only matches the literal host string against IP ranges and a fixed hostname list. Reworded, and added the missing IPv4-mapped IPv6 + NAT64 entries to the blocked-values list (they exist in the impl via `ipv6_embedded_ipv4`, but were not surfaced in the doc summary). * `well_known_url`: said "the origin resolves to" — same fix. Both now spell out "no DNS resolution; mitigate rebinding at the network layer" so the contract is self-consistent across helpers. No behaviour change.
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 input-safety gaps batched into one PR, following the
shape of PR-K #4099 / #4100 / #4104.
MCP OAuth — token/registration endpoints (#3623)
parse_authorization_server_metadataalready ran a literalis_ssrf_blocked_hostcheck against discovered endpoints, but:is_ssrf_blocked_urldid not reject userinfo URLs — sohttp://[email protected]/would pass the host checkwhile reqwest connected to the IMDS literal (same shape as WASM host_net_fetch URL parser ignores userinfo — SSRF bypass to IMDS / loopback #3527).
KernelOAuthProvider::register_clientPOSTed to the registrationendpoint with no SSRF re-check, even though the value could come
from a cached vault entry written before policy tightened.
routes/mcp_auth.rsPOSTedto the stored
token_endpointfrom the vault with no re-check —the kernel's
try_refreshalready had the matching guard.Fix: tighten
is_ssrf_blocked_urlto reject non-http/https schemesand any URL with userinfo, then re-validate at every outbound site
(parser,
register_client, auth-code exchange).Channels — attachment URLs (#3442)
bridge::download_file_to_blocksanddownload_image_to_blocksvalidated only URL scheme. A forged inbound message could smuggle
http://169.254.169.254/latest/meta-data/...orhttp://127.0.0.1:4545/api/agentsand have the response base64'dinto the agent's LLM context.
Fix: route both paths through the existing
http_client::validate_url_for_fetchSSRF guard already used by thegeneric webhook adapter — rejects loopback / private / link-local /
unique-local / multicast / IPv4-mapped IPv6 / NAT64 / internal
hostnames.
DingTalk — replay window + silent-empty-secret (#3441)
The non-numeric-timestamp bypass itself was already closed (parser
early returns 400). Two remaining issues:
so anything wider than a few minutes is pure replay surface.
Tightened to ±5 min.
read_token(secret_env).unwrap_or_default()accepted an emptysigning secret silently — verification still rejects (HMAC of an
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 theTeams default-deny shape from fix(channels): close 3 inbound-safety holes (LINE/Teams/email) #4100.
Closes #3623
Closes #3442
Closes #3441
Test plan
is_ssrf_blocked_url_rejects_userinfo—http://[email protected]/,http://user:[email protected]/x,http://[email protected]/is_ssrf_blocked_url_rejects_non_http_schemes— file/ftp/gopherparse_authorization_server_metadata_rejects_userinfo_endpointparse_authorization_server_metadata_rejects_imds_registration_endpointtest_validate_url_scheme_blocks_ssrf_targets— 8 SSRF targetson the channel attachment path
validate_url_scheme,is_ssrf_blocked_host,well_known_url,validate_metadata_endpointsstay greensecret_envunsetsee the new
error!log at boot rather than silent dropcargo build/cargo test/cargo clippy