diff --git a/terraform/provider/CHANGELOG.md b/terraform/provider/CHANGELOG.md index 9d486b44f18..af03cfb4ce9 100644 --- a/terraform/provider/CHANGELOG.md +++ b/terraform/provider/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **provider**: Optional `custom_headers` map to send extra HTTP headers on every LiteLLM API request (e.g. proxy or gateway headers). `x-api-key` from `api_key` always takes precedence +- **provider**: Optional `custom_headers` map to send extra HTTP headers on every LiteLLM API request (e.g. proxy or gateway headers). Applied by both request helpers. `x-api-key` from `api_key` always takes precedence. Cross-origin redirects drop custom headers and `x-api-key` ## [0.4.0] - 2026-08-06 diff --git a/terraform/provider/litellm/client.go b/terraform/provider/litellm/client.go index afdc27849b9..e8bcbfafc1a 100644 --- a/terraform/provider/litellm/client.go +++ b/terraform/provider/litellm/client.go @@ -20,18 +20,48 @@ type Client struct { CustomHeaders map[string]string } -func NewClient(apiBase, apiKey string, insecureSkipVerify bool) *Client { - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: insecureSkipVerify}, +func NewClient(cfg ProviderConfig) *Client { + headers := make(map[string]string, len(cfg.CustomHeaders)) + for k, v := range cfg.CustomHeaders { + headers[k] = v } - return &Client{ - APIBase: apiBase, - APIKey: apiKey, - httpClient: &http.Client{Transport: tr}, - InsecureSkipVerify: insecureSkipVerify, - CustomHeaders: map[string]string{}, + c := &Client{ + APIBase: cfg.APIBase, + APIKey: cfg.APIKey, + InsecureSkipVerify: cfg.InsecureSkipVerify, + CustomHeaders: headers, } + c.httpClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify}, + }, + CheckRedirect: c.checkRedirect, + } + return c +} + +func (c *Client) checkRedirect(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("stopped after 10 redirects") + } + orig := via[0].URL + if req.URL.Scheme != orig.Scheme || req.URL.Host != orig.Host { + for k := range c.CustomHeaders { + req.Header.Del(k) + } + req.Header.Del("x-api-key") + } + return nil +} + +func (c *Client) applyRequestHeaders(req *http.Request) { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("accept", "application/json") + for k, v := range c.CustomHeaders { + req.Header.Set(k, v) + } + req.Header.Set("x-api-key", c.APIKey) } // Organization member methods @@ -281,12 +311,7 @@ func (c *Client) sendRequest(method, path string, body interface{}) (map[string] return nil, fmt.Errorf("error creating request: %v", err) } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("accept", "application/json") - for k, v := range c.CustomHeaders { - req.Header.Set(k, v) - } - req.Header.Set("x-api-key", c.APIKey) + c.applyRequestHeaders(req) resp, err := c.httpClient.Do(req) if err != nil { diff --git a/terraform/provider/litellm/client_test.go b/terraform/provider/litellm/client_test.go index 2749cf6e56e..a9525a8e284 100644 --- a/terraform/provider/litellm/client_test.go +++ b/terraform/provider/litellm/client_test.go @@ -2,12 +2,22 @@ package litellm import ( "encoding/json" + "io" "net/http" "net/http/httptest" "strings" "testing" ) +func testClient(apiBase, apiKey string, insecure bool, headers map[string]string) *Client { + return NewClient(ProviderConfig{ + APIBase: apiBase, + APIKey: apiKey, + InsecureSkipVerify: insecure, + CustomHeaders: headers, + }) +} + func TestSendRequestCustomHeaders(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.Header.Get("X-Proxy-Auth"); got != "proxy-token" { @@ -24,20 +34,84 @@ func TestSendRequestCustomHeaders(t *testing.T) { })) defer srv.Close() - client := NewClient(srv.URL, "sk-real-key", false) - client.CustomHeaders = map[string]string{ + client := testClient(srv.URL, "sk-real-key", false, map[string]string{ "X-Proxy-Auth": "proxy-token", "X-Request-Source": "terraform", "x-api-key": "should-not-win", - } + }) if _, err := client.sendRequest("GET", "/health", nil); err != nil { t.Fatalf("sendRequest: %v", err) } } +func TestMakeRequestCustomHeaders(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Proxy-Auth"); got != "proxy-token" { + t.Errorf("X-Proxy-Auth = %q, want %q", got, "proxy-token") + } + if got := r.Header.Get("x-api-key"); got != "sk-real-key" { + t.Errorf("x-api-key = %q, want configured API key (must not be overridden)", got) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) + })) + defer srv.Close() + + client := testClient(srv.URL, "sk-real-key", false, map[string]string{ + "X-Proxy-Auth": "proxy-token", + "x-api-key": "should-not-win", + }) + + resp, err := MakeRequest(client, "GET", "/health", nil) + if err != nil { + t.Fatalf("MakeRequest: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } +} + +func TestCrossOriginRedirectStripsSensitiveHeaders(t *testing.T) { + var finalAuth, finalAPIKey string + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + finalAuth = r.Header.Get("X-Proxy-Auth") + finalAPIKey = r.Header.Get("x-api-key") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true}) + })) + defer target.Close() + + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Proxy-Auth"); got != "proxy-token" { + t.Errorf("origin X-Proxy-Auth = %q, want proxy-token", got) + } + http.Redirect(w, r, target.URL+"/final", http.StatusFound) + })) + defer origin.Close() + + client := testClient(origin.URL, "sk-real-key", false, map[string]string{ + "X-Proxy-Auth": "proxy-token", + }) + + resp, err := MakeRequest(client, "GET", "/start", nil) + if err != nil { + t.Fatalf("MakeRequest: %v", err) + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + + if finalAuth != "" { + t.Errorf("cross-origin redirect kept X-Proxy-Auth = %q", finalAuth) + } + if finalAPIKey != "" { + t.Errorf("cross-origin redirect kept x-api-key = %q", finalAPIKey) + } +} + func TestRedactSensitiveDataNestedCredentialValues(t *testing.T) { - c := NewClient("http://localhost:4000", "sk-test", false) + c := testClient("http://localhost:4000", "sk-test", false, nil) input := `{"credential_name":"azure-cred","credential_values":{"api_key":"sk-secret-123","config":{"region":"us-east-1","client_secret":"nested-secret"}}}` got := c.redactSensitiveData(input) @@ -56,7 +130,7 @@ func TestRedactSensitiveDataNestedCredentialValues(t *testing.T) { } func TestRedactSensitiveDataDeeplyNestedSensitiveKeys(t *testing.T) { - c := NewClient("http://localhost:4000", "sk-test", false) + c := testClient("http://localhost:4000", "sk-test", false, nil) input := `{"data":[{"litellm_params":{"model":"gpt-4","api_key":"sk-deep-456","aws_secret_access_key":"aws-secret"}}]}` got := c.redactSensitiveData(input) @@ -72,7 +146,7 @@ func TestRedactSensitiveDataDeeplyNestedSensitiveKeys(t *testing.T) { } func TestRedactSensitiveDataTopLevelStringFields(t *testing.T) { - c := NewClient("http://localhost:4000", "sk-test", false) + c := testClient("http://localhost:4000", "sk-test", false, nil) input := `{"model_api_key":"sk-top-789","vertex_credentials":"{\"type\":\"service_account\"}","team_alias":"eng"}` got := c.redactSensitiveData(input) @@ -88,7 +162,7 @@ func TestRedactSensitiveDataTopLevelStringFields(t *testing.T) { } func TestRedactSensitiveDataNonJSONFallback(t *testing.T) { - c := NewClient("http://localhost:4000", "sk-test", false) + c := testClient("http://localhost:4000", "sk-test", false, nil) input := `error before "api_key": "sk-fallback-000" after` got := c.redactSensitiveData(input) diff --git a/terraform/provider/litellm/provider.go b/terraform/provider/litellm/provider.go index a5877ea23e6..496df6bba4c 100644 --- a/terraform/provider/litellm/provider.go +++ b/terraform/provider/litellm/provider.go @@ -60,18 +60,23 @@ func Provider() *schema.Provider { // providerConfigure configures the provider with the given schema data. func providerConfigure(d *schema.ResourceData) (interface{}, error) { - customHeaders := map[string]string{} - if v, ok := d.GetOk("custom_headers"); ok { - for k, val := range v.(map[string]interface{}) { - customHeaders[k] = val.(string) - } - } - - client := NewClient( - d.Get("api_base").(string), - d.Get("api_key").(string), - d.Get("insecure_skip_verify").(bool), - ) - client.CustomHeaders = customHeaders - return client, nil + return NewClient(ProviderConfig{ + APIBase: d.Get("api_base").(string), + APIKey: d.Get("api_key").(string), + InsecureSkipVerify: d.Get("insecure_skip_verify").(bool), + CustomHeaders: customHeadersFromSchema(d), + }), nil +} + +func customHeadersFromSchema(d *schema.ResourceData) map[string]string { + v, ok := d.GetOk("custom_headers") + if !ok { + return map[string]string{} + } + raw := v.(map[string]interface{}) + headers := make(map[string]string, len(raw)) + for k, val := range raw { + headers[k] = val.(string) + } + return headers } diff --git a/terraform/provider/litellm/provider_test.go b/terraform/provider/litellm/provider_test.go index 00817e7c410..dd5f08558ca 100644 --- a/terraform/provider/litellm/provider_test.go +++ b/terraform/provider/litellm/provider_test.go @@ -48,7 +48,7 @@ func createTestUsers(t *testing.T) { return } - client := NewClient(apiBase, apiKey, false) + client := NewClient(ProviderConfig{APIBase: apiBase, APIKey: apiKey, InsecureSkipVerify: false}) // Create test users users := []map[string]interface{}{ diff --git a/terraform/provider/litellm/resource_credential_crud_test.go b/terraform/provider/litellm/resource_credential_crud_test.go index 3398e58dd13..e179ba3eab4 100644 --- a/terraform/provider/litellm/resource_credential_crud_test.go +++ b/terraform/provider/litellm/resource_credential_crud_test.go @@ -39,7 +39,7 @@ func TestRetryCredentialRead_SuccessOnFirstAttempt(t *testing.T) { })) defer srv.Close() - client := NewClient(srv.URL, "test-key", true) + client := NewClient(ProviderConfig{APIBase: srv.URL, APIKey: "test-key", InsecureSkipVerify: true}) d := newTestResourceData(t, "test-cred") err := retryCredentialRead(d, client, 3) @@ -72,7 +72,7 @@ func TestRetryCredentialRead_SuccessAfterRetries(t *testing.T) { })) defer srv.Close() - client := NewClient(srv.URL, "test-key", true) + client := NewClient(ProviderConfig{APIBase: srv.URL, APIKey: "test-key", InsecureSkipVerify: true}) d := newTestResourceData(t, "test-cred") err := retryCredentialRead(d, client, 3) @@ -93,7 +93,7 @@ func TestRetryCredentialRead_ExhaustsRetries(t *testing.T) { })) defer srv.Close() - client := NewClient(srv.URL, "test-key", true) + client := NewClient(ProviderConfig{APIBase: srv.URL, APIKey: "test-key", InsecureSkipVerify: true}) d := newTestResourceData(t, "test-cred") err := retryCredentialRead(d, client, 2) @@ -118,7 +118,7 @@ func TestRetryCredentialRead_NonRetryableError(t *testing.T) { })) defer srv.Close() - client := NewClient(srv.URL, "test-key", true) + client := NewClient(ProviderConfig{APIBase: srv.URL, APIKey: "test-key", InsecureSkipVerify: true}) d := newTestResourceData(t, "test-cred") err := retryCredentialRead(d, client, 3) @@ -154,7 +154,7 @@ func TestRetryCredentialRead_IDRestoredBetweenRetries(t *testing.T) { })) defer srv.Close() - client := NewClient(srv.URL, "test-key", true) + client := NewClient(ProviderConfig{APIBase: srv.URL, APIKey: "test-key", InsecureSkipVerify: true}) d := newTestResourceData(t, "my-cred") err := retryCredentialRead(d, client, 2) @@ -172,7 +172,7 @@ func TestRetryCredentialRead_MaxRetriesOne(t *testing.T) { })) defer srv.Close() - client := NewClient(srv.URL, "test-key", true) + client := NewClient(ProviderConfig{APIBase: srv.URL, APIKey: "test-key", InsecureSkipVerify: true}) d := newTestResourceData(t, "test-cred") err := retryCredentialRead(d, client, 1) @@ -189,7 +189,7 @@ func TestRetryCredentialRead_ConnectionError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) srv.Close() - client := NewClient(srv.URL, "test-key", true) + client := NewClient(ProviderConfig{APIBase: srv.URL, APIKey: "test-key", InsecureSkipVerify: true}) d := newTestResourceData(t, "test-cred") err := retryCredentialRead(d, client, 1) diff --git a/terraform/provider/litellm/resource_team_member_test.go b/terraform/provider/litellm/resource_team_member_test.go index 156c4b3abfa..2abb39aee48 100644 --- a/terraform/provider/litellm/resource_team_member_test.go +++ b/terraform/provider/litellm/resource_team_member_test.go @@ -21,7 +21,7 @@ func TestTeamMemberUpdateSendsRole(t *testing.T) { })) defer srv.Close() - client := NewClient(srv.URL, "test-key", true) + client := NewClient(ProviderConfig{APIBase: srv.URL, APIKey: "test-key", InsecureSkipVerify: true}) d := schema.TestResourceDataRaw(t, resourceLiteLLMTeamMember().Schema, map[string]interface{}{ "team_id": "team-1", "user_id": "user-1", diff --git a/terraform/provider/litellm/resource_vector_store_crud_test.go b/terraform/provider/litellm/resource_vector_store_crud_test.go index 485ec54346d..241682e89ab 100644 --- a/terraform/provider/litellm/resource_vector_store_crud_test.go +++ b/terraform/provider/litellm/resource_vector_store_crud_test.go @@ -28,7 +28,7 @@ func TestVectorStoreReadDoesNotPersistServerLitellmParams(t *testing.T) { })) defer srv.Close() - client := NewClient(srv.URL, "test-key", true) + client := NewClient(ProviderConfig{APIBase: srv.URL, APIKey: "test-key", InsecureSkipVerify: true}) d := schema.TestResourceDataRaw(t, resourceLiteLLMVectorStore().Schema, map[string]interface{}{ "vector_store_name": "kb", "custom_llm_provider": "openai", diff --git a/terraform/provider/litellm/utils.go b/terraform/provider/litellm/utils.go index 01d8045300c..82d4d905555 100644 --- a/terraform/provider/litellm/utils.go +++ b/terraform/provider/litellm/utils.go @@ -79,8 +79,7 @@ func MakeRequest(client *Client, method, endpoint string, body interface{}) (*ht return nil, fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("x-api-key", client.APIKey) + client.applyRequestHeaders(req) return client.httpClient.Do(req) }