fix(channels): mandatory webhook HMAC verification + SSRF guard#3942
Merged
Conversation
…/Teams/Viber/DingTalk; SSRF guard on attachment URLs and WebhookStore #3438 — Messenger: add HMAC-SHA1 X-Hub-Signature verification using app_secret. Missing header → 403. Bad HMAC → 403. No app_secret configured → loud warning, skip check (backwards compat). New MessengerConfig.app_secret_env field (default MESSENGER_APP_SECRET). POST handler now reads raw Bytes before deserializing JSON so the HMAC is over the wire body. #3439 — LINE: make X-Line-Signature mandatory. Missing header was silently accepted; now returns HTTP 400 Bad Request. Present-but-invalid signature continues to return 401 Unauthorized. #3440 — Teams: add HMAC-SHA256 verification of the Authorization: HMAC <b64> header using the outgoing webhook security token (base64-encoded key from the Teams portal). Missing header → 401. Bad HMAC → 401. No security_token → loud warning, skip check. New TeamsConfig.security_token_env field (default TEAMS_SECURITY_TOKEN). #3440 — Viber: add HMAC-SHA256 verification of X-Viber-Content-Signature header using the auth_token. Missing header → 403. Bad HMAC → 403. #3441 — DingTalk: timestamp parse failure is now a hard HTTP 400 error instead of silently skipping HMAC verification. Empty or non-numeric timestamp header always rejects the request. #3442 / #3701 — SSRF guard: add validate_url_for_fetch() to http_client.rs. Rejects non-http/https schemes and IP literals / hostnames in loopback (127/::1), RFC-1918 private (10/172.16-31/192.168), link-local (169.254/fe80::), unique-local (fc00::/7), and well-known metadata-service ranges. WebhookAdapter::new() now returns Result<Self, String>; construction fails if callback_url resolves to a private host. channel_bridge.rs logs an error and skips the adapter on rejection. Closes #3438, #3439, #3440, #3441, #3442, #3701
short-form bypass, dedup HMAC compare Self-review of feccd09 surfaced two real defects that would have shipped silently, plus several engineering loose ends. All addressed in one pass. # Bugs ## LINE: HMAC verified the wrong bytes The handler took `axum::extract::Json<Value>` and then re-serialized via `serde_json::to_vec(&body.0)` to hand to the HMAC verifier. Once a body has been parsed into `serde_json::Value` it has lost the information needed to round-trip back to the original bytes — at least key order (`Value::Object` is alphabetised) and whitespace. LINE's HMAC covers the wire bytes, so the round-tripped bytes would never match the digest the platform sent and **every legitimate webhook would have returned 401**. Fix: pull the body in as `axum::body::Bytes`, verify HMAC against those raw bytes, then `serde_json::from_slice` the JSON post-verification — the same pattern Messenger / Teams / Viber were already using in this PR. Added two regression tests: `test_verify_line_signature_round_trip` and `test_line_signature_breaks_when_body_round_tripped_through_value` which constructs `{"b":1, "a":2}`, HMACs it, then proves the round-tripped form `{"a":2,"b":1}` fails to verify. ## SSRF guard bypassed by IPv6-mapped/NAT64 addresses, `[::]`, trailing-dot FQDN A probe over a wider corpus showed: * `http://[::]/` → Ok (should reject — `host_str()` returns the bracketed `"[::]"` which `IpAddr::from_str` refuses, so the private-IP branch never ran). * `http://[::ffff:127.0.0.1]/` → Ok (IPv4-mapped IPv6 — packets go to `127.0.0.1` on the wire; we never extracted the embedded v4). * `http://[64:ff9b::7f00:1]/` → Ok (RFC 6052 NAT64 well-known prefix — same problem). * `http://localhost./` → Ok (trailing-dot FQDN; the comparison against `"localhost"` failed because of the `.`). (The IPv4 short-form bypasses I worried about in review — `http://127.1/`, `http://2130706433/`, `http://0177.0.0.1/`, `http://0x7f.0.0.1/` — turned out *not* to be bypasses: WHATWG URL parser inside `url::Url::host()` already normalises them to `Ipv4(127.0.0.1)`. Test cases added to lock that property in.) Fix: rewrote `validate_url_for_fetch` to branch on `url::Url::host()` (the `Host<&str>` enum), not `host_str()`. New helper `ipv6_embedded_ipv4` extracts the embedded IPv4 from `::ffff:x.x.x.x` and `64:ff9b::x.x.x.x`. Domain branch strips trailing dots before comparison. Also tightened the IPv4 table: explicit /24 for `192.0.0.0/24` (was `/16` which over-rejected `192.0.1.x`), CGN `100.64.0.0/10`, multicast `224/4`, reserved `240/4`. New test cases cover all of the above. # Engineering * **Constant-time compare deduplicated.** Messenger, LINE, Teams, Viber each had their own hand-rolled `for ... |= a ^ b` loop. All four now call `crate::http_client::ct_eq`, which is backed by the `subtle` crate (already a workspace dep). Hand-rolled CT compares risk being lowered to early-exit `memcmp` by future compiler rounds; `subtle::ConstantTimeEq` is the audited reference. * **"No secret configured" warning moved to construction.** The fall-through warn fired on every webhook request, flooding logs whenever an operator left the env var unset. Now logged exactly once when the adapter is built. Per-request path stays silent. * **Teams base64 key cached.** `verify_teams_signature` was decoding the configured token from base64 on every request. Now decoded once in `TeamsAdapter::new`; misconfigured non-base64 input collapses to `security_token_key: None` (verification disabled, warning logged once) so the hot path stays branch-light. * **Status codes unified.** Across all five channels: - missing/malformed input header → `400 Bad Request` - present but rejected (sig mismatch, stale, replay) → `401 Unauthorized` Previously they were a mix of 400 / 401 / 403. Operators now get a uniform signal in their access logs. * **`sha1` pinned to 0.10.** Workspace `Cargo.toml` had `sha1 = "0.11"` which moved to digest 0.11; `hmac = "0.12"` is on digest 0.10 and the two are incompatible — `Hmac::<Sha1>::new_from_slice` won't compile. The PR's previous in-progress edit had this right but it got reverted somehow. Pinned with a comment explaining the pairing.
The previous follow-up commit added new env vars (`app_secret_env` for Messenger, `security_token_env` for Teams) and changed inbound HTTP contracts (LINE/Viber/DingTalk are now hard 400/401 instead of silent bypass). None of that was reflected in user-facing docs, so an operator reading `configuration/channels/page.mdx` would not know to set the new env vars and would fall through to the "skip verification with a warning" backwards-compat path. Updates: - New top-of-page **Webhook security** section explaining the uniform status-code contract (400 for missing/malformed headers, 401 for bad signatures), the constant-time wire-byte HMAC, and the SSRF guard on outbound `callback_url`. - `[channels.teams]` — add `security_token_env` field row with a warning that production should always set it. - `[channels.messenger]` — add `app_secret_env` field row with the same warning. - `[channels.line]` — note that `channel_secret_env` is now strictly required (was silently bypassed when the header was absent). - `[channels.viber]` — note that `auth_token_env` is also the HMAC key for inbound verification. - `[channels.dingtalk]` — webhook mode now rejects missing/non-numeric `timestamp` headers with a hard 400. - `[channels.webhook]` — explicitly document that `callback_url` is SSRF-guarded and which ranges are refused.
…dates The previous docs commit only updated the channels sub-page. The top-level configuration/page.mdx (independent full reference) and configuration/security/page.mdx (env var index) duplicate the same material and were still out of sync. - configuration/page.mdx: same Teams/Messenger/LINE/Viber/DingTalk/ Webhook updates as the channels sub-page (new env var fields, mandatory-signature notes, callback_url SSRF wording). - configuration/security/page.mdx: add TEAMS_SECURITY_TOKEN and MESSENGER_APP_SECRET to the env-var inventory.
houko
force-pushed
the
worktree-agent-aa47a034fc5bb5dcc
branch
from
April 28, 2026 14:28
e12b9ac to
85735ca
Compare
The previous docs commits documented the new env-var fields and the status-code contract, but operators upgrading still had to assemble 'what do I need to do' from scattered field descriptions. Make it explicit: - CHANGELOG.md Unreleased / Security: per-channel action required, per-channel fallback semantics, the SSRF guard's effect on local dev callback_url setups. - configuration/channels/page.mdx: an 'Upgrading from earlier versions' subsection right under 'Webhook security' that lists the same items as a numbered checklist, plus the explicit no-op clause for operators who don't run any of these channels.
2 tasks
houko
added a commit
that referenced
this pull request
Apr 28, 2026
Several recently-merged PRs added new fields to KernelConfig without regenerating the golden fixture: - app_secret_env (Messenger HMAC) from #3942 - security_token_env (Teams HMAC) from #3942 - public_base_url (mobile pairing) from #3344 The drift caused kernel_config_schema_matches_golden_fixture to fail on every open PR's CI (Test/Ubuntu/Windows/macOS), blocking ~30 in-flight fixes from merging even though their own changes were unrelated to the config schema. Regenerated via: cargo test -p librefang-api --test config_schema_golden \ -- --ignored regenerate_golden --nocapture 29 lines added, no removals — purely additive new optional fields. Co-authored-by: Evan Hu <[email protected]>
houko
added a commit
that referenced
this pull request
Apr 28, 2026
…ot host (#4019) #3940 added an SSRF guard to mcp_oauth::is_ssrf_blocked_host that classifies V4 and V6 ranges separately. Three bypasses survived: 1. IPv4-mapped IPv6 (`::ffff:127.0.0.1`, RFC 4291 §2.5.5.2) — the address resolves through V6 parsing, but on the wire packets go to the embedded V4 endpoint. None of the V6 checks (loopback, fc00::/7, fe80::/10) match it, so a request to `http://[::ffff:7f00:0001]/mcp` reaches 127.0.0.1 unchallenged. 2. NAT64 prefix `64:ff9b::x.x.x.x` (RFC 6052) — same wire-level delivery to a V4 endpoint, same bypass. 3. `localhost.` (trailing dot) — DNS resolvers treat `localhost.` and `localhost` as the same name, but the lowercase string match skipped the `localhost.` form. `librefang-channels::http_client::is_private_ipv4` already handled all three (in #3942); mcp_oauth was the outlier. Mirror the same shape: extract embedded V4 from a V6 address, run the V4 rules, and trim a trailing dot before the hostname comparison. Adds four regression tests covering IPv4-mapped loopback / IMDS, NAT64 loopback, and the `localhost.` / `LOCALHOST` variants.
This was referenced May 1, 2026
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
X-Hub-Signatureverification. POST handler now reads raw bytes before JSON deserialization so the HMAC covers the wire body. Missing header → 403; bad HMAC → 403; noapp_secretconfigured → loud warning + skip (backwards compat). New config fieldapp_secret_env(defaultMESSENGER_APP_SECRET).X-Line-Signaturemandatory. Missing header was silently bypassing the check; now returns HTTP 400 Bad Request.Authorization: HMAC <base64>header using the outgoing webhook security token. Newsecurity_token_envconfig field (defaultTEAMS_SECURITY_TOKEN). Missing/bad header → 401; no token configured → loud warning + skip.X-Viber-Content-Signatureheader using theauth_token. Missing → 403; invalid → 403.timestampheader always rejects the request.validate_url_for_fetch()tohttp_client.rs. Rejects non-http/https schemes and IP literals or hostnames in loopback (127.x / ::1), RFC-1918 private (10.x / 172.16-31.x / 192.168.x), link-local (169.254.x / fe80::), unique-local (fc00::/7), and cloud metadata service ranges.WebhookAdapter::new()now returnsResult<Self, String>and rejects privatecallback_urlvalues at construction time.channel_bridge.rslogs an error and skips the adapter on rejection.Changed files
crates/librefang-channels/src/http_client.rs— SSRF guard + unit testscrates/librefang-channels/src/messenger.rs— HMAC-SHA1 verify +app_secretfield + testscrates/librefang-channels/src/line.rs— mandatory signature checkcrates/librefang-channels/src/teams.rs— HMAC-SHA256 verify +security_tokenfield + testscrates/librefang-channels/src/viber.rs— HMAC-SHA256 verify + testscrates/librefang-channels/src/dingtalk.rs— hard 400 on bad timestamp + testcrates/librefang-channels/src/webhook.rs— SSRF-guard oncallback_url+ testscrates/librefang-types/src/config/types.rs— newsecurity_token_env(Teams) andapp_secret_env(Messenger) config fields with#[serde(default)]crates/librefang-api/src/channel_bridge.rs— wire new config fields to adapter constructors; handleWebhookAdapter::newResultTest plan
test_verify_hub_signature_valid/invalid— Messenger HMAC-SHA1 coveragetest_verify_teams_signature_valid/invalid— Teams HMAC-SHA256 coveragetest_verify_viber_signature_valid/invalid— Viber HMAC-SHA256 coveragetest_webhook_rejects_private_callback_url/test_webhook_accepts_public_callback_url— SSRF guard coveragetest_validate_url_*— SSRF guard unit tests inhttp_client.rstest_dingtalk_verify_signature_rejects_wrong_timestamp— DingTalk timestamp coverageCloses #3438, #3439, #3440, #3441, #3442, #3701