domain,infra: Guard webmention fetches with a connect-time resolved-IP check #90

Merged
rosa merged 1 commit from fix/52-webmention-source-ssrf-guard into main 2026-07-04 17:59:03 +00:00
Owner

Closes #52.

Problem

The outbound webmention client validated only the URL literal
(WebmentionTarget::parse), never the resolved IP. A source, redirect hop, or
discovered endpoint whose DNS pointed at a private, loopback, or link-local
address (e.g. 169.254.169.254 cloud metadata) was connected to — an SSRF.

Fix

  • Domain: one is_global_ip(IpAddr) -> bool policy (handling IPv4-mapped
    IPv6), reused by both the literal WebmentionTarget::parse check and the new
    connect-time resolver.
  • Infra: a custom reqwest DNS resolver on the shared hardened client resolves
    names via tokio::net::lookup_host and drops non-global addresses. reqwest
    connects only to the returned addresses, so the checked IP is the connected
    IP — no DNS-rebinding TOCTOU. This covers discovery, delivery, verification,
    and every redirect hop.
  • A source resolving only to non-global addresses is refused via a typed
    GlobalGuardBlocked sentinel, preserved on the reqwest connect error's
    source() chain. The verify job recognises it and aborts terminally rather
    than retrying a source that will never be permitted.

Scope notes

  • The sender/discovery path stays on the existing retryable path; only the verify
    job distinguishes the block and aborts terminally.
  • A blocked source aborts without retracting any existing stored row.

Tests

  • Domain: is_global_ip reject/accept cases (incl. IPv4-mapped private and
    metadata IPs) and WebmentionTarget::parse cases.
  • Infra: resolve_global_only keep/block cases and chain_contains_block
    recovering the sentinel through a wrapping error's source().
  • mise run ci and mise run clippy pass.

Plan and research: thoughts/shared/plans/007_… and
thoughts/shared/research/008_….

Closes #52. ## Problem The outbound webmention client validated only the URL *literal* (`WebmentionTarget::parse`), never the resolved IP. A `source`, redirect hop, or discovered endpoint whose DNS pointed at a private, loopback, or link-local address (e.g. `169.254.169.254` cloud metadata) was connected to — an SSRF. ## Fix - **Domain:** one `is_global_ip(IpAddr) -> bool` policy (handling IPv4-mapped IPv6), reused by both the literal `WebmentionTarget::parse` check and the new connect-time resolver. - **Infra:** a custom reqwest DNS resolver on the shared hardened client resolves names via `tokio::net::lookup_host` and drops non-global addresses. reqwest connects only to the returned addresses, so the checked IP *is* the connected IP — no DNS-rebinding TOCTOU. This covers discovery, delivery, verification, and every redirect hop. - A source resolving only to non-global addresses is refused via a typed `GlobalGuardBlocked` sentinel, preserved on the reqwest connect error's `source()` chain. The verify job recognises it and **aborts terminally** rather than retrying a source that will never be permitted. ## Scope notes - The sender/discovery path stays on the existing retryable path; only the verify job distinguishes the block and aborts terminally. - A blocked source aborts without retracting any existing stored row. ## Tests - Domain: `is_global_ip` reject/accept cases (incl. IPv4-mapped private and metadata IPs) and `WebmentionTarget::parse` cases. - Infra: `resolve_global_only` keep/block cases and `chain_contains_block` recovering the sentinel through a wrapping error's `source()`. - `mise run ci` and `mise run clippy` pass. Plan and research: `thoughts/shared/plans/007_…` and `thoughts/shared/research/008_…`.
domain,infra: Guard webmention fetches with a connect-time resolved-IP check
All checks were successful
ci/woodpecker/push/clippy Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
5539c4b7ed
The outbound webmention client only validated the URL literal
(WebmentionTarget::parse), never the resolved IP, so a source, redirect
hop, or discovered endpoint whose DNS pointed at a private, loopback, or
link-local address (e.g. 169.254.169.254) was connected to.

Add is_global_ip as the single global-address policy in the domain and
route WebmentionTarget::parse through it. Install a custom reqwest DNS
resolver on the shared hardened client that resolves names via
tokio::net::lookup_host and drops non-global addresses, so reqwest
connects only to vetted IPs (no DNS-rebinding TOCTOU) across discovery,
delivery, verification, and every redirect hop.

A source that resolves only to non-global addresses is refused via a
typed GlobalGuardBlocked sentinel, preserved on the reqwest connect
error's source chain; the verify job recognises it and aborts terminally
rather than retrying a source that will never be permitted.

Closes #52.
rosa merged commit 59b1f0a226 into main 2026-07-04 17:59:03 +00:00
rosa deleted branch fix/52-webmention-source-ssrf-guard 2026-07-04 17:59:03 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
rosa/vernier!90
No description provided.