Skip to content
Open
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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ All notable changes to mcp-audit are documented in this file.

### Added

- HTTP security principals with explicit local identity and optional static
bearer authentication backed by constant-time token comparison.
- HTTP proxy request-body and header limits, complete server timeouts, optional
browser Origin validation, Host validation for DNS-rebinding protection, and
`mcp_audit_http_request_rejections_total` metrics.
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,13 @@ Prometheus metrics are available at `http://localhost:9091/metrics` by default.
| `proxy.retry.max_interval_ms` | `2000` | Maximum upstream retry backoff. |
| `proxy.client_id` | `claude-desktop` | Client identifier written to audit entries. |
| `proxy.server_id` | `filesystem` | Server identifier written to audit entries. |
| `auth.mode` | `none` | HTTP client authentication mode: `none` or `static_bearer`. None uses the explicitly configured static principal for local compatibility. |
| `auth.static.bearer_token` | empty | Pre-shared bearer token for `static_bearer` mode. Prefer `MCP_AUDIT_STATIC_BEARER_TOKEN`; minimum 32 bytes. |
| `auth.static.subject` | `local` | Trusted subject for the local or static-bearer principal. |
| `auth.static.client_id` | empty | Principal client identifier. Empty inherits `proxy.client_id`. |
| `auth.static.issuer` | `static` | Principal issuer label. |
| `auth.static.roles` | empty | Roles attached to the principal for later policy evaluation. |
| `auth.static.scopes` | empty | Scopes attached to the principal for later policy evaluation. |
| `audit.storage` | `jsonl` | Storage backend: `jsonl` or `sqlite`. |
| `audit.path` | `./audit.jsonl` | JSONL audit log path. |
| `audit.sqlite_path` | `./audit.db` | SQLite database path. |
Expand Down
3 changes: 2 additions & 1 deletion STABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ The following surfaces are covered by the stability policy starting at `v1.0.0`:
- The dashboard authentication keys (`dashboard.auth.token`) and dashboard bind address key (`dashboard.bind_address`) are part of the stable configuration surface.
- `proxy.forward_headers` is part of the stable configuration surface. Forwarded headers are passed verbatim to the trusted upstream HTTP MCP server, but HTTP headers are not recorded as dedicated fields in audit entries.
- `proxy.bind_address` and the `proxy.http.*` request-limit, timeout, Origin, and Host validation keys are part of the stable configuration surface.
- The `auth.mode` and `auth.static.*` keys are part of the stable configuration surface.
- The JSONL rotation keys (`audit.rotation.max_size_bytes`, `audit.rotation.max_files`, `audit.rotation.interval`, `audit.rotation.max_age_days`) are part of the stable configuration surface.

### CLI flags
Expand All @@ -42,7 +43,7 @@ The signature is computed over `id + timestamp + method + tool_name + params`. C

Metric names and label sets are stable. The `mcp_audit_*` prefix is reserved. New metrics are additive. A metric is never removed or renamed in a MINOR release.

`mcp_audit_http_request_rejections_total{reason}` counts requests rejected before upstream forwarding. Stable reasons are `body_too_large`, `origin`, and `host`.
`mcp_audit_http_request_rejections_total{reason}` counts requests rejected before upstream forwarding. Stable reasons are `body_too_large`, `origin`, `host`, and `authentication`.

### OTLP export attributes

Expand Down
75 changes: 75 additions & 0 deletions cmd/mcp-audit/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,78 @@ func TestValidateConfigAllowsAuthorizationForwardHeader(t *testing.T) {
}
}

