Skip to content

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:

  1. Length — rejected if longer than chat.MaxBaseURLLength (2 KiB).
  2. Control characters — any byte in 0x000x1F or 0x7F is rejected.
  3. Parse failureurl.Parse must succeed (the parse error is not echoed, since it may contain attacker input).
  4. Userinfo — any user[:pass]@host form is rejected unconditionally; credentials belong in Token, never the URL.
  5. Scheme — must be https. The test-only Config.AllowInsecureBaseURL bool permits http for httptest.Server targets; it is tagged json:"-" so config files cannot enable it.
  6. Host — the URL must include a host.
  7. Placeholder hostexample.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:

INFO chat provider initialised  provider=openai-compatible  endpoint_host=proxy.corp.internal

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:

  1. Direct tokenConfig.Token.
  2. Env-var referenceConfig.Credentials.Env names an env var; the value is read at runtime. The literal secret stays out of any config file.
  3. Keychain referenceConfig.Credentials.Keychain is a "service/account" pair resolved through the host-injected Config.Credentials.Lookup func. 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.
  4. LiteralConfig.Credentials.Key, an inline value (legacy; least preferred).
  5. Well-known env varANTHROPIC_API_KEY, OPENAI_API_KEY, or GEMINI_API_KEY, exported by the provider modules as EnvClaudeKey / EnvOpenAIKey / EnvGeminiKey. Each provider module passes its own fallback var into ResolveAPIKey.

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:

  1. 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.
  2. Path containment. After filepath.Clean + filepath.Abs, the resolved path is verified to lie beneath the store directory via filepath.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).