Scope --insecure TLS to explicit base URL, drop global transport override

Builds on s3rj1k's --insecure flag (#35) but limits insecure TLS to the
resolved inference client for an explicit --base-url, instead of mutating
http.DefaultTransport process-wide. Built-in providers, auth, and model
discovery keep normal certificate verification. Documents the flag in
the CLI reference.

Co-authored-by: s3rj1k <evasive.gyron@gmail.com>
This commit is contained in:
patriceckhart 2026-06-16 07:41:38 +02:00
parent e0ca3fdd3e
commit ab7fb37046
5 changed files with 150 additions and 90 deletions

View file

@ -141,6 +141,7 @@ zot --help
| `--model <id>` | Pick the model (see `--list-models`). |
| `--api-key <key>` | Override the API key. |
| `--base-url <url>` | Override the provider base URL (tests, self-hosted). |
| `--insecure` | Skip TLS certificate verification, only for the explicit `--base-url` endpoint (self-signed local/internal inference servers). Built-in providers, auth, and model discovery keep normal TLS verification. |
| `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). |
| `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |
| `--reasoning off\|minimum\|low\|medium\|high\|maximum` | Set thinking level on supported models (default: off). |

View file

@ -23,6 +23,7 @@ type Resolved struct {
AuthMethod string // "apikey" | "oauth" | "" (no credential yet)
AccountID string // ChatGPT account id (for openai oauth), "" otherwise
BaseURL string
InsecureTLS bool
CWD string
Reasoning string
Temperature *float32
@ -409,6 +410,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
model = fm.ID
}
explicitBaseURL := args.BaseURL != ""
// If the model defines a base URL (e.g. local ollama) and the
// user didn't pass --base-url, use the model's URL. For ollama,
// keep http://localhost:11434 as a fallback only after the model
@ -420,10 +423,10 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
args.BaseURL = "http://localhost:11434"
}
provider.InsecureSkipVerify = (args.InsecureTLS || cfg.Insecure) && args.BaseURL != ""
if provider.InsecureSkipVerify {
provider.ApplyInsecureTLS()
}
// Insecure TLS is intentionally scoped to explicit custom endpoints.
// Built-in provider base URLs, auth calls, and model discovery keep normal
// certificate verification even when --insecure is present.
insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
// If the model has a base URL, credentials are optional (local
// models like ollama don't need real API keys).
@ -512,6 +515,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
AuthMethod: method,
AccountID: accountID,
BaseURL: args.BaseURL,
InsecureTLS: insecureTLS,
CWD: args.CWD,
Reasoning: reasoning,
Temperature: temperature,
@ -639,92 +643,100 @@ func (r Resolved) NewClient() provider.Client {
if !r.HasCredential() {
panic("NewClient called without credential; check HasCredential first")
}
wrap := r.withHTTPClient
switch r.Provider {
case "ollama":
return provider.NewOpenAI(r.Credential, r.BaseURL)
return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
case "kimi":
// kimi-coding speaks anthropic-messages on api.kimi.com/coding.
// Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
inner := provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())
inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
if r.AuthMethod == "oauth" {
return r.wrapWithRefresh(inner)
}
return inner
case "moonshotai":
return provider.NewMoonshot(r.Credential, r.BaseURL)
return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
case "moonshotai-cn":
return provider.NewMoonshotCN(r.Credential, r.BaseURL)
return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
case "deepseek":
return provider.NewDeepSeek(r.Credential, r.BaseURL)
return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
case "openai":
return provider.NewOpenAI(r.Credential, r.BaseURL)
return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
case "openai-codex":
inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
return r.wrapWithRefresh(inner)
case "openai-responses":
// Public OpenAI Responses API (api.openai.com/v1/responses) via
// API key. Separate provider from `openai` (Chat Completions) and
// from `openai-codex` (ChatGPT subscription OAuth).
return provider.NewOpenAIResponses(r.Credential, r.BaseURL)
return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
case "google":
return provider.NewGemini(r.Credential, r.BaseURL)
return wrap(provider.NewGemini(r.Credential, r.BaseURL))
case "cerebras":
return provider.NewCerebras(r.Credential, r.BaseURL)
return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
case "groq":
return provider.NewGroq(r.Credential, r.BaseURL)
return wrap(provider.NewGroq(r.Credential, r.BaseURL))
case "xai":
return provider.NewXAI(r.Credential, r.BaseURL)
return wrap(provider.NewXAI(r.Credential, r.BaseURL))
case "together":
return provider.NewTogether(r.Credential, r.BaseURL)
return wrap(provider.NewTogether(r.Credential, r.BaseURL))
case "huggingface":
return provider.NewHuggingFace(r.Credential, r.BaseURL)
return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
case "openrouter":
return provider.NewOpenRouter(r.Credential, r.BaseURL)
return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
case "zai":
return provider.NewZAI(r.Credential, r.BaseURL)
return wrap(provider.NewZAI(r.Credential, r.BaseURL))
case "xiaomi":
return provider.NewXiaomi(r.Credential, r.BaseURL)
return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
case "xiaomi-token-plan-ams":
return provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL)
return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
case "xiaomi-token-plan-cn":
return provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL)
return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
case "xiaomi-token-plan-sgp":
return provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL)
return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
case "opencode":
return provider.NewOpenCode(r.Credential, r.BaseURL)
return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
case "opencode-go":
return provider.NewOpenCodeGo(r.Credential, r.BaseURL)
return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
case "minimax":
return provider.NewMinimaxAnthropic(r.Credential, r.BaseURL)
return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
case "minimax-cn":
return provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL)
return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
case "fireworks":
return provider.NewFireworksAnthropic(r.Credential, r.BaseURL)
return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
case "vercel-ai-gateway":
return provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL)
return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
case "mistral":
return provider.NewMistral(r.Credential, r.BaseURL)
return wrap(provider.NewMistral(r.Credential, r.BaseURL))
case "amazon-bedrock":
return provider.NewBedrock(r.Credential, r.BaseURL)
return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
case "google-vertex":
return provider.NewGoogleVertex(r.Credential, r.BaseURL)
return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
case "azure-openai-responses":
return provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL)
return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
case "github-copilot":
return provider.NewGithubCopilot(r.Credential, r.BaseURL)
return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
case "cloudflare-workers-ai":
return provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL)
return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
case "cloudflare-ai-gateway":
return provider.NewCloudflareAIGateway(r.Credential, r.BaseURL)
return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
default:
if r.AuthMethod == "oauth" {
inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
return r.wrapWithRefresh(inner)
}
return provider.NewAnthropic(r.Credential, r.BaseURL)
return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
}
}
func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
if !r.InsecureTLS {
return c
}
return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
}
// wrapWithRefresh wraps an OAuth client so the access token is
// refreshed automatically before each API call. Without this, long
// sessions (hours) silently fail when the 1-hour token expires.
@ -748,12 +760,12 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
factory := func(token string) provider.Client {
switch provName {
case "openai-codex":
return provider.NewOpenAICodex(token, accountID, baseURL)
return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
case "kimi":
// anthropic-messages on api.kimi.com/coding.
return provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders())
return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
default:
return provider.NewAnthropicOAuth(token, baseURL)
return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
}
}

