Skip to content

fix(channels): mandatory webhook HMAC verification + SSRF guard#3942

Merged
houko merged 5 commits into
mainfrom
worktree-agent-aa47a034fc5bb5dcc
Apr 28, 2026
Merged

fix(channels): mandatory webhook HMAC verification + SSRF guard#3942
houko merged 5 commits into
mainfrom
worktree-agent-aa47a034fc5bb5dcc

Conversation

@houko

@houko houko commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Changed files

  • crates/librefang-channels/src/http_client.rs — SSRF guard + unit tests
  • crates/librefang-channels/src/messenger.rs — HMAC-SHA1 verify + app_secret field + tests
  • crates/librefang-channels/src/line.rs — mandatory signature check
  • crates/librefang-channels/src/teams.rs — HMAC-SHA256 verify + security_token field + tests
  • crates/librefang-channels/src/viber.rs — HMAC-SHA256 verify + tests
  • crates/librefang-channels/src/dingtalk.rs — hard 400 on bad timestamp + test
  • crates/librefang-channels/src/webhook.rs — SSRF-guard on callback_url + tests
  • crates/librefang-types/src/config/types.rs — new security_token_env (Teams) and app_secret_env (Messenger) config fields with #[serde(default)]
  • crates/librefang-api/src/channel_bridge.rs — wire new config fields to adapter constructors; handle WebhookAdapter::new Result

Test plan

  • All existing tests still pass
  • test_verify_hub_signature_valid/invalid — Messenger HMAC-SHA1 coverage
  • test_verify_teams_signature_valid/invalid — Teams HMAC-SHA256 coverage
  • test_verify_viber_signature_valid/invalid — Viber HMAC-SHA256 coverage
  • test_webhook_rejects_private_callback_url / test_webhook_accepts_public_callback_url — SSRF guard coverage
  • test_validate_url_* — SSRF guard unit tests in http_client.rs
  • test_dingtalk_verify_signature_rejects_wrong_timestamp — DingTalk timestamp coverage

Closes #3438, #3439, #3440, #3441, #3442, #3701

@github-actions github-actions Bot added area/channels Messaging channel adapters size/L 250-999 lines changed labels Apr 28, 2026
houko added 4 commits April 28, 2026 23:28
…/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
houko force-pushed the worktree-agent-aa47a034fc5bb5dcc branch from e12b9ac to 85735ca Compare April 28, 2026 14:28
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.
@houko
houko merged commit cd3d1ce into main Apr 28, 2026
13 checks passed
@houko
houko deleted the worktree-agent-aa47a034fc5bb5dcc branch April 28, 2026 14:34
@github-actions github-actions Bot added the area/docs Documentation and guides label Apr 28, 2026
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.
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/docs Documentation and guides size/L 250-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Messenger webhook accepts unsigned POST bodies — full inbound message spoofing

1 participant