Skip to content

feat(workflow): operator CIDR allowlist for webhook SSRF check - #6553

Open
sanjay3290 wants to merge 5 commits into
block:mainfrom
sanjay3290:buzz-spark-6500-allowlist
Open

feat(workflow): operator CIDR allowlist for webhook SSRF check#6553
sanjay3290 wants to merge 5 commits into
block:mainfrom
sanjay3290:buzz-spark-6500-allowlist

Conversation

@sanjay3290

Copy link
Copy Markdown

Closes #6500.

call_webhook's SSRF check rejects every private/reserved range, including 100.64.0.0/10. On a Tailscale deployment that range is the operator's internal network, so call_webhook cannot reach anything they run. This adds an opt-in, operator-controlled CIDR allowlist. The default is empty, so behaviour is byte-for-byte unchanged for anyone who does not set the variable.

What changed

  • buzz-core::network gains IpCidr (with contains), parse_allowed_cidrs, and is_blocked_ip(ip, allowed) == is_private_ip(ip) && !allowed.contains(ip). Hand-rolled masking, no new dependency.
  • buzz-workflow::executor gains ssrf_verdict(host, addrs, allowed_spec). check_ssrf is now resolve-then-delegate, reading BUZZ_WORKFLOW_WEBHOOK_ALLOWED_CIDRS.
  • The SSRF error now names the variable, so a blocked destination points at its own remedy.
  • Malformed entries are dropped and the dropped list is returned to the caller, which logs one tracing::warn! naming the variable. buzz-core has no tracing dependency and does no logging itself.
  • .env.example documents the variable.

parse_allowed_cidrs fails closed: an unparseable or over-wide entry is dropped rather than widened, and a bare address with no /prefix is dropped rather than treated as a host route.

Scope

This is option 1 of the two the issue asks for. Option 2 — surfacing blocked destinations as an explicit step error instead of a silent non-delivery — is #5122 and is not addressed here. The issue says option 1 alone unblocks the reported case.

Verification

ssrf_verdict and its five tests are behind #[cfg(feature = "reqwest")], matching every other item in that block. Two things follow, and both matter when reading a green pipeline:

  • ./scripts/run-tests.sh unit does not run buzz-workflow at all. Its suites are buzz-core, buzz-auth, buzz-voice, buzz-cli, buzz-db, buzz-conformance, buzz-push-gateway, buzz-backend-kubernetes, buzz-agent. Only the buzz-core half of this change is covered there.
  • cargo clippy --workspace cannot see a cfg mismatch on this function, because feature unification turns reqwest on. An earlier revision of this branch was missing the #[cfg] and passed workspace clippy while cargo clippy -p buzz-workflow --all-targets -- -D warnings failed on dead code. Reviewers should run the per-crate form.

Run cargo test -p buzz-workflow --features reqwest to exercise the workflow-side tests; without the feature they are filtered out (measured: 5 run with it, 0 without).

Gates run on the branch and again on the branch merged with main:

cargo fmt --all --check                                             exit 0
cargo clippy --workspace --all-targets -- -D warnings               clean
cargo clippy -p buzz-workflow --all-targets -- -D warnings          clean
cargo clippy -p buzz-workflow --all-targets --features reqwest ...  clean
cargo test -p buzz-core                                             270 passed, 0 failed
cargo test -p buzz-workflow --features reqwest                      175 passed, 0 failed
./scripts/run-tests.sh unit                                         All tests passed

Mutation probes, with the baseline restored and re-confirmed green between each:

Change Result
is_blocked_ip drops the allowlist term 3 failed
parse_allowed_cidrs returns an empty dropped list 1 failed
IpCidr::contains always returns true 4 failed
Delete the ssrf_verdict call from check_ssrf 1 failed, plus clippy dead-code error
Replace the env::var argument with a literal "" 1 failed

The last row is the one worth calling out. Deleting the call site is caught by the dead-code lint, but silently disabling the feature by breaking the variable name is not — that is what check_ssrf_reads_allowlist_env_var exists to catch. It drives check_ssrf through a current-thread runtime under a mutex, and needs no network: "127.0.0.1:80".to_socket_addrs() is a pure parse.

Co-authored-by: Sanjay Ramadugu <sramadugu1@gmail.com>
Signed-off-by: Sanjay Ramadugu <sramadugu1@gmail.com>
Co-authored-by: Sanjay Ramadugu <sramadugu1@gmail.com>
Signed-off-by: Sanjay Ramadugu <sramadugu1@gmail.com>
…n in workflow

Co-authored-by: Sanjay Ramadugu <sramadugu1@gmail.com>
Signed-off-by: Sanjay Ramadugu <sramadugu1@gmail.com>
Co-authored-by: Sanjay Ramadugu <sramadugu1@gmail.com>
Signed-off-by: Sanjay Ramadugu <sramadugu1@gmail.com>
Co-authored-by: Sanjay Ramadugu <sramadugu1@gmail.com>
Signed-off-by: Sanjay Ramadugu <sramadugu1@gmail.com>
@sanjay3290
sanjay3290 requested a review from a team as a code owner August 22, 2026 09:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

call_webhook SSRF blocklist covers 100.64.0.0/10 (CGNAT), making webhooks unusable for Tailscale-based self-hosters

1 participant