func TestLoadConfigReadsStaticBearerAuthFromEnvironment(t *testing.T) {
t.Setenv("MCP_AUDIT_STATIC_BEARER_TOKEN", "0123456789abcdef0123456789abcdef")
configPath := filepath.Join(t.TempDir(), "config.yaml")
raw := []byte(`proxy:
transport: http
upstream: http://upstream.local
auth:
mode: static_bearer
static:
subject: alice
client_id: client-1
issuer: internal
roles: [operator]
scopes: [tools:read]
dashboard:
enabled: false
metrics:
path: /metrics
`)
if err := os.WriteFile(configPath, raw, 0600); err != nil {
t.Fatalf("write config: %v", err)
}
config, err := loadConfig(cliFlags{config: configPath, set: map[string]bool{}})
if err != nil {
t.Fatalf("load config: %v", err)
}
if config.Auth.Static.BearerToken != "0123456789abcdef0123456789abcdef" || config.Auth.Static.Subject != "alice" {
t.Fatalf("static auth config = %#v", config.Auth.Static)
}
authenticator, err := newAuthenticator(config)
if err != nil || authenticator == nil {
t.Fatalf("new authenticator = %T, err = %v", authenticator, err)
}
}

func TestValidateConfigRejectsInvalidAuth(t *testing.T) {
cases := []struct {
name string
configure func(*appConfig)
}{
{name: "unknown mode", configure: func(config *appConfig) { config.Auth.Mode = "magic" }},
{name: "missing subject", configure: func(config *appConfig) { config.Auth.Static.Subject = "" }},
{name: "token in none mode", configure: func(config *appConfig) { config.Auth.Static.BearerToken = "0123456789abcdef0123456789abcdef" }},
{name: "short static token", configure: func(config *appConfig) {
config.Proxy.Transport = "http"
config.Proxy.Upstream = "http://upstream.local"
config.Proxy.UpstreamTimeoutMS = proxy.DefaultHTTPUpstreamTimeoutMS
config.Proxy.HTTP.MaxRequestBodyBytes = proxy.DefaultHTTPMaxRequestBodyBytes
config.Proxy.HTTP.MaxHeaderBytes = proxy.DefaultHTTPMaxHeaderBytes
config.Proxy.HTTP.ReadHeaderTimeout = proxy.DefaultHTTPReadHeaderTimeout
config.Proxy.HTTP.ReadTimeout = proxy.DefaultHTTPReadTimeout
config.Proxy.HTTP.WriteTimeout = proxy.DefaultHTTPWriteTimeout
config.Proxy.HTTP.IdleTimeout = proxy.DefaultHTTPIdleTimeout
config.Auth.Mode = "static_bearer"
config.Auth.Static.BearerToken = "short"
}},
{name: "static bearer on stdio", configure: func(config *appConfig) {
config.Auth.Mode = "static_bearer"
config.Auth.Static.BearerToken = "0123456789abcdef0123456789abcdef"
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
config := minimalValidConfig()
tc.configure(&config)
if err := validateConfig(config); err == nil {
t.Fatal("expected authentication config error")
}
})
}
}

