From 2bc3ba8472b788ed6069d62c6bbe8d8c1613ecf8 Mon Sep 17 00:00:00 2001 From: Paolo Dettori Date: Fri, 10 Jul 2026 19:40:49 -0400 Subject: [PATCH 1/4] feat: add static-inject AuthBridge plugin for static credential injection Assisted-By: Claude (Anthropic AI) Signed-off-by: Paolo Dettori --- .../authlib/plugins/staticinject/plugin.go | 215 ++++++++++++++++++ .../plugins/staticinject/plugin_test.go | 208 +++++++++++++++++ .../authlib/plugins/staticinject/resolver.go | 82 +++++++ .../plugins/staticinject/resolver_test.go | 123 ++++++++++ .../authbridge-proxy/plugins_staticinject.go | 5 + authbridge/docs/plugin-reference.md | 57 +++++ 6 files changed, 690 insertions(+) create mode 100644 authbridge/authlib/plugins/staticinject/plugin.go create mode 100644 authbridge/authlib/plugins/staticinject/plugin_test.go create mode 100644 authbridge/authlib/plugins/staticinject/resolver.go create mode 100644 authbridge/authlib/plugins/staticinject/resolver_test.go create mode 100644 authbridge/cmd/authbridge-proxy/plugins_staticinject.go diff --git a/authbridge/authlib/plugins/staticinject/plugin.go b/authbridge/authlib/plugins/staticinject/plugin.go new file mode 100644 index 000000000..15164657f --- /dev/null +++ b/authbridge/authlib/plugins/staticinject/plugin.go @@ -0,0 +1,215 @@ +package staticinject + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" + "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins" +) + +// Source values for staticInjectConfig.Source. +const ( + sourceSecretDir = "secret_dir" + sourceMappings = "mappings" +) + +// KeyBy values for staticInjectConfig.KeyBy. +const ( + keyByHost = "host" + keyByStatic = "static" +) + +// staticInjectConfig is the plugin's local config schema. Named with a +// package-specific prefix (rather than the repo's common bare `config`) +// because this file also imports the shared authlib/config package under +// its own name. +// +// Field tags drive both runtime decoding (json) and operator-facing +// schema introspection (description / required / default / enum). See +// pipeline/schema.go for the consumer contract. +type staticInjectConfig struct { + // Source selects where credential values come from: "secret_dir" + // reads a file per key from SecretDir; "mappings" uses the inline + // Mappings map (tests/dev only — do not put real secrets in YAML). + Source string `json:"source" required:"true" description:"Credential source." enum:"secret_dir,mappings"` + + // SecretDir is the directory containing one file per credential + // key, used when source=secret_dir. + SecretDir string `json:"secret_dir" description:"Directory of per-key credential files; used when source=secret_dir."` + + // Mappings is an inline key->value credential map, used when + // source=mappings. Tests/dev only. + Mappings map[string]string `json:"mappings" description:"Inline key to credential map; used when source=mappings (tests/dev only)."` + + // KeyBy selects how the resolver key is derived: "host" (default) + // uses the outbound request's destination host; "static" always + // uses the configured Key. + KeyBy string `json:"key_by" description:"How to derive the resolver key." default:"host" enum:"host,static"` + + // Key is the single lookup key used when key_by=static. + Key string `json:"key" description:"Lookup key used when key_by=static."` + + // Placeholder, when set, requires the inbound bearer to equal this + // exact string before injection proceeds. Enforces that the + // workload never presents (and therefore never holds) a real + // credential — only the agreed-upon placeholder. + Placeholder string `json:"placeholder" description:"When set, only inject if the inbound bearer equals this exact placeholder string."` +} + +func (c *staticInjectConfig) applyDefaults() { + if c.KeyBy == "" { + c.KeyBy = keyByHost + } +} + +func (c *staticInjectConfig) validate() error { + switch c.Source { + case sourceSecretDir: + if c.SecretDir == "" { + return fmt.Errorf("secret_dir is required when source is %q", sourceSecretDir) + } + case sourceMappings: + if len(c.Mappings) == 0 { + return fmt.Errorf("mappings is required when source is %q", sourceMappings) + } + default: + return fmt.Errorf("source must be %q or %q, got %q", sourceSecretDir, sourceMappings, c.Source) + } + + switch c.KeyBy { + case keyByHost: + case keyByStatic: + if c.Key == "" { + return fmt.Errorf("key is required when key_by is %q", keyByStatic) + } + default: + return fmt.Errorf("key_by must be %q or %q, got %q", keyByHost, keyByStatic, c.KeyBy) + } + return nil +} + +// buildResolver constructs the Resolver implied by c.Source. Callers must +// have already validated c. +func buildResolver(c staticInjectConfig) Resolver { + switch c.Source { + case sourceSecretDir: + return FileResolver{Dir: c.SecretDir} + case sourceMappings: + return MapResolver(c.Mappings) + default: + // Unreachable after validate(), but return a resolver that + // always fails closed rather than a nil interface that would + // panic on use. + return MapResolver(nil) + } +} + +// StaticInject is an outbound plugin that swaps a placeholder credential on +// the Authorization header for a real static credential resolved from a +// configured source (a secret-mounted file or, for tests/dev, an inline +// map). Built once via Configure; the zero value is a deny-everything +// plugin (fail-closed) until Configure succeeds. +type StaticInject struct { + cfg staticInjectConfig + resolver Resolver +} + +// New constructs an unconfigured plugin. Configure must be called before +// the pipeline accepts traffic. +func New() *StaticInject { return &StaticInject{} } + +func init() { + plugins.RegisterPlugin("static-inject", func() pipeline.Plugin { return New() }) +} + +func (p *StaticInject) Name() string { return "static-inject" } + +func (p *StaticInject) Capabilities() pipeline.PluginCapabilities { + return pipeline.PluginCapabilities{ + Description: "Swaps a placeholder credential for a real static credential on outbound requests.", + } +} + +// ConfigSchema implements pipeline.SchemaProvider; surfaces field metadata +// to abctl edit templates and other config-aware tooling. +func (p *StaticInject) ConfigSchema() []pipeline.FieldSchema { + return pipeline.SchemaOf(staticInjectConfig{}) +} + +func (p *StaticInject) Configure(raw json.RawMessage) error { + var c staticInjectConfig + if len(raw) > 0 { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&c); err != nil { + return fmt.Errorf("static-inject config: %w", err) + } + } + c.applyDefaults() + if err := c.validate(); err != nil { + return fmt.Errorf("static-inject config: %w", err) + } + + resolver := buildResolver(c) + + // Commit cfg+resolver to the struct only after all validation and + // construction succeeds — a failed Configure leaves the plugin in + // its zero deny-state. + p.cfg = c + p.resolver = resolver + + return nil +} + +// OnRequest implements the fail-closed injection sequence: missing/mismatched +// placeholder, unresolved key, or an unsafe resolved value all deny with 401 +// and leave the Authorization header unchanged. Only a fully successful +// resolution swaps the header. +func (p *StaticInject) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.Action { + if p.resolver == nil { + return pipeline.DenyStatus(401, "static-inject.unconfigured", "static-inject plugin not configured") + } + + bearer := auth.ExtractBearer(pctx.Headers.Get("Authorization")) + if bearer == "" { + return pipeline.DenyStatus(401, "static-inject.missing-auth", "missing bearer token on outbound request") + } + + if p.cfg.Placeholder != "" && bearer != p.cfg.Placeholder { + return pipeline.DenyStatus(401, "static-inject.placeholder-mismatch", "workload did not present the configured placeholder") + } + + var key string + switch p.cfg.KeyBy { + case keyByStatic: + key = p.cfg.Key + default: // keyByHost + key = pctx.Host + } + + value, ok := p.resolver.Resolve(ctx, key) + if !ok { + return pipeline.DenyStatus(401, "static-inject.unresolved-key", "no credential available for the resolved key") + } + + if !SafeSetHeader(pctx.Headers, "Authorization", "Bearer "+value) { + return pipeline.DenyStatus(401, "static-inject.unsafe-value", "resolved credential value is unsafe to set as a header") + } + + return pipeline.Action{Type: pipeline.Continue} +} + +func (p *StaticInject) OnResponse(_ context.Context, _ *pipeline.Context) pipeline.Action { + return pipeline.Action{Type: pipeline.Continue} +} + +// Compile-time interface checks. +var ( + _ pipeline.Plugin = (*StaticInject)(nil) + _ pipeline.Configurable = (*StaticInject)(nil) + _ pipeline.SchemaProvider = (*StaticInject)(nil) +) diff --git a/authbridge/authlib/plugins/staticinject/plugin_test.go b/authbridge/authlib/plugins/staticinject/plugin_test.go new file mode 100644 index 000000000..e43060385 --- /dev/null +++ b/authbridge/authlib/plugins/staticinject/plugin_test.go @@ -0,0 +1,208 @@ +package staticinject + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" +) + +func TestSwapsPlaceholderForRealToken(t *testing.T) { + p := New() + cfg := `{ + "source": "mappings", + "mappings": {"api.example.com": "REAL"}, + "key_by": "host" + }` + if err := p.Configure(json.RawMessage(cfg)); err != nil { + t.Fatalf("Configure() error = %v", err) + } + + pctx := &pipeline.Context{ + Host: "api.example.com", + Headers: http.Header{ + "Authorization": []string{"Bearer PLACEHOLDER"}, + }, + } + + action := p.OnRequest(context.Background(), pctx) + + if action.Type != pipeline.Continue { + t.Fatalf("OnRequest() action.Type = %v, want %v (violation: %+v)", action.Type, pipeline.Continue, action.Violation) + } + if got := pctx.Headers.Get("Authorization"); got != "Bearer REAL" { + t.Errorf("Authorization header = %q, want %q", got, "Bearer REAL") + } +} + +func TestDenyOnMissingKey(t *testing.T) { + p := New() + cfg := `{ + "source": "mappings", + "mappings": {"other.example.com": "REAL"}, + "key_by": "host" + }` + if err := p.Configure(json.RawMessage(cfg)); err != nil { + t.Fatalf("Configure() error = %v", err) + } + + pctx := &pipeline.Context{ + Host: "api.example.com", + Headers: http.Header{ + "Authorization": []string{"Bearer PLACEHOLDER"}, + }, + } + + action := p.OnRequest(context.Background(), pctx) + + if action.Type != pipeline.Reject { + t.Fatalf("OnRequest() action.Type = %v, want %v (Reject)", action.Type, pipeline.Reject) + } + if got := pctx.Headers.Get("Authorization"); got != "Bearer PLACEHOLDER" { + t.Errorf("Authorization header = %q, want unmodified %q", got, "Bearer PLACEHOLDER") + } +} + +func TestDenyOnMissingAuth(t *testing.T) { + p := New() + cfg := `{ + "source": "mappings", + "mappings": {"api.example.com": "REAL"}, + "key_by": "host" + }` + if err := p.Configure(json.RawMessage(cfg)); err != nil { + t.Fatalf("Configure() error = %v", err) + } + + pctx := &pipeline.Context{ + Host: "api.example.com", + Headers: http.Header{}, + } + + action := p.OnRequest(context.Background(), pctx) + + if action.Type != pipeline.Reject { + t.Fatalf("OnRequest() action.Type = %v, want %v (Reject)", action.Type, pipeline.Reject) + } +} + +func TestDenyOnPlaceholderMismatch(t *testing.T) { + p := New() + cfg := `{ + "source": "mappings", + "mappings": {"api.example.com": "REAL"}, + "key_by": "host", + "placeholder": "EXPECTED_PLACEHOLDER" + }` + if err := p.Configure(json.RawMessage(cfg)); err != nil { + t.Fatalf("Configure() error = %v", err) + } + + pctx := &pipeline.Context{ + Host: "api.example.com", + Headers: http.Header{ + "Authorization": []string{"Bearer SOMETHING_ELSE"}, + }, + } + + action := p.OnRequest(context.Background(), pctx) + + if action.Type != pipeline.Reject { + t.Fatalf("OnRequest() action.Type = %v, want %v (Reject)", action.Type, pipeline.Reject) + } + if got := pctx.Headers.Get("Authorization"); got != "Bearer SOMETHING_ELSE" { + t.Errorf("Authorization header = %q, want unmodified %q", got, "Bearer SOMETHING_ELSE") + } +} + +func TestKeyByStatic(t *testing.T) { + p := New() + cfg := `{ + "source": "mappings", + "mappings": {"X": "REAL"}, + "key_by": "static", + "key": "X" + }` + if err := p.Configure(json.RawMessage(cfg)); err != nil { + t.Fatalf("Configure() error = %v", err) + } + + pctx := &pipeline.Context{ + Host: "api.example.com", + Headers: http.Header{ + "Authorization": []string{"Bearer PLACEHOLDER"}, + }, + } + + action := p.OnRequest(context.Background(), pctx) + + if action.Type != pipeline.Continue { + t.Fatalf("OnRequest() action.Type = %v, want %v (violation: %+v)", action.Type, pipeline.Continue, action.Violation) + } + if got := pctx.Headers.Get("Authorization"); got != "Bearer REAL" { + t.Errorf("Authorization header = %q, want %q", got, "Bearer REAL") + } +} + +// ============================================================================= +// Name / Capabilities / ConfigSchema / Configure sanity checks +// ============================================================================= + +func TestName(t *testing.T) { + p := New() + if got := p.Name(); got != "static-inject" { + t.Errorf("Name() = %q, want %q", got, "static-inject") + } +} + +func TestConfigure_Invalid(t *testing.T) { + tests := []struct { + name string + config string + wantErr string + }{ + {"missing source", `{}`, "source"}, + {"unknown source", `{"source": "bogus"}`, "source"}, + {"secret_dir missing dir", `{"source": "secret_dir"}`, "secret_dir"}, + {"mappings missing map", `{"source": "mappings"}`, "mappings"}, + {"bad key_by", `{"source": "mappings", "mappings": {"a": "b"}, "key_by": "bogus"}`, "key_by"}, + {"key_by static missing key", `{"source": "mappings", "mappings": {"a": "b"}, "key_by": "static"}`, "key"}, + {"unknown field", `{"source": "mappings", "mappings": {"a": "b"}, "unknown_field": 1}`, "static-inject config"}, + {"invalid json", `{invalid}`, "static-inject config"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := New() + err := p.Configure(json.RawMessage(tt.config)) + if err == nil { + t.Fatalf("Configure() error = nil, want error containing %q", tt.wantErr) + } + }) + } +} + +func TestConfigure_FailureLeavesZeroDenyState(t *testing.T) { + p := New() + // First, a valid configure so we know state would otherwise change. + if err := p.Configure(json.RawMessage(`{"source":"mappings","mappings":{"api.example.com":"REAL"},"key_by":"host"}`)); err != nil { + t.Fatalf("Configure() error = %v", err) + } + + // Now configure with an invalid payload; must fail and must NOT clobber + // the plugin into some half-built resolver-less state that panics. + p2 := New() + if err := p2.Configure(json.RawMessage(`{"source":"bogus"}`)); err == nil { + t.Fatalf("Configure() error = nil, want error") + } + + pctx := &pipeline.Context{ + Host: "api.example.com", + Headers: http.Header{"Authorization": []string{"Bearer PLACEHOLDER"}}, + } + action := p2.OnRequest(context.Background(), pctx) + if action.Type != pipeline.Reject { + t.Fatalf("OnRequest() on unconfigured plugin action.Type = %v, want %v (Reject, fail-closed)", action.Type, pipeline.Reject) + } +} diff --git a/authbridge/authlib/plugins/staticinject/resolver.go b/authbridge/authlib/plugins/staticinject/resolver.go new file mode 100644 index 000000000..5baa1e259 --- /dev/null +++ b/authbridge/authlib/plugins/staticinject/resolver.go @@ -0,0 +1,82 @@ +// Package staticinject implements the static-inject outbound AuthBridge +// plugin: it swaps a placeholder credential on the outbound Authorization +// header for a real static credential, so a model-influenced workload never +// holds the real secret. See plugin.go for the pipeline.Plugin +// implementation; this file holds the self-contained credential resolver +// and header-safety helpers. +package staticinject + +import ( + "context" + "net/http" + "path/filepath" + + credconfig "github.com/kagenti/kagenti-extensions/authbridge/authlib/config" +) + +// Resolver looks up a credential value by key. ok=false means the key is +// unknown or the value is unavailable — callers MUST fail closed and never +// forward the unresolved key. +type Resolver interface { + Resolve(ctx context.Context, key string) (value string, ok bool) +} + +// MapResolver is an inline map-backed Resolver. Intended for tests and +// local/dev configurations (source: mappings) — production deployments +// should use FileResolver so the real credential never lives in the +// pipeline YAML. +type MapResolver map[string]string + +// Resolve looks up key in the map. ok=false when key is absent. +func (m MapResolver) Resolve(_ context.Context, key string) (string, bool) { + v, ok := m[key] + return v, ok +} + +// FileResolver reads a credential from a file named key inside Dir. Values +// are whitespace-trimmed, matching the repo's config.ReadCredentialFile +// convention used by other plugins for secret_dir-style sources. +type FileResolver struct { + Dir string +} + +// Resolve reads /. It is path-contained: any key that would +// resolve outside Dir as a direct child (path separators, absolute paths, +// ".." traversal) is rejected with ok=false before any filesystem access. +// Any read error (missing file, permission, empty file) also yields +// ok=false — FileResolver never distinguishes "missing" from "unreadable" +// to callers, since both must fail closed identically. +func (r FileResolver) Resolve(_ context.Context, key string) (string, bool) { + joined := filepath.Join(r.Dir, key) + if filepath.Dir(joined) != filepath.Clean(r.Dir) { + return "", false + } + value, err := credconfig.ReadCredentialFile(joined) + if err != nil { + return "", false + } + return value, true +} + +// SafeHeaderValue reports whether v is safe to place in an HTTP header +// value — false if it contains CR, LF, or NUL, which could otherwise be +// used for header/response splitting (CWE-113). +func SafeHeaderValue(v string) bool { + for i := 0; i < len(v); i++ { + switch v[i] { + case '\r', '\n', 0: + return false + } + } + return true +} + +// SafeSetHeader sets h[name] = []string{value} only when SafeHeaderValue(value) +// is true. On an unsafe value it returns false and leaves h unmodified. +func SafeSetHeader(h http.Header, name, value string) bool { + if !SafeHeaderValue(value) { + return false + } + h.Set(name, value) + return true +} diff --git a/authbridge/authlib/plugins/staticinject/resolver_test.go b/authbridge/authlib/plugins/staticinject/resolver_test.go new file mode 100644 index 000000000..eb6a79722 --- /dev/null +++ b/authbridge/authlib/plugins/staticinject/resolver_test.go @@ -0,0 +1,123 @@ +package staticinject + +import ( + "context" + "net/http" + "os" + "path/filepath" + "testing" +) + +// ============================================================================= +// FileResolver tests +// ============================================================================= + +func TestFileResolver_ReadsAndTrims(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "api.example.com"), []byte("secret-value\n"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + r := FileResolver{Dir: dir} + value, ok := r.Resolve(context.Background(), "api.example.com") + if !ok { + t.Fatalf("Resolve() ok = false, want true") + } + if value != "secret-value" { + t.Errorf("Resolve() value = %q, want %q", value, "secret-value") + } +} + +func TestFileResolver_RejectsTraversal(t *testing.T) { + dir := t.TempDir() + // A file that a naive join could escape to, one directory above Dir. + if err := os.WriteFile(filepath.Join(filepath.Dir(dir), "x"), []byte("nope"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + defer os.Remove(filepath.Join(filepath.Dir(dir), "x")) + + r := FileResolver{Dir: dir} + value, ok := r.Resolve(context.Background(), "../x") + if ok { + t.Fatalf("Resolve() ok = true, want false (path traversal must be rejected)") + } + if value != "" { + t.Errorf("Resolve() value = %q, want empty on rejection", value) + } +} + +func TestFileResolver_MissingKey(t *testing.T) { + dir := t.TempDir() + + r := FileResolver{Dir: dir} + value, ok := r.Resolve(context.Background(), "does-not-exist") + if ok { + t.Fatalf("Resolve() ok = true, want false for missing key") + } + if value != "" { + t.Errorf("Resolve() value = %q, want empty on missing key", value) + } +} + +// ============================================================================= +// MapResolver tests +// ============================================================================= + +func TestMapResolver(t *testing.T) { + r := MapResolver{"api.example.com": "REAL"} + + value, ok := r.Resolve(context.Background(), "api.example.com") + if !ok { + t.Fatalf("Resolve() ok = false, want true for present key") + } + if value != "REAL" { + t.Errorf("Resolve() value = %q, want %q", value, "REAL") + } + + _, ok = r.Resolve(context.Background(), "missing.example.com") + if ok { + t.Fatalf("Resolve() ok = true, want false for absent key") + } +} + +// ============================================================================= +// SafeHeaderValue / SafeSetHeader tests +// ============================================================================= + +func TestSafeHeaderValue_RejectsCRLFNUL(t *testing.T) { + tests := []struct { + name string + value string + want bool + }{ + {"normal token", "Bearer abc123", true}, + {"contains CR", "abc\rdef", false}, + {"contains LF", "abc\ndef", false}, + {"contains NUL", "abc\x00def", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := SafeHeaderValue(tt.value); got != tt.want { + t.Errorf("SafeHeaderValue(%q) = %v, want %v", tt.value, got, tt.want) + } + }) + } +} + +func TestSafeSetHeader(t *testing.T) { + h := http.Header{} + if ok := SafeSetHeader(h, "Authorization", "Bearer safe-value"); !ok { + t.Fatalf("SafeSetHeader() ok = false, want true for a safe value") + } + if got := h.Get("Authorization"); got != "Bearer safe-value" { + t.Errorf("header value = %q, want %q", got, "Bearer safe-value") + } + + h2 := http.Header{"Authorization": []string{"Bearer original"}} + if ok := SafeSetHeader(h2, "Authorization", "evil\r\nX-Injected: 1"); ok { + t.Fatalf("SafeSetHeader() ok = true, want false for an unsafe value") + } + if got := h2.Get("Authorization"); got != "Bearer original" { + t.Errorf("header value = %q, want unmodified %q", got, "Bearer original") + } +} diff --git a/authbridge/cmd/authbridge-proxy/plugins_staticinject.go b/authbridge/cmd/authbridge-proxy/plugins_staticinject.go new file mode 100644 index 000000000..b1efc8df9 --- /dev/null +++ b/authbridge/cmd/authbridge-proxy/plugins_staticinject.go @@ -0,0 +1,5 @@ +//go:build !exclude_plugin_staticinject + +package main + +import _ "github.com/kagenti/kagenti-extensions/authbridge/authlib/plugins/staticinject" diff --git a/authbridge/docs/plugin-reference.md b/authbridge/docs/plugin-reference.md index b5f52b227..09edb7e76 100644 --- a/authbridge/docs/plugin-reference.md +++ b/authbridge/docs/plugin-reference.md @@ -1083,6 +1083,59 @@ Plugins keep ownership of: The IBAC plugin (`authlib/plugins/ibac/judge.go`) is the in-tree reference; copy its shape when adding a new LLM-using plugin. For what IBAC actually does end-to-end (threat model, configuration, deny-reason vocabulary), see [`ibac-plugin.md`](ibac-plugin.md). +## `static-inject`: static credential injection + +An outbound plugin (`authlib/plugins/staticinject`, registered name +`static-inject`) that swaps a placeholder credential on the outbound +`Authorization` header for a real static credential, so a +model-influenced workload never holds the real secret. Unlike +[Credential placeholder swap](#credential-placeholder-swap) below — +which mints and resolves a per-request `abph_` handle for a real user +token across two cooperating plugins — `static-inject` is a single +self-contained plugin that injects one operator-provisioned static +credential (an API key, a shared service credential) keyed by +destination host or a fixed key. The two mechanisms solve different +problems and do not interact. + +The workload authenticates outbound calls with an agreed-upon +placeholder string; `static-inject` verifies that placeholder (if +configured) and replaces it with the real credential resolved from +either a secret-mounted file or an inline map (tests/dev only). The +real credential is never present in the workload's own config or +environment. + +### Config + +| Field | Type | Required | Default | Notes | +|---|---|---|---|---| +| `source` | string | yes | — | `secret_dir` or `mappings`. | +| `secret_dir` | string | when `source: secret_dir` | — | Directory containing one file per credential key; read via the same whitespace-trimmed `config.ReadCredentialFile` convention other plugins use for file-sourced values (see [File-sourced values](#file-sourced-values)). | +| `mappings` | map[string]string | when `source: mappings` | — | Inline key→credential map. Tests/dev only — do not put real secrets in YAML. | +| `key_by` | string | no | `host` | `host` (resolve by the outbound request's destination host) or `static` (always use `key`). | +| `key` | string | when `key_by: static` | — | The single lookup key used when `key_by` is `static`. | +| `placeholder` | string | no | — | When set, injection only proceeds if the inbound bearer equals this exact string. | + +### Fail-closed + +`static-inject` denies (`401`, `static-inject.`) and leaves the +`Authorization` header unchanged whenever any step of the swap can't be +completed cleanly: + +- missing or unparsable bearer token (`static-inject.missing-auth`); +- `placeholder` is set and the inbound bearer doesn't match + (`static-inject.placeholder-mismatch`); +- the resolved key has no credential in the configured source + (`static-inject.unresolved-key`); +- the resolved value would be unsafe to place in a header — contains + `\r`, `\n`, or `\x00` — a CWE-113 header-splitting guard + (`static-inject.unsafe-value`); +- the plugin was never successfully `Configure`d + (`static-inject.unconfigured`). + +There is no partial-injection path: a denied request never forwards +the placeholder, and a successful swap only ever writes the fully +resolved real credential. + ## Credential placeholder swap An opt-in mode that keeps the **real** user token out of the agent. With @@ -1175,3 +1228,7 @@ interface — a **current limitation**, tracked as a future enhancement. - `authbridge/authlib/llmclient/` — chat-completions helper for plugins that call an LLM (see "Plugins making outbound LLM calls" above). +- `authbridge/authlib/plugins/staticinject/` — the `static-inject` + plugin (see [`static-inject`: static credential injection](#static-inject-static-credential-injection) + above); resolver + header-safety helpers are self-contained in this + package. From 201ecb71955989a7c7e96924561a9d8d92e5313f Mon Sep 17 00:00:00 2001 From: Paolo Dettori Date: Fri, 10 Jul 2026 20:46:08 -0400 Subject: [PATCH 2/4] test: strengthen static-inject tests (unsafe-value deny, atomic-commit, wantErr asserts) Assisted-By: Claude (Anthropic AI) Signed-off-by: Paolo Dettori --- .../plugins/staticinject/plugin_test.go | 58 ++++++++++++++++--- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/authbridge/authlib/plugins/staticinject/plugin_test.go b/authbridge/authlib/plugins/staticinject/plugin_test.go index e43060385..66e8f0bd6 100644 --- a/authbridge/authlib/plugins/staticinject/plugin_test.go +++ b/authbridge/authlib/plugins/staticinject/plugin_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "net/http" + "strings" "testing" "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" @@ -146,6 +147,37 @@ func TestKeyByStatic(t *testing.T) { } } +func TestDenyOnUnsafeCredentialValue(t *testing.T) { + p := New() + cfg := `{ + "source": "mappings", + "mappings": {"api.example.com": "evil\r\nX-Injected: 1"}, + "key_by": "host" + }` + if err := p.Configure(json.RawMessage(cfg)); err != nil { + t.Fatalf("Configure() error = %v", err) + } + + pctx := &pipeline.Context{ + Host: "api.example.com", + Headers: http.Header{ + "Authorization": []string{"Bearer PLACEHOLDER"}, + }, + } + + action := p.OnRequest(context.Background(), pctx) + + if action.Type != pipeline.Reject { + t.Fatalf("OnRequest() action.Type = %v, want %v (Reject, CWE-113 unsafe value)", action.Type, pipeline.Reject) + } + if got := pctx.Headers.Get("Authorization"); got != "Bearer PLACEHOLDER" { + t.Errorf("Authorization header = %q, want unmodified %q", got, "Bearer PLACEHOLDER") + } + if got := pctx.Headers.Get("Authorization"); strings.Contains(got, "X-Injected") { + t.Errorf("Authorization header = %q, must not contain injected value", got) + } +} + // ============================================================================= // Name / Capabilities / ConfigSchema / Configure sanity checks // ============================================================================= @@ -179,30 +211,38 @@ func TestConfigure_Invalid(t *testing.T) { if err == nil { t.Fatalf("Configure() error = nil, want error containing %q", tt.wantErr) } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("Configure() error = %q, want error containing %q", err.Error(), tt.wantErr) + } }) } } -func TestConfigure_FailureLeavesZeroDenyState(t *testing.T) { +func TestConfigure_FailurePreservesPreviousState(t *testing.T) { p := New() - // First, a valid configure so we know state would otherwise change. + // First, a valid configure so the plugin has a committed cfg+resolver. if err := p.Configure(json.RawMessage(`{"source":"mappings","mappings":{"api.example.com":"REAL"},"key_by":"host"}`)); err != nil { t.Fatalf("Configure() error = %v", err) } - // Now configure with an invalid payload; must fail and must NOT clobber - // the plugin into some half-built resolver-less state that panics. - p2 := New() - if err := p2.Configure(json.RawMessage(`{"source":"bogus"}`)); err == nil { + // Now reconfigure the SAME plugin with an invalid payload; it must fail + // and must NOT clobber the previously committed cfg/resolver. + if err := p.Configure(json.RawMessage(`{"source":"bogus"}`)); err == nil { t.Fatalf("Configure() error = nil, want error") } + // The plugin must still behave exactly as it did under the first + // (valid) configuration, proving Configure only commits state after + // successful validation. pctx := &pipeline.Context{ Host: "api.example.com", Headers: http.Header{"Authorization": []string{"Bearer PLACEHOLDER"}}, } - action := p2.OnRequest(context.Background(), pctx) - if action.Type != pipeline.Reject { - t.Fatalf("OnRequest() on unconfigured plugin action.Type = %v, want %v (Reject, fail-closed)", action.Type, pipeline.Reject) + action := p.OnRequest(context.Background(), pctx) + if action.Type != pipeline.Continue { + t.Fatalf("OnRequest() after failed reconfigure action.Type = %v, want %v (violation: %+v)", action.Type, pipeline.Continue, action.Violation) + } + if got := pctx.Headers.Get("Authorization"); got != "Bearer REAL" { + t.Errorf("Authorization header = %q, want %q (previous config preserved)", got, "Bearer REAL") } } From 13d806275b58c9874874a344e3ed977041664db9 Mon Sep 17 00:00:00 2001 From: Paolo Dettori Date: Sat, 11 Jul 2026 09:14:52 -0400 Subject: [PATCH 3/4] feat: static-inject configurable inject_header for x-api-key Add inject_header config option to staticInjectConfig so credentials can be written to a header other than Authorization (e.g. x-api-key for direct api.anthropic.com auth). Default remains 'Authorization', preserving byte-identical legacy behavior (Bearer scheme, Authorization not deleted). Any other configured header writes the raw credential value and removes the inbound Authorization header so a stale placeholder bearer never reaches the backend. Assisted-By: Claude (Anthropic AI) Signed-off-by: Paolo Dettori --- .../authlib/plugins/staticinject/plugin.go | 27 +++++++- .../plugins/staticinject/plugin_test.go | 63 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/authbridge/authlib/plugins/staticinject/plugin.go b/authbridge/authlib/plugins/staticinject/plugin.go index 15164657f..75a44698f 100644 --- a/authbridge/authlib/plugins/staticinject/plugin.go +++ b/authbridge/authlib/plugins/staticinject/plugin.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "github.com/kagenti/kagenti-extensions/authbridge/authlib/auth" "github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline" @@ -58,12 +59,22 @@ type staticInjectConfig struct { // workload never presents (and therefore never holds) a real // credential — only the agreed-upon placeholder. Placeholder string `json:"placeholder" description:"When set, only inject if the inbound bearer equals this exact placeholder string."` + + // InjectHeader is the request header the resolved credential is written to. + // Default "Authorization" (written as "Bearer ", preserving legacy + // behavior). Any other value (e.g. "x-api-key") writes the RAW credential + // value and removes the inbound Authorization header so a stale placeholder + // bearer never reaches the backend. + InjectHeader string `json:"inject_header" description:"Header to inject the credential into. Default Authorization (Bearer scheme); any other value writes the raw value and drops Authorization." default:"Authorization"` } func (c *staticInjectConfig) applyDefaults() { if c.KeyBy == "" { c.KeyBy = keyByHost } + if c.InjectHeader == "" { + c.InjectHeader = "Authorization" + } } func (c *staticInjectConfig) validate() error { @@ -196,9 +207,23 @@ func (p *StaticInject) OnRequest(ctx context.Context, pctx *pipeline.Context) pi return pipeline.DenyStatus(401, "static-inject.unresolved-key", "no credential available for the resolved key") } - if !SafeSetHeader(pctx.Headers, "Authorization", "Bearer "+value) { + target := p.cfg.InjectHeader + var headerVal string + if strings.EqualFold(target, "Authorization") { + headerVal = "Bearer " + value + } else { + headerVal = value // raw credential, e.g. x-api-key + } + if !SafeSetHeader(pctx.Headers, target, headerVal) { return pipeline.DenyStatus(401, "static-inject.unsafe-value", "resolved credential value is unsafe to set as a header") } + // When injecting into a non-Authorization header, drop the inbound + // Authorization so the placeholder bearer never reaches the backend + // (some backends reject a request carrying both a valid key and a + // bogus Authorization). + if !strings.EqualFold(target, "Authorization") { + pctx.Headers.Del("Authorization") + } return pipeline.Action{Type: pipeline.Continue} } diff --git a/authbridge/authlib/plugins/staticinject/plugin_test.go b/authbridge/authlib/plugins/staticinject/plugin_test.go index 66e8f0bd6..ca2e8a8a3 100644 --- a/authbridge/authlib/plugins/staticinject/plugin_test.go +++ b/authbridge/authlib/plugins/staticinject/plugin_test.go @@ -118,6 +118,69 @@ func TestDenyOnPlaceholderMismatch(t *testing.T) { } } +func TestInjectHeader_XAPIKey(t *testing.T) { + p := New() + cfg := `{ + "source": "mappings", + "mappings": {"api.example.com": "REALKEY"}, + "key_by": "host", + "inject_header": "x-api-key" + }` + if err := p.Configure(json.RawMessage(cfg)); err != nil { + t.Fatalf("Configure() error = %v", err) + } + + pctx := &pipeline.Context{ + Host: "api.example.com", + Headers: http.Header{ + "Authorization": []string{"Bearer PLACEHOLDER"}, + }, + } + + action := p.OnRequest(context.Background(), pctx) + + if action.Type != pipeline.Continue { + t.Fatalf("OnRequest() action.Type = %v, want %v (violation: %+v)", action.Type, pipeline.Continue, action.Violation) + } + if got := pctx.Headers.Get("x-api-key"); got != "REALKEY" { + t.Errorf("x-api-key header = %q, want %q (raw value, no Bearer prefix)", got, "REALKEY") + } + if got := pctx.Headers.Get("Authorization"); got != "" { + t.Errorf("Authorization header = %q, want removed (empty)", got) + } +} + +func TestInjectHeader_DefaultUnchanged(t *testing.T) { + p := New() + cfg := `{ + "source": "mappings", + "mappings": {"api.example.com": "REAL"}, + "key_by": "host" + }` + if err := p.Configure(json.RawMessage(cfg)); err != nil { + t.Fatalf("Configure() error = %v", err) + } + + pctx := &pipeline.Context{ + Host: "api.example.com", + Headers: http.Header{ + "Authorization": []string{"Bearer PLACEHOLDER"}, + }, + } + + action := p.OnRequest(context.Background(), pctx) + + if action.Type != pipeline.Continue { + t.Fatalf("OnRequest() action.Type = %v, want %v (violation: %+v)", action.Type, pipeline.Continue, action.Violation) + } + if got := pctx.Headers.Get("Authorization"); got != "Bearer REAL" { + t.Errorf("Authorization header = %q, want %q (default inject_header behavior unchanged)", got, "Bearer REAL") + } + if _, ok := pctx.Headers["X-Api-Key"]; ok { + t.Errorf("x-api-key header should not be set when inject_header defaults to Authorization") + } +} + func TestKeyByStatic(t *testing.T) { p := New() cfg := `{ From adf5d2afc8ef2332eae4b2be02cdeecd1e6f6a0a Mon Sep 17 00:00:00 2001 From: Paolo Dettori Date: Mon, 13 Jul 2026 12:13:40 -0400 Subject: [PATCH 4/4] fix: fail closed on empty resolved credential in static-inject ReadCredentialFile trims a whitespace-only secret file to "" and returns ok, and an inline mapping value may be "". The OnRequest guard only checked !ok, so an empty credential was forwarded as "Bearer " (or an empty raw header) instead of denying. Add a value == "" guard alongside !ok and lock it in with a test covering both an empty inline mapping and a whitespace-only secret file. Addresses review feedback from @huang195 on PR #655. Assisted-By: Claude (Anthropic AI) Signed-off-by: Paolo Dettori --- .../authlib/plugins/staticinject/plugin.go | 6 +- .../plugins/staticinject/plugin_test.go | 61 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/authbridge/authlib/plugins/staticinject/plugin.go b/authbridge/authlib/plugins/staticinject/plugin.go index 75a44698f..08a897688 100644 --- a/authbridge/authlib/plugins/staticinject/plugin.go +++ b/authbridge/authlib/plugins/staticinject/plugin.go @@ -203,7 +203,11 @@ func (p *StaticInject) OnRequest(ctx context.Context, pctx *pipeline.Context) pi } value, ok := p.resolver.Resolve(ctx, key) - if !ok { + // Fail closed on an empty credential as well: ReadCredentialFile trims a + // whitespace-only secret file to "" and returns ok, and an inline mapping + // may hold "". Without this guard either would forward an empty + // "Bearer " / raw header instead of denying. + if !ok || value == "" { return pipeline.DenyStatus(401, "static-inject.unresolved-key", "no credential available for the resolved key") } diff --git a/authbridge/authlib/plugins/staticinject/plugin_test.go b/authbridge/authlib/plugins/staticinject/plugin_test.go index ca2e8a8a3..6623d3f26 100644 --- a/authbridge/authlib/plugins/staticinject/plugin_test.go +++ b/authbridge/authlib/plugins/staticinject/plugin_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "net/http" + "os" + "path/filepath" "strings" "testing" @@ -241,6 +243,65 @@ func TestDenyOnUnsafeCredentialValue(t *testing.T) { } } +// TestDenyOnEmptyResolvedValue locks in fail-closed behavior when the resolver +// returns an empty credential. ReadCredentialFile trims a whitespace-only secret +// file to "" (ok=true), and an inline mapping may hold "" — both must deny rather +// than forward an empty "Bearer " header. +func TestDenyOnEmptyResolvedValue(t *testing.T) { + t.Run("empty inline mapping value", func(t *testing.T) { + p := New() + cfg := `{ + "source": "mappings", + "mappings": {"api.example.com": ""}, + "key_by": "host" + }` + if err := p.Configure(json.RawMessage(cfg)); err != nil { + t.Fatalf("Configure() error = %v", err) + } + + pctx := &pipeline.Context{ + Host: "api.example.com", + Headers: http.Header{"Authorization": []string{"Bearer PLACEHOLDER"}}, + } + action := p.OnRequest(context.Background(), pctx) + if action.Type != pipeline.Reject { + t.Fatalf("OnRequest() action.Type = %v, want %v (Reject, empty credential)", action.Type, pipeline.Reject) + } + if got := pctx.Headers.Get("Authorization"); got != "Bearer PLACEHOLDER" { + t.Errorf("Authorization header = %q, want unmodified %q", got, "Bearer PLACEHOLDER") + } + }) + + t.Run("whitespace-only secret file", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "api.example.com"), []byte(" \n"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + p := New() + cfg := `{ + "source": "secret_dir", + "secret_dir": "` + dir + `", + "key_by": "host" + }` + if err := p.Configure(json.RawMessage(cfg)); err != nil { + t.Fatalf("Configure() error = %v", err) + } + + pctx := &pipeline.Context{ + Host: "api.example.com", + Headers: http.Header{"Authorization": []string{"Bearer PLACEHOLDER"}}, + } + action := p.OnRequest(context.Background(), pctx) + if action.Type != pipeline.Reject { + t.Fatalf("OnRequest() action.Type = %v, want %v (Reject, whitespace-only file)", action.Type, pipeline.Reject) + } + if got := pctx.Headers.Get("Authorization"); got != "Bearer PLACEHOLDER" { + t.Errorf("Authorization header = %q, want unmodified %q", got, "Bearer PLACEHOLDER") + } + }) +} + // ============================================================================= // Name / Capabilities / ConfigSchema / Configure sanity checks // =============================================================================