|
| 1 | +//go:build kubernetes |
| 2 | + |
| 3 | +package controlplane |
| 4 | + |
| 5 | +import ( |
| 6 | + "context" |
| 7 | + "encoding/json" |
| 8 | + "errors" |
| 9 | + "net/http" |
| 10 | + "net/http/httptest" |
| 11 | + "strings" |
| 12 | + "testing" |
| 13 | + |
| 14 | + "github.com/posthog/duckgres/controlplane/provisioner" |
| 15 | +) |
| 16 | + |
| 17 | +func TestPoolCandidateConsumesForwardedHTTPSContinuation(t *testing.T) { |
| 18 | + for _, explicitPort := range []bool{false, true} { |
| 19 | + name := "implicit HTTPS port" |
| 20 | + if explicitPort { |
| 21 | + name = "explicit HTTPS port" |
| 22 | + } |
| 23 | + t.Run(name, func(t *testing.T) { |
| 24 | + coordinator := newFakeCoordinator(t) |
| 25 | + pages := 0 |
| 26 | + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 27 | + user, password, ok := r.BasicAuth() |
| 28 | + if !ok || user != "observer" || password != "test-password" || r.Header.Get("X-Forwarded-Proto") != "https" || r.Header.Get("X-Forwarded-Port") != "443" { |
| 29 | + t.Error("probe lost authentication or forwarded origin headers") |
| 30 | + w.WriteHeader(http.StatusUnauthorized) |
| 31 | + return |
| 32 | + } |
| 33 | + next := func(path string) { |
| 34 | + host := strings.Split(r.Host, ":")[0] |
| 35 | + if explicitPort { |
| 36 | + host += ":443" |
| 37 | + } |
| 38 | + _ = json.NewEncoder(w).Encode(map[string]any{"nextUri": "https://" + host + path}) |
| 39 | + } |
| 40 | + switch r.URL.Path { |
| 41 | + case "/v1/statement": |
| 42 | + next("/v1/statement/queued/query/token/1") |
| 43 | + case "/v1/statement/queued/query/token/1": |
| 44 | + pages++ |
| 45 | + next("/v1/statement/executing/query/token/2") |
| 46 | + case "/v1/statement/executing/query/token/2": |
| 47 | + pages++ |
| 48 | + _ = json.NewEncoder(w).Encode(map[string]any{"data": coordinator.nodes}) |
| 49 | + default: |
| 50 | + coordinator.server.Config.Handler.ServeHTTP(w, r) |
| 51 | + } |
| 52 | + })) |
| 53 | + defer server.Close() |
| 54 | + validation, err := validateTrinoPoolCandidate(context.Background(), server.Client(), server.URL, |
| 55 | + func() (string, string) { return "observer", "test-password" }, |
| 56 | + trinoPoolObservation{ReadyWorkers: 2, CoordinatorImage: fakeCoordinatorImage}, |
| 57 | + trinoPoolExpectation{Image: fakeCoordinatorImage, CatalogRevision: 42, InternalHTTP: true, |
| 58 | + ProjectionDigest: provisioner.TrinoProjectionDigest(fakePolicyRevision, fakePasswordRevision, fakeGroupRevision)}) |
| 59 | + if err != nil { |
| 60 | + t.Fatalf("candidate could not consume its forwarded result pages: %v", err) |
| 61 | + } |
| 62 | + if pages != 2 || validation.ReadyWorkers != 2 { |
| 63 | + t.Fatalf("pages=%d, workers=%d, want two of each", pages, validation.ReadyWorkers) |
| 64 | + } |
| 65 | + }) |
| 66 | + } |
| 67 | +} |
| 68 | + |
| 69 | +func TestPoolProbeRejectsUntrustedContinuationOrigins(t *testing.T) { |
| 70 | + for _, target := range []string{ |
| 71 | + "https://foreign.example.test/v1/statement/result", |
| 72 | + "https://%s:444/v1/statement/result", |
| 73 | + "http://%s:443/v1/statement/result", |
| 74 | + "ftp://%s/v1/statement/result", |
| 75 | + "//%s/v1/statement/result", |
| 76 | + "https://user:secret@%s/v1/statement/result", |
| 77 | + "https://%s/v1/statement/result?secret=value", |
| 78 | + "https://%s/v1/statement/result?", |
| 79 | + "https://%s/v1/statement/result#fragment", |
| 80 | + "https://%s/v1/statement-adjacent", |
| 81 | + "https://%s/v1/info", |
| 82 | + "https://%s/v1/statement", |
| 83 | + } { |
| 84 | + t.Run(target, func(t *testing.T) { |
| 85 | + requests := 0 |
| 86 | + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 87 | + requests++ |
| 88 | + host := strings.Split(r.Host, ":")[0] |
| 89 | + _ = json.NewEncoder(w).Encode(map[string]any{"nextUri": strings.ReplaceAll(target, "%s", host)}) |
| 90 | + })) |
| 91 | + defer server.Close() |
| 92 | + client := rolloutSQLClient{baseURL: server.URL, client: server.Client(), username: "observer", password: "test-password", internalHTTP: true} |
| 93 | + _, err := client.statement(context.Background(), "SELECT 1") |
| 94 | + if err == nil || err.Error() != "invalid coordinator response endpoint" || requests != 1 { |
| 95 | + t.Fatalf("unsafe continuation accepted: requests=%d, err=%v", requests, err) |
| 96 | + } |
| 97 | + if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "test-password") || strings.Contains(err.Error(), "example.test") { |
| 98 | + t.Fatalf("failure exposed response-controlled or credential text: %v", err) |
| 99 | + } |
| 100 | + }) |
| 101 | + } |
| 102 | +} |
| 103 | + |
| 104 | +func TestPoolProbeDoesNotFollowHTTPRedirect(t *testing.T) { |
| 105 | + foreignRequests := 0 |
| 106 | + foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { foreignRequests++ })) |
| 107 | + defer foreign.Close() |
| 108 | + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 109 | + if r.URL.Path == "/v1/statement" { |
| 110 | + _ = json.NewEncoder(w).Encode(map[string]any{"nextUri": "https://" + strings.Split(r.Host, ":")[0] + "/v1/statement/result"}) |
| 111 | + return |
| 112 | + } |
| 113 | + http.Redirect(w, r, foreign.URL+"/v1/statement/result", http.StatusTemporaryRedirect) |
| 114 | + })) |
| 115 | + defer server.Close() |
| 116 | + client := rolloutSQLClient{baseURL: server.URL, client: newRolloutHTTPClient(""), username: "observer", password: "test-password", internalHTTP: true} |
| 117 | + if _, err := client.statement(context.Background(), "SELECT 1"); err == nil || foreignRequests != 0 { |
| 118 | + t.Fatalf("redirect followed: calls=%d, error=%v", foreignRequests, err) |
| 119 | + } |
| 120 | +} |
| 121 | + |
| 122 | +func TestLegacyProbeDoesNotAcceptForwardedHTTPSPort(t *testing.T) { |
| 123 | + requests := 0 |
| 124 | + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 125 | + requests++ |
| 126 | + _ = json.NewEncoder(w).Encode(map[string]any{"nextUri": "https://" + strings.Split(r.Host, ":")[0] + "/v1/statement/result"}) |
| 127 | + })) |
| 128 | + defer server.Close() |
| 129 | + client := rolloutSQLClient{baseURL: server.URL, client: server.Client(), username: "observer", password: "test-password"} |
| 130 | + _, err := client.statement(context.Background(), "SELECT 1") |
| 131 | + if err == nil || err.Error() != "invalid coordinator response endpoint" || requests != 1 { |
| 132 | + t.Fatalf("legacy probe accepted a different HTTPS port: requests=%d, error=%v", requests, err) |
| 133 | + } |
| 134 | +} |
| 135 | + |
| 136 | +func TestPoolNodeInventoryFailureKeepsSafeCause(t *testing.T) { |
| 137 | + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 138 | + _ = json.NewEncoder(w).Encode(map[string]any{"nextUri": "https://foreign.example.test/v1/statement/result"}) |
| 139 | + })) |
| 140 | + defer server.Close() |
| 141 | + client := rolloutSQLClient{baseURL: server.URL, client: server.Client(), username: "observer", password: "test-password", internalHTTP: true} |
| 142 | + _, err := registeredWorkerCount(context.Background(), client, "node-1") |
| 143 | + if !errors.Is(err, errTrinoPoolCandidateNotReady) || !strings.Contains(err.Error(), "invalid coordinator response endpoint") { |
| 144 | + t.Fatalf("inventory failure lost its safe underlying cause: %v", err) |
| 145 | + } |
| 146 | +} |
0 commit comments