Skip to content

feat(cli): support custom HTTP request headers per server (#59)#72

Merged
dvcdsys merged 2 commits into
developfrom
feat/cli-custom-headers
Jun 5, 2026
Merged

feat(cli): support custom HTTP request headers per server (#59)#72
dvcdsys merged 2 commits into
developfrom
feat/cli-custom-headers

Conversation

@dvcdsys
Copy link
Copy Markdown
Owner

@dvcdsys dvcdsys commented Jun 4, 2026

Fixes #59.

Problem

Self-hosting cix behind an authenticating reverse proxy / Zero-Trust gateway (Cloudflare Access, oauth2-proxy, Authelia, an SSO/mTLS-terminating LB) blocks the CLI and AI-agent tooling at the edge. There are two independent auth layers:

  1. Edge / proxy — decides whether the request reaches cix at all.
  2. Origin / cixAuthorization: Bearer cix_….

The browser dashboard satisfies (1) via interactive SSO, so it works. The CLI/agent send only the Bearer (layer 2) and have no way to satisfy layer 1 except an allow-listed IP — off-VPN they get a 302/403 before reaching cix.

Fix

Opt-in per-server custom HTTP headers, attached to every outbound request in addition to the cix Bearer — e.g. a Cloudflare Access service token (CF-Access-Client-Id / CF-Access-Client-Secret). The proxy validates and strips them at the edge, so the cix origin still only sees the Bearer — cix needs no knowledge of the proxy. Not Cloudflare-specific. No server-side changes.

servers:
  - name: default
    url: https://cix.example.com
    key: cix_xxx
    headers:                                    # NEW, opt-in
      CF-Access-Client-Id: "<client-id>.access"
      CF-Access-Client-Secret: "${CIX_CF_ACCESS_SECRET}"   # ${ENV} expansion
cix config set server.default.header.CF-Access-Client-Id "<id>.access"
cix config set server.default.header.CF-Access-Client-Secret '${CIX_CF_ACCESS_SECRET}'
cix config unset server.default.header.CF-Access-Client-Id

What changed

  • config (internal/config/config.go): ServerEntry.Headers (sensitive, omitempty) + SetServerHeader/UnsetServerHeader + validateHeader (RFC 7230 token names, anti-CRLF on values).
  • CLI (cmd/config.go): cix config set/unset server.<name>.header.<Name>; values are never echoed; cix config show and the TUI surface only a count (headers=N).
  • client (internal/client/): SetCustomHeaders + applyCustomHeaders, applied in do(), Health() (previously header-less — would be bounced at the edge), and the streaming path — always before cix-managed headers (Authorization/Content-Type/Accept) so a stray config can't clobber auth.
  • getClient (cmd/root.go): ${ENV}-expands values into a throwaway copy (never written back, mirroring the --api-url/--api-key override pattern), validates after expansion, and fails without echoing the value.
  • docs: CLI_CONFIG.md section + SECURITY_DEPLOYMENT.md cross-link.

Security / invariants

  • Header values are sensitive — never logged or printed (only (set)/count).
  • cix-managed headers always win; a custom header can't override Authorization.
  • Secrets stay in the environment (${VAR}); the config file keeps only the placeholder.
  • Opt-in: no headers configured = current behavior (old configs load unchanged via omitempty).

Tests

  • config: header round-trip + on-disk placeholder retention, unset (incl. last-header map drop), validateHeader table, invalid-input rejection.
  • client (httptest): do() and Health() send custom headers + Bearer; custom Authorization does not win; no-headers stays clean.
  • e2e (cmd): getClient expands ${ENV} to the wire while the config file keeps the placeholder; a CRLF-injected expanded value is rejected without echoing it.

go build ./..., go vet ./..., go test ./... all green (CLI module).

Scope

CLI-only — the /cix skill and AI-agent tooling shell out to cix, so this covers the "agent/plugin" path too. Out of scope (per issue): server-side CF-Access JWT validation, legacy api.headers migration, an ad-hoc --header flag, and a full in-TUI header editor (TUI shows the count; editing is via config set/YAML).

🤖 Generated with Claude Code

dvcdsys and others added 2 commits June 4, 2026 23:34
Self-hosting cix behind an authenticating reverse proxy / Zero-Trust
gateway (Cloudflare Access, oauth2-proxy, Authelia) blocks the CLI and
AI-agent tooling at the edge: they send only the cix Bearer and have no
way to satisfy the proxy off-VPN, so they get a 302/403 before reaching
cix. The browser dashboard passes via interactive SSO and is unaffected.

Add opt-in per-server custom headers attached to every outbound request
(including the previously header-less /health probe) in addition to the
cix Bearer — e.g. a Cloudflare Access service token. The proxy validates
and strips them at the edge, so cix needs no knowledge of the proxy.
No server-side changes.

- config: ServerEntry.Headers (sensitive, omitempty) + SetServerHeader /
  UnsetServerHeader + validateHeader (RFC 7230 token names, anti-CRLF).
- CLI: `cix config set/unset server.<name>.header.<Name>`; values never
  echoed; `cix config show` and TUI surface a count only.
- client: SetCustomHeaders + applyCustomHeaders, applied in do(), Health()
  and the streaming path, always BEFORE cix-managed headers so a stray
  config can't clobber Authorization.
- getClient: ${ENV}-expand values into a throwaway copy (never written
  back), validate after expansion, fail without echoing the value.
- docs: CLI_CONFIG.md section + SECURITY_DEPLOYMENT.md cross-link.

Tests: config round-trip/validation, client apply + Authorization
precedence, and an e2e getClient test proving ${ENV} reaches the wire
while the config file keeps the placeholder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address two footguns in the ${ENV} expansion of custom request headers,
both found in review of #59:

1. An unset (typo'd / unexported) variable used to expand to "" via
   os.ExpandEnv and be sent as an empty header — bouncing at the proxy
   with an opaque 403 and no hint. Now ExpandEnvHeaderValue treats a
   referenced-but-unset variable as a hard error that NAMES the variable
   (never the value). A set-but-empty var is still honored as intentional.
2. os.ExpandEnv mangled a literal `$` in a value (e.g. `pa$$word` →
   `pa`). The new expander supports `$$` as an escape for a literal `$`,
   so values containing `$` survive intact.

Also documents header-name canonicalization (CF- → Cf-, harmless since
HTTP header names are case-insensitive) in CLI_CONFIG.md.

Tests: ExpandEnvHeaderValue table (escapes, set/empty/unset) and a
getClient test proving an unset header env var fails loudly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dvcdsys dvcdsys merged commit 2c31336 into develop Jun 5, 2026
2 checks passed
@dvcdsys dvcdsys deleted the feat/cli-custom-headers branch June 5, 2026 12:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant