Skip to content

fix(security): SSRF protection for approval callback_url (#435) - #790

Merged
santoshkumarradha merged 4 commits into
Agent-Field:mainfrom
7vignesh:fix/ssrf-approval-callback-435
Jul 19, 2026
Merged

fix(security): SSRF protection for approval callback_url (#435)#790
santoshkumarradha merged 4 commits into
Agent-Field:mainfrom
7vignesh:fix/ssrf-approval-callback-435

Conversation

@7vignesh

Copy link
Copy Markdown
Contributor

The approval callback_url field was accepted without SSRF validation and dispatched via a plain http.Client, allowing an attacker to use the control plane as a proxy to internal services (cloud metadata, RFC-1918, loopback).

Changes

  • Validate callback_url at registration time using services.ValidateWebhookURL() to reject private/internal targets (localhost, 169.254.x, 10.x, 172.16.x, 192.168.x, ::1) before the URL is persisted.
  • Replace the plain http.Client in notifyApprovalCallback with services.NewSSRFSafeClient(), which enforces DNS-rebinding-safe private-IP blocking at dial time.
  • Add comprehensive test coverage for both registration rejection and runtime transport enforcement.

Fixes #435

Summary

Closes an SSRF vulnerability (CWE-918) in the approval callback flow. Agents could register arbitrary callback_url values (including cloud metadata endpoints and RFC-1918 addresses) that the control plane would POST to upon approval resolution — effectively turning the CP into an open proxy. This PR adds input validation at registration time and switches the outbound HTTP client to the existing SSRF-safe transport as defense in depth.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Docs only
  • Tests only
  • CI / tooling
  • Breaking change

Test plan

  • cd control-plane && go test ./internal/handlers/ -run "TestRequestApprovalHandler_Rejects" -v -count=1
  • cd control-plane && go test ./internal/handlers/ -run "TestRequestApprovalHandler_PublicCallback" -v -count=1
  • cd control-plane && go test ./internal/handlers/ -run "TestNotifyApprovalCallback_UsesSSRFSafeClient" -v -count=1
  • cd control-plane && go test ./internal/handlers/ -run "TestApprovalWebhook_CallbackNotification" -v -count=1
  • cd control-plane && go test ./internal/handlers/ -run "TestApprovalFlow" -v -count=1

Test coverage

  • I ran tests for the surface(s) I changed locally.
  • New code paths are covered by tests in this PR (no bare additions).
  • If I removed code, I updated coverage-baseline.json in this PR only if the removal caused a legitimate regression and I called it out in the summary above.
  • The coverage gate check is green in CI before requesting review.

Checklist

Related issues / PRs

Fixes #435

Copilot AI review requested due to automatic review settings July 17, 2026 14:55
@7vignesh
7vignesh requested review from a team and AbirAbbas as code owners July 17, 2026 14:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@7vignesh
7vignesh force-pushed the fix/ssrf-approval-callback-435 branch 2 times, most recently from 05c685e to afcd0e9 Compare July 17, 2026 15:17

@santoshkumarradha santoshkumarradha left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the focused security fix. I checked this against the existing SSRF helper surface, and the defense-in-depth shape here is right: reject unsafe callback_url values up front, then enforce the same policy again at dial time.

I also ran the targeted handler tests from the PR locally on the branch head:
cd control-plane && go test ./internal/handlers/ -run 'TestRequestApprovalHandler_Rejects|TestRequestApprovalHandler_PublicCallback|TestNotifyApprovalCallback_UsesSSRFSafeClient|TestApprovalWebhook_CallbackNotification|TestApprovalFlow' -count=1

That passed cleanly. I’m leaving this approved and waiting on the remaining required CI checks before merge.

…#435)

The approval callback_url field was accepted without SSRF validation and
dispatched via a plain http.Client, allowing an attacker to use the
control plane as a proxy to internal services (cloud metadata, RFC-1918,
loopback).

Changes:
- Validate callback_url at registration time using services.ValidateWebhookURL()
  to reject private/internal targets (localhost, 169.254.x, 10.x, 172.16.x,
  192.168.x, ::1) before the URL is persisted.
- Replace the plain http.Client in notifyApprovalCallback with
  services.NewSSRFSafeClient() which enforces DNS-rebinding-safe private-IP
  blocking at dial time.
- Add comprehensive test coverage for both registration rejection and
  runtime transport enforcement.

Fixes Agent-Field#435
auto-merge was automatically disabled July 17, 2026 17:15

Head branch was pushed to by a user without write access

@7vignesh
7vignesh force-pushed the fix/ssrf-approval-callback-435 branch from 094d0b6 to 2efee0c Compare July 17, 2026 17:15
@7vignesh

7vignesh commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

All required checks are green ✅ the only failures are the pre-existing timeout (same deadlock on ). Ready to merge when you get a chance. Thanks for the review!

@santoshkumarradha

Copy link
Copy Markdown
Member

Quick maintainer update: I reran the required checks on Saturday, July 18, 2026, and the same required Full documentation is at https://coverage.readthedocs.io/en/7.13.5 workflow is still failing on the PR head, specifically followed by the aggregate job. The approval still stands, but we still cannot merge until that required check goes green on the contributor branch.

@santoshkumarradha

Copy link
Copy Markdown
Member

Quick maintainer update: I reran the required checks on Saturday, July 18, 2026, and the same required Coverage Summary workflow is still failing on the PR head, specifically coverage (control-plane) followed by the aggregate coverage-summary job. The approval still stands, but we still cannot merge until that required check goes green on the contributor branch.

7vignesh and others added 2 commits July 18, 2026 22:36
…ient

The existing TestExecuteReasonerAndWebhookHelpersCoverage test calls
notifyApprovalCallback against a httptest server (127.0.0.1). After
switching to NewSSRFSafeClient (Agent-Field#435), the SSRF transport rejects
loopback as a private IP, causing the test to hang waiting for
callbacks that never arrive.

Fix: set services.SetWebhookAllowedHosts([]string{"127.0.0.1"})
before the callback calls so the httptest server is reachable.
@github-actions

Copy link
Copy Markdown
Contributor

📊 Coverage gate

Thresholds from .coverage-gate.toml: per-surface ≥ 84%, aggregate ≥ 85%, max per-surface regression ≤ 1.0 pp, max aggregate regression ≤ 0.50 pp.

Surface Current Baseline Δ
control-plane 86.90% 87.40% ↓ -0.50 pp 🟡
sdk-go 92.50% 92.00% ↑ +0.50 pp 🟢
sdk-python 93.82% 93.73% ↑ +0.09 pp 🟢
sdk-typescript 90.44% 90.42% ↑ +0.02 pp 🟢
web-ui 84.75% 84.79% ↓ -0.04 pp 🟡
aggregate 85.54% 85.75% ↓ -0.21 pp 🟡

✅ Gate passed

No surface regressed past the allowed threshold and the aggregate stayed above the floor.

@github-actions

Copy link
Copy Markdown
Contributor

📐 Patch coverage gate

Threshold: 80% on lines this PR touches vs origin/main (from .coverage-gate.toml:thresholds.min_patch).

Surface Touched lines Patch coverage Status
control-plane 9 100.00%
sdk-go 0 ➖ no changes
sdk-python 0 ➖ no changes
sdk-typescript 0 ➖ no changes
web-ui 0 ➖ no changes

✅ Patch gate passed

Every surface whose lines were touched by this PR has patch coverage at or above the threshold.

@7vignesh

Copy link
Copy Markdown
Contributor Author

@santoshkumarradha review pls

@7vignesh

7vignesh commented Jul 18, 2026

Copy link
Copy Markdown
Contributor Author

The CI failed because the existing TestExecuteReasonerAndWebhookHelpersCoverage test fires notifyApprovalCallback against a local httptest server (127.0.0.1). After my change to use the SSRF-safe client, that client sees 127.0.0.1 as a
private IP and refuses to connect. The test was waiting on a channel for callbacks that would never arrive, so it hung until the CI timeout killed it. The coverage job never finished, which made the summary job fail too.
Fixed by allowlisting 127.0.0.1 in that specific test (same pattern the SSRF tests in this PR already use).

@santoshkumarradha santoshkumarradha left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for chasing the test follow-up quickly. I reran the full control-plane handler test package locally on the current head, and the SSRF fix plus the loopback test adjustment both look good from the maintainer side.

@santoshkumarradha
santoshkumarradha added this pull request to the merge queue Jul 19, 2026
Merged via the queue into Agent-Field:main with commit 7fb1193 Jul 19, 2026
20 checks passed
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.

[Go SDK] WithDIDAuth silently disables signing on JWK parse failure

3 participants