func TestValidateConfigRejectsInvalidDashboardConfig(t *testing.T) {
cases := []struct {
name string
Expand Down Expand Up @@ -523,6 +595,9 @@ func minimalValidConfig() appConfig {
config := appConfig{}
config.Proxy.Transport = "stdio"
config.Proxy.Upstream = "cat"
config.Proxy.ClientID = "test-client"
config.Auth.Mode = "none"
config.Auth.Static.Subject = "local"
config.Audit.Storage = "jsonl"
config.Metrics.Path = "/metrics"
config.Dashboard.Enabled = true
Expand Down
78 changes: 78 additions & 0 deletions cmd/mcp-audit/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (

"github.com/P4ST4S/mcp-audit/internal/audit"
"github.com/P4ST4S/mcp-audit/internal/audit/storage"
"github.com/P4ST4S/mcp-audit/internal/auth"
"github.com/P4ST4S/mcp-audit/internal/dashboard"
"github.com/P4ST4S/mcp-audit/internal/httpclient"
"github.com/P4ST4S/mcp-audit/internal/metrics"
Expand Down Expand Up @@ -65,6 +66,17 @@ type appConfig struct {
ClientID string `mapstructure:"client_id"`
ServerID string `mapstructure:"server_id"`
} `mapstructure:"proxy"`
Auth struct {
Mode string `mapstructure:"mode"`
Static struct {
BearerToken string `mapstructure:"bearer_token"`
Subject string `mapstructure:"subject"`
ClientID string `mapstructure:"client_id"`
Issuer string `mapstructure:"issuer"`
Roles []string `mapstructure:"roles"`
Scopes []string `mapstructure:"scopes"`
} `mapstructure:"static"`
} `mapstructure:"auth"`
Audit struct {
Storage string `mapstructure:"storage"`
Path string `mapstructure:"path"`
Expand Down Expand Up @@ -225,6 +237,11 @@ func main() {
Trace: traceExporter,
})
limiter := middleware.NewRateLimiter(config.Middleware.RateLimit.Enabled, config.Middleware.RateLimit.RequestsPerMinute)
authenticator, err := newAuthenticator(config)
if err != nil {
logger.Error("failed to initialize client authentication", "error", err)
os.Exit(1)
}

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
Expand Down Expand Up @@ -272,6 +289,7 @@ func main() {
IdleTimeout: config.Proxy.HTTP.IdleTimeout,
AllowedOrigins: config.Proxy.HTTP.AllowedOrigins,
AllowedHosts: config.Proxy.HTTP.AllowedHosts,
Authenticator: authenticator,
TLS: httpclient.TLSConfig{
CAFile: config.Proxy.TLS.CAFile,
ServerName: config.Proxy.TLS.ServerName,
Expand Down Expand Up @@ -383,6 +401,9 @@ func loadConfig(flags cliFlags) (appConfig, error) {
if err := v.Unmarshal(&config); err != nil {
return appConfig{}, fmt.Errorf("main: decode config: %w", err)
}
if token := os.Getenv("MCP_AUDIT_STATIC_BEARER_TOKEN"); token != "" {
config.Auth.Static.BearerToken = token
}
return config, validateConfig(config)
}

Expand Down Expand Up @@ -423,6 +444,13 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("proxy.retry.max_interval_ms", 2000)
v.SetDefault("proxy.client_id", "claude-desktop")
v.SetDefault("proxy.server_id", "filesystem")
v.SetDefault("auth.mode", auth.ModeNone)
v.SetDefault("auth.static.bearer_token", "")
v.SetDefault("auth.static.subject", "local")
v.SetDefault("auth.static.client_id", "")
v.SetDefault("auth.static.issuer", "static")
v.SetDefault("auth.static.roles", []string{})
v.SetDefault("auth.static.scopes", []string{})
v.SetDefault("audit.storage", "jsonl")
v.SetDefault("audit.path", "./audit.jsonl")
v.SetDefault("audit.sqlite_path", "./audit.db")
Expand Down Expand Up @@ -531,6 +559,9 @@ func validateConfig(config appConfig) error {
if (config.Proxy.TLS.ClientCertFile == "") != (config.Proxy.TLS.ClientKeyFile == "") {
return fmt.Errorf("main: proxy.tls.client_cert_file and proxy.tls.client_key_file must be configured together")
}
if err := validateAuthConfig(config); err != nil {
return err
}
if config.Metrics.Path == "" || !strings.HasPrefix(config.Metrics.Path, "/") {
return fmt.Errorf("main: metrics.path must start with /")
}
Expand Down Expand Up @@ -580,6 +611,53 @@ func validateConfig(config appConfig) error {
return nil
}

func validateAuthConfig(config appConfig) error {
if config.Auth.Static.ClientID == "" {
config.Auth.Static.ClientID = config.Proxy.ClientID
}
if config.Auth.Static.Subject == "" || config.Auth.Static.ClientID == "" {
return fmt.Errorf("main: auth.static.subject and client_id are required")
}
switch config.Auth.Mode {
case auth.ModeNone:
if config.Auth.Static.BearerToken != "" {
return fmt.Errorf("main: auth.static.bearer_token requires auth.mode=static_bearer")
}
case auth.ModeStaticBearer:
if config.Proxy.Transport != "http" {
return fmt.Errorf("main: auth.mode=static_bearer requires proxy.transport=http")
}
if len(config.Auth.Static.BearerToken) < 32 {
return fmt.Errorf("main: auth.static.bearer_token must be at least 32 bytes")
}
default:
return fmt.Errorf("main: auth.mode must be none or static_bearer")
}
return nil
}

func newAuthenticator(config appConfig) (auth.Authenticator, error) {
clientID := config.Auth.Static.ClientID
if clientID == "" {
clientID = config.Proxy.ClientID
}
principal := auth.Principal{
Subject: config.Auth.Static.Subject,
ClientID: clientID,
Issuer: config.Auth.Static.Issuer,
Roles: config.Auth.Static.Roles,
Scopes: config.Auth.Static.Scopes,
}
switch config.Auth.Mode {
case auth.ModeNone:
return auth.NewNoneAuthenticator(principal)
case auth.ModeStaticBearer:
return auth.NewStaticBearerAuthenticator(config.Auth.Static.BearerToken, principal)
default:
return nil, fmt.Errorf("main: unsupported auth mode %q", config.Auth.Mode)
}
}

func rotationConfigured(config appConfig) bool {
return config.Audit.Rotation.MaxSizeBytes > 0 ||
config.Audit.Rotation.MaxFiles > 0 ||
Expand Down
10 changes: 10 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ proxy:
client_id: "claude-desktop"
server_id: "filesystem"

auth:
mode: none
static:
bearer_token: ""
subject: local
client_id: ""
issuer: static
roles: []
scopes: []

audit:
storage: jsonl
path: ./audit.jsonl
Expand Down
79 changes: 79 additions & 0 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package auth

import (
"context"
"net/http"
)

const (
ModeNone = "none"
ModeStaticBearer = "static_bearer"
)

// Principal is the authenticated identity used by gateway controls.
type Principal struct {
Subject string
ClientID string
Issuer string
Roles []string
Scopes []string
Claims map[string]any
}

// Authenticator derives a trusted principal from an incoming HTTP request.
type Authenticator interface {
Authenticate(context.Context, *http.Request) (*Principal, error)
}

type principalContextKey struct{}

// WithPrincipal attaches an authenticated principal to a request context.
func WithPrincipal(ctx context.Context, principal *Principal) context.Context {
return context.WithValue(ctx, principalContextKey{}, clonePrincipal(principal))
}

// PrincipalFromContext returns a defensive copy of the request principal.
func PrincipalFromContext(ctx context.Context) (*Principal, bool) {
principal, ok := ctx.Value(principalContextKey{}).(*Principal)
if !ok || principal == nil {
return nil, false
}
return clonePrincipal(principal), true
}

func clonePrincipal(principal *Principal) *Principal {
if principal == nil {
return nil
}
cloned := *principal
cloned.Roles = append([]string(nil), principal.Roles...)
cloned.Scopes = append([]string(nil), principal.Scopes...)
if principal.Claims != nil {
cloned.Claims = make(map[string]any, len(principal.Claims))
for key, value := range principal.Claims {
cloned.Claims[key] = cloneClaimValue(value)
}
}
return &cloned
}

func cloneClaimValue(value any) any {
switch typed := value.(type) {
case map[string]any:
cloned := make(map[string]any, len(typed))
for key, item := range typed {
cloned[key] = cloneClaimValue(item)
}
return cloned
case []any:
cloned := make([]any, len(typed))
for index, item := range typed {
cloned[index] = cloneClaimValue(item)
}
return cloned
case []string:
return append([]string(nil), typed...)
default:
return value
}
}
Loading
Loading