View file

@ -1,6 +1,7 @@
package agent
import (
"net/http"
"os"
"path/filepath"
"strings"
@ -212,37 +213,35 @@ func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
}
}
func TestResolveInsecureOnlyWithCustomBaseURL(t *testing.T) {
orig := provider.InsecureSkipVerify
t.Cleanup(func() { provider.InsecureSkipVerify = orig })
func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) {
orig := http.DefaultTransport
t.Cleanup(func() { http.DefaultTransport = orig })
t.Setenv("ZOT_HOME", t.TempDir())
t.Setenv("OPENAI_API_KEY", "test-key")
// no --base-url: must stay false even with --insecure.
provider.InsecureSkipVerify = false
_, err := Resolve(Args{Provider: "openai", InsecureTLS: true}, false)
resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if provider.InsecureSkipVerify {
t.Fatal("InsecureSkipVerify must not be set without a custom base URL")
if resolved.InsecureTLS {
t.Fatal("InsecureTLS must not be set for built-in provider base URLs")
}
assertDefaultTransportStillSecure(t)
// --base-url + --insecure: must be true.
provider.InsecureSkipVerify = false
_, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if !provider.InsecureSkipVerify {
t.Fatal("InsecureSkipVerify must be set with --insecure and --base-url")
if !resolved.InsecureTLS {
t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url")
}
assertDefaultTransportStillSecure(t)
}
func TestResolveInsecureFromConfig(t *testing.T) {
orig := provider.InsecureSkipVerify
t.Cleanup(func() { provider.InsecureSkipVerify = orig })
func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
orig := http.DefaultTransport
t.Cleanup(func() { http.DefaultTransport = orig })
t.Setenv("ZOT_HOME", t.TempDir())
t.Setenv("OPENAI_API_KEY", "test-key")
@ -250,23 +249,32 @@ func TestResolveInsecureFromConfig(t *testing.T) {
t.Fatal(err)
}
// no --base-url: must stay false.
provider.InsecureSkipVerify = false
_, err := Resolve(Args{Provider: "openai"}, false)
resolved, err := Resolve(Args{Provider: "openai"}, false)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if provider.InsecureSkipVerify {
t.Fatal("InsecureSkipVerify must not be set without a custom base URL")
if resolved.InsecureTLS {
t.Fatal("InsecureTLS must not be set without a custom base URL")
}
assertDefaultTransportStillSecure(t)
// --base-url: must be true.
provider.InsecureSkipVerify = false
_, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if !provider.InsecureSkipVerify {
t.Fatal("InsecureSkipVerify must be set when config insecure=true and --base-url is provided")
if !resolved.InsecureTLS {
t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided")
}
assertDefaultTransportStillSecure(t)
}
func assertDefaultTransportStillSecure(t *testing.T) {
t.Helper()
tr, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return
}
if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify {
t.Fatal("http.DefaultTransport must not be made insecure")
}
}

View file

@ -5,12 +5,46 @@ import (
"net/http"
)
// InsecureSkipVerify disables TLS cert verification for inference connections.
var InsecureSkipVerify bool
// ApplyInsecureTLS replaces http.DefaultTransport to skip TLS cert verification.
func ApplyInsecureTLS() {
http.DefaultTransport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
// NewHTTPClient returns a provider HTTP client. When insecureTLS is true,
// only this client skips TLS certificate verification. The process-wide
// default transport is left untouched so auth, discovery, and other providers
// keep normal certificate validation.
func NewHTTPClient(insecureTLS bool) *http.Client {
if !insecureTLS {
return &http.Client{Timeout: 0}
}
tr, ok := http.DefaultTransport.(*http.Transport)
if ok {
tr = tr.Clone()
} else {
tr = &http.Transport{}
}
if tr.TLSClientConfig != nil {
tr.TLSClientConfig = tr.TLSClientConfig.Clone()
} else {
tr.TLSClientConfig = &tls.Config{}
}
tr.TLSClientConfig.InsecureSkipVerify = true //nolint:gosec
return &http.Client{Timeout: 0, Transport: tr}
}
// WithHTTPClient scopes an HTTP client to a concrete provider client.
// Unsupported clients are returned unchanged.
func WithHTTPClient(c Client, httpClient *http.Client) Client {
if httpClient == nil {
return c
}
switch v := c.(type) {
case *openaiClient:
v.http = httpClient
case *anthropicClient:
v.http = httpClient
case *geminiClient:
v.http = httpClient
case *bedrockClient:
v.http = httpClient
case *renamedClient:
v.inner = WithHTTPClient(v.inner, httpClient)
}
return c
}

View file

@ -6,22 +6,24 @@ import (
"testing"
)
func TestApplyInsecureTLSSetsDefaultTransport(t *testing.T) {
func TestNewHTTPClientDoesNotChangeDefaultTransport(t *testing.T) {
orig := http.DefaultTransport
t.Cleanup(func() { http.DefaultTransport = orig })
ApplyInsecureTLS()
tr, ok := http.DefaultTransport.(*http.Transport)
client := NewHTTPClient(true)
if http.DefaultTransport != orig {
t.Fatal("NewHTTPClient must not mutate http.DefaultTransport")
}
tr, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("expected *http.Transport, got %T", http.DefaultTransport)
t.Fatalf("expected *http.Transport, got %T", client.Transport)
}
if tr.TLSClientConfig == nil || !tr.TLSClientConfig.InsecureSkipVerify {
t.Fatal("expected InsecureSkipVerify=true in TLS config")
t.Fatal("expected scoped InsecureSkipVerify=true in TLS config")
}
}
func TestInsecureClientReachesTLSServer(t *testing.T) {
func TestScopedInsecureClientReachesTLSServer(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
@ -30,20 +32,23 @@ func TestInsecureClientReachesTLSServer(t *testing.T) {
orig := http.DefaultTransport
t.Cleanup(func() { http.DefaultTransport = orig })
client := &http.Client{}
if _, err := client.Get(srv.URL); err == nil {
t.Fatal("expected TLS error with default transport, got nil")
secureClient := NewHTTPClient(false)
if _, err := secureClient.Get(srv.URL); err == nil {
t.Fatal("expected TLS error with secure client, got nil")
}
ApplyInsecureTLS()
resp, err := client.Get(srv.URL)
insecureClient := NewHTTPClient(true)
resp, err := insecureClient.Get(srv.URL)
if err != nil {
t.Fatalf("request failed after ApplyInsecureTLS: %v", err)
t.Fatalf("request failed with scoped insecure client: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d", resp.StatusCode)
}
defaultClient := &http.Client{}
if _, err := defaultClient.Get(srv.URL); err == nil {
t.Fatal("default client must still reject self-signed TLS")
}
}