Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
context: ./authbridge
dockerfile: cmd/authbridge-proxy/Dockerfile
build_args: |
GO_BUILD_TAGS=exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc,exclude_plugin_tokenbroker
GO_BUILD_TAGS=exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_placeholderresolve,exclude_plugin_sparc,exclude_plugin_tokenbroker

# SPARC reflection service — the backend the `sparc` plugin calls.
# Deployed once per cluster via authbridge/sparc-service/deploy.
Expand Down Expand Up @@ -107,7 +107,7 @@
# Always use the computed tag
type=raw,value=${{ steps.tag.outputs.tag }}
# Add 'latest' tag for version tags, workflow_dispatch, and pushes to main
type=raw,value=latest,enable=${{ (github.ref_type == 'tag' && startsWith(github.ref_name, 'v')) || github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' }}

Check warning on line 110 in .github/workflows/build.yaml

View workflow job for this annotation

GitHub Actions / YAML Lint

110:151 [line-length] line too long (189 > 150 characters)

# 7. Build and push image
- name: Build and push ${{ matrix.image_config.name }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ jobs:
if: matrix.binary == 'authbridge-proxy'
run: |
TAGS="exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser"
TAGS="$TAGS,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc,exclude_plugin_tokenbroker"
TAGS="$TAGS,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_placeholderresolve,exclude_plugin_sparc,exclude_plugin_tokenbroker"
go build -v -tags "$TAGS" ./...
go test -v -race -cover -tags "$TAGS" ./...

Expand Down
2 changes: 1 addition & 1 deletion authbridge/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ podman build -f cmd/authbridge-proxy/Dockerfile -t authbridge:latest . # p
podman build -f cmd/authbridge-envoy/Dockerfile -t authbridge-envoy:latest . # envoy-sidecar
# authbridge-lite: the proxy Dockerfile built with exclude_plugin_* tags (auth-only)
podman build -f cmd/authbridge-proxy/Dockerfile \
--build-arg GO_BUILD_TAGS="exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_sparc,exclude_plugin_tokenbroker" \
--build-arg GO_BUILD_TAGS="exclude_plugin_a2aparser,exclude_plugin_ibac,exclude_plugin_inferenceparser,exclude_plugin_mcpparser,exclude_plugin_opa,exclude_plugin_placeholderresolve,exclude_plugin_sparc,exclude_plugin_tokenbroker" \
-t authbridge-lite:latest .
kind load docker-image authbridge:latest --name kagenti
kind load docker-image authbridge-envoy:latest --name kagenti
Expand Down
95 changes: 95 additions & 0 deletions authbridge/authlib/credinject/credinject.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

// Package credinject provides reusable primitives for resolving a credential
// and safely injecting it into an HTTP header — the shared core behind
// credential-injecting plugins (e.g. placeholder-resolve, and a future
// host-keyed injector). It is transport-agnostic and imports no plugin, so
// plugins depend on it rather than the reverse.
package credinject

import (
"context"
"net/http"
"path/filepath"
"strings"

"github.com/kagenti/kagenti-extensions/authbridge/authlib/config"
)

// Resolver maps a lookup key to a real credential value. ok is false when the
// key is unknown or unavailable — callers MUST fail closed on a false (never
// forward the unresolved key).
type Resolver interface {
Resolve(ctx context.Context, key string) (value string, ok bool)
}

// LifecycleResolver is implemented by resolvers that need background warm-up
// (e.g. a remote/cached source). Static resolvers omit it and are treated as
// always ready.
type LifecycleResolver interface {
Start(ctx context.Context) error
Ready() bool
Stop(ctx context.Context) error
}

// DenyResolver resolves nothing. It is the fail-closed default for an
// unconfigured source, so a missing source never silently resolves from an
// ambient source (e.g. the process environment).
type DenyResolver struct{}

// Resolve always fails closed.
func (DenyResolver) Resolve(context.Context, string) (string, bool) { return "", false }

// MapResolver resolves from an inline map. For tests/dev only — it holds
// cleartext credentials in process memory and is not a production source.
type MapResolver map[string]string

// Resolve returns the mapped value for key, if present.
func (m MapResolver) Resolve(_ context.Context, key string) (string, bool) {
v, ok := m[key]
return v, ok
}

// FileResolver resolves each key by reading <Dir>/<key> as a credential file
// (whitespace-trimmed via config.ReadCredentialFile). The key is path-
// contained: any key that is not a direct child filename of Dir (i.e. contains
// a separator or "..") is rejected, so the resolver is safe for arbitrary,
// caller-supplied keys such as a destination host.
type FileResolver struct{ Dir string }

// Resolve reads <Dir>/<key>, rejecting keys that would escape Dir.
func (f FileResolver) Resolve(_ context.Context, key string) (string, bool) {
if key == "" {
return "", false
}
p := filepath.Join(f.Dir, key)
// The joined path must be a direct child of Dir — this rejects separators,
// absolute keys, and "../" traversal regardless of the key's grammar.
if filepath.Dir(p) != filepath.Clean(f.Dir) {
return "", false
}
v, err := config.ReadCredentialFile(p)
if err != nil {
return "", false
}
return v, true
}

// SafeHeaderValue reports whether v is safe to place in an HTTP header value.
// It rejects CR, LF, and NUL to prevent header/response splitting (CWE-113)
// via a poisoned credential store.
func SafeHeaderValue(v string) bool {
return !strings.ContainsAny(v, "\r\n\x00")
}

// SafeSetHeader sets h[name]=value only when value is header-safe. It returns
// false and leaves h unmodified when value is unsafe, so the caller can fail
// closed rather than forward a poisoned value.
func SafeSetHeader(h http.Header, name, value string) bool {
if !SafeHeaderValue(value) {
return false
}
h.Set(name, value)
return true
}
83 changes: 83 additions & 0 deletions authbridge/authlib/credinject/credinject_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package credinject

import (
"context"
"net/http"
"os"
"path/filepath"
"testing"
)

func TestDenyResolverFailsClosed(t *testing.T) {
if v, ok := (DenyResolver{}).Resolve(context.Background(), "ANYTHING"); ok || v != "" {
t.Errorf("DenyResolver.Resolve = (%q, %v), want (\"\", false)", v, ok)
}
}

func TestMapResolver(t *testing.T) {
m := MapResolver{"K": "v"}
if v, ok := m.Resolve(context.Background(), "K"); !ok || v != "v" {
t.Errorf("hit = (%q,%v), want (v,true)", v, ok)
}
if _, ok := m.Resolve(context.Background(), "MISS"); ok {
t.Error("miss should be ok=false")
}
}

func TestFileResolverReadsDirectChild(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "TOKEN"), []byte(" sk-real\n"), 0o600); err != nil {
t.Fatal(err)
}
v, ok := FileResolver{Dir: dir}.Resolve(context.Background(), "TOKEN")
if !ok || v != "sk-real" { // ReadCredentialFile trims surrounding whitespace
t.Errorf("Resolve = (%q,%v), want (sk-real,true)", v, ok)
}
}

func TestFileResolverRejectsTraversalAndSeparators(t *testing.T) {
dir := t.TempDir()
// A secret one level up that must never be reachable.
if err := os.WriteFile(filepath.Join(filepath.Dir(dir), "SECRET"), []byte("leak"), 0o600); err != nil {
t.Fatal(err)
}
fr := FileResolver{Dir: dir}
for _, key := range []string{"../SECRET", "sub/TOKEN", "/etc/passwd", "", "a/../../SECRET"} {
if v, ok := fr.Resolve(context.Background(), key); ok {
t.Errorf("Resolve(%q) = (%q,true), want ok=false (containment)", key, v)
}
}
}

func TestSafeHeaderValue(t *testing.T) {
for _, ok := range []string{"sk-abc123", "Bearer x.y.z", ""} {
if !SafeHeaderValue(ok) {
t.Errorf("SafeHeaderValue(%q) = false, want true", ok)
}
}
for _, bad := range []string{"a\rb", "a\nb", "a\x00b", "tok\r\nInjected: 1"} {
if SafeHeaderValue(bad) {
t.Errorf("SafeHeaderValue(%q) = true, want false", bad)
}
}
}

func TestSafeSetHeader(t *testing.T) {
h := http.Header{}
if !SafeSetHeader(h, "Authorization", "Bearer ok") {
t.Fatal("safe value should set")
}
if got := h.Get("Authorization"); got != "Bearer ok" {
t.Errorf("Authorization = %q", got)
}
h2 := http.Header{}
if SafeSetHeader(h2, "Authorization", "Bearer bad\r\nX: y") {
t.Fatal("unsafe value should not set")
}
if h2.Get("Authorization") != "" {
t.Error("header must be unmodified on unsafe value")
}
}
2 changes: 1 addition & 1 deletion authbridge/authlib/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ require (
golang.org/x/sys v0.46.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171
google.golang.org/grpc v1.81.1
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)

Expand Down Expand Up @@ -71,7 +72,6 @@ require (
golang.org/x/sync v0.21.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
oras.land/oras-go/v2 v2.5.0 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)
21 changes: 21 additions & 0 deletions authbridge/authlib/openshell/buf.gen.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generates Go gRPC stubs from the trimmed OpenShell gateway proto. Only the two
# RPCs the placeholder-resolve gateway resolver calls are vendored (see
# proto/openshell.proto), so a single go_package override is needed. The proto
# declares no go_package, so managed mode injects it; module= output places it
# under genproto/openshellv1. Remote plugins are pinned to match authlib/go.mod.
#
# Regenerate: cd authlib/openshell && buf generate
version: v2
managed:
enabled: true
override:
- file_option: go_package
path: openshell.proto
value: github.com/kagenti/kagenti-extensions/authbridge/authlib/openshell/genproto/openshellv1
plugins:
- remote: buf.build/protocolbuffers/go:v1.36.6
out: genproto
opt: module=github.com/kagenti/kagenti-extensions/authbridge/authlib/openshell/genproto
- remote: buf.build/grpc/go:v1.5.1
out: genproto
opt: module=github.com/kagenti/kagenti-extensions/authbridge/authlib/openshell/genproto
3 changes: 3 additions & 0 deletions authbridge/authlib/openshell/buf.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
version: v2
modules:
- path: proto
Loading
Loading