|
5 | 5 | "fmt" |
6 | 6 | "net/http" |
7 | 7 | "net/http/httptest" |
| 8 | + neturl "net/url" |
8 | 9 | "testing" |
9 | 10 | "time" |
10 | 11 |
|
@@ -433,6 +434,11 @@ func TestMatchesDomain(t *testing.T) { |
433 | 434 | {"only-dot pattern matches nothing", "example.com", ".", false}, |
434 | 435 | {"whitespace tolerated", " example.com ", " example.com ", true}, |
435 | 436 | {"ip address exact", "169.254.169.254", "169.254.169.254", true}, |
| 437 | + // FQDN trailing dot (regression: must not bypass the matcher). |
| 438 | + {"trailing dot host matches apex pattern", "example.com.", "example.com", true}, |
| 439 | + {"trailing dot host matches subdomain pattern", "docs.example.com.", "example.com", true}, |
| 440 | + {"trailing dot pattern matches apex host", "example.com", "example.com.", true}, |
| 441 | + {"trailing dot host matches strict-subdomain pattern", "docs.example.com.", ".example.com", true}, |
436 | 442 | } |
437 | 443 |
|
438 | 444 | for _, tc := range tests { |
@@ -523,3 +529,59 @@ func TestFetch_BlockedDomains_DeniesIgnoringRobots(t *testing.T) { |
523 | 529 | assert.Contains(t, result.Output, "is blocked by blocked_domains") |
524 | 530 | assert.False(t, robotsRequested, "blocked URLs must not trigger any network call, including robots.txt") |
525 | 531 | } |
| 532 | + |
| 533 | +// TestFetch_AllowedDomains_RejectsRedirectToBlockedHost is a regression test for an |
| 534 | +// SSRF-style bypass: an allow-listed origin returning a redirect to a host |
| 535 | +// that is NOT in the allow-list must be rejected before the redirect is |
| 536 | +// followed, otherwise the policy is hollow. |
| 537 | +func TestFetch_AllowedDomains_RejectsRedirectToBlockedHost(t *testing.T) { |
| 538 | + redirected := false |
| 539 | + url := runHTTPServer(t, func(w http.ResponseWriter, r *http.Request) { |
| 540 | + if r.URL.Path == "/robots.txt" { |
| 541 | + http.NotFound(w, r) |
| 542 | + return |
| 543 | + } |
| 544 | + redirected = true |
| 545 | + http.Redirect(w, r, "http://attacker.example.com/secret", http.StatusFound) |
| 546 | + }) |
| 547 | + parsed, err := neturl.Parse(url) |
| 548 | + require.NoError(t, err) |
| 549 | + |
| 550 | + // Allow only the test server's host. The redirect target must be |
| 551 | + // rejected without any network call to attacker.example.com. |
| 552 | + tool := NewFetchTool(WithAllowedDomains([]string{parsed.Hostname()})) |
| 553 | + |
| 554 | + result, err := tool.handler.CallTool(t.Context(), FetchToolArgs{ |
| 555 | + URLs: []string{url + "/start"}, |
| 556 | + Format: "text", |
| 557 | + }) |
| 558 | + require.NoError(t, err) |
| 559 | + assert.True(t, redirected, "the test server should have been hit at least once to issue the redirect") |
| 560 | + assert.Contains(t, result.Output, "Error fetching") |
| 561 | + assert.Contains(t, result.Output, "attacker.example.com", "the error should mention the rejected redirect target") |
| 562 | + assert.Contains(t, result.Output, "is not in allowed_domains") |
| 563 | +} |
| 564 | + |
| 565 | +// TestFetch_BlockedDomains_RejectsRedirectToBlockedHost mirrors the allow-list |
| 566 | +// regression test for the deny-list path: a redirect to a deny-listed host |
| 567 | +// must not be followed. |
| 568 | +func TestFetch_BlockedDomains_RejectsRedirectToBlockedHost(t *testing.T) { |
| 569 | + url := runHTTPServer(t, func(w http.ResponseWriter, r *http.Request) { |
| 570 | + if r.URL.Path == "/robots.txt" { |
| 571 | + http.NotFound(w, r) |
| 572 | + return |
| 573 | + } |
| 574 | + http.Redirect(w, r, "http://169.254.169.254/metadata", http.StatusFound) |
| 575 | + }) |
| 576 | + |
| 577 | + tool := NewFetchTool(WithBlockedDomains([]string{"169.254.169.254"})) |
| 578 | + |
| 579 | + result, err := tool.handler.CallTool(t.Context(), FetchToolArgs{ |
| 580 | + URLs: []string{url + "/innocent"}, |
| 581 | + Format: "text", |
| 582 | + }) |
| 583 | + require.NoError(t, err) |
| 584 | + assert.Contains(t, result.Output, "Error fetching") |
| 585 | + assert.Contains(t, result.Output, "is blocked by blocked_domains") |
| 586 | + assert.Contains(t, result.Output, "169.254.169.254") |
| 587 | +} |
0 commit comments