Provider-endpoint & credential security¶
This page covers how chat protects the two things most likely to leak: the
provider endpoint (where credentials are sent) and the credentials
themselves. It also documents the snapshot-ID contract that keeps conversation
persistence path-traversal-free.
Provider-endpoint validation¶
Every chat.New call runs Config.BaseURL through ValidateBaseURL before
any credential leaves the process. A misconfigured or tampered endpoint fails
fast with a typed error instead of sending an Authorization header to an
attacker-controlled host.
Threat model. An operator who can influence config — a tampered file, a
hostile environment variable, a compromised setup wizard — could redirect
provider traffic to an attacker-controlled HTTPS host and capture the credential.
URLs carrying userinfo (https://user:pass@host/) are a related trap: some HTTP
libraries propagate userinfo as Basic auth, others log the full URL. Both paths
are closed at the chat boundary.
Rejection rules, applied cheapest-first, each wrapping the exported sentinel
chat.ErrInvalidBaseURL:
- Length — rejected if longer than
chat.MaxBaseURLLength(2 KiB). - Control characters — any byte in
0x00–0x1For0x7Fis rejected. - Parse failure —
url.Parsemust succeed (the parse error is not echoed, since it may contain attacker input). - Userinfo — any
user[:pass]@hostform is rejected unconditionally; credentials belong inToken, never the URL. - Scheme — must be
https. The test-onlyConfig.AllowInsecureBaseURLbool permitshttpforhttptest.Servertargets; it is taggedjson:"-"so config files cannot enable it. - Host — the URL must include a host.
- Placeholder host —
example.com,example.net,example.org,localhost.localdomain, and any subdomain of these, are rejected to catch scaffolding that was never replaced with a real endpoint.
An empty BaseURL is always accepted (providers with a fixed endpoint set
none). ProviderOpenAICompatible additionally requires a non-empty BaseURL.
Downstream tools that accept a BaseURL in their own surface (a CLI flag, env
var, or wizard) should call ValidateBaseURL at that boundary so misconfiguration
surfaces early rather than at chat.New time:
if err := chat.ValidateBaseURL(userInput, false); err != nil {
return fmt.Errorf("bad base URL: %w", err)
}
Discriminate a validation failure from other errors with
errors.Is(err, chat.ErrInvalidBaseURL).
Endpoint logging — host only¶
On every successful provider construction the client logs the endpoint host (and only the host) at INFO through the injected logger:
The URL path and query are never logged — they can carry provider-specific
identifiers. When BaseURL is empty (a fixed-endpoint provider) the line omits
endpoint_host entirely. This gives operators visibility into which host each
tool instance targets without ever surfacing a full URL.
Credential resolution¶
No credential is ever written to a log. Keys are resolved through one shared
five-step cascade (chat.ResolveAPIKey); the first non-empty source wins, and
every step is whitespace-trimmed so a half-configured value cannot mask a
fully-configured one below it:
- Direct token —
Config.Token. - Env-var reference —
Config.Credentials.Envnames an env var; the value is read at runtime. The literal secret stays out of any config file. - Keychain reference —
Config.Credentials.Keychainis a"service/account"pair resolved through the host-injectedConfig.Credentials.Lookupfunc. With no lookup wired (the default), this step is silently skipped, so env-var/literal/fallback still resolve. The lookup receives the request context, so a remote secret store honours the caller's deadline. - Literal —
Config.Credentials.Key, an inline value (legacy; least preferred). - Well-known env var —
ANTHROPIC_API_KEY,OPENAI_API_KEY, orGEMINI_API_KEY, exported by the provider modules asEnvClaudeKey/EnvOpenAIKey/EnvGeminiKey. Each provider module passes its own fallback var intoResolveAPIKey.
The keychain seam means the core carries no OS-keychain backend; a host injects one. Because every provider routes through the same function, the credential contract is auditable in one place, and the literal path (step 4) is the only one that ever puts a secret in a config file — a host can warn on it.
Failover logging & redaction¶
The fallback composite never logs a provider
error verbatim. Each transition emits exactly one WARN line — chat provider
failover — carrying the from/to provider enum names and a coarse
reason of status or network, derived from the error's classification, not
its message. A dropped non-primary provider likewise logs only its name and
endpoint host. The failover policy classifies errors structurally (HTTP status
via the extractor registry, or network/timeout error types) and never inspects
error strings, so an error message that echoes a URL or token cannot reach the
log through this path.
Snapshot-storage security¶
FileStore refuses to touch any filesystem path built from a snapshot
identifier that is not a canonical google/uuid string. Two independent defences
guard every Save, Load, and Delete:
- Shape validation. The ID must match
^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$(lowercase hex, canonical 8-4-4-4-12). This forecloses path traversal at the input layer — no.., no/, no\, no NUL bytes, no Unicode lookalikes. - Path containment. After
filepath.Clean+filepath.Abs, the resolved path is verified to lie beneath the store directory viafilepath.Rel— defence-in-depth against platform path quirks and any future regex relaxation.
A rejected ID returns an error wrapping the exported sentinel
chat.ErrInvalidSnapshotID, distinguishable from an I/O error via errors.Is.
If your application accepts snapshot IDs from an external source, validate at the
boundary with chat.ValidateSnapshotID rather than deferring to the store:
if err := chat.ValidateSnapshotID(id); err != nil {
return err // reject before any filesystem work
}
List is intentionally robust rather than strict: files whose names are not
canonical UUIDs are logged at DEBUG and skipped, so one stray file cannot break
enumeration. Snapshots built via chat.NewSnapshot always get a fresh
uuid.New() ID, so the validator is transparent for library-produced snapshots.
Stored snapshots never contain API tokens — they are excluded from Save — and
can optionally be encrypted at rest with AES-256-GCM (see
Persist conversations).
Related¶
- Choose & configure a provider — the credential cascade in practice.
- Fail over across providers — the failover log contract.
- Persist conversations — snapshot encryption and IDs.