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

- Optional incoming TLS for the HTTP proxy, with a TLS 1.2 minimum and
startup validation of the configured certificate and private key.
- 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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ Prometheus metrics are available at `http://localhost:9091/metrics` by default.
| `proxy.http.allowed_origins` | empty | Optional exact browser Origin allowlist. Requests without `Origin` remain valid non-browser clients. |
| `proxy.http.allowed_hosts` | empty | Optional Host allowlist for DNS-rebinding protection. Entries are hostnames or IP addresses with optional ports. |
| `proxy.forward_headers` | empty | Request headers allowed to bypass the default upstream strip list. Use `["Authorization"]` only when the upstream MCP HTTP server requires bearer-token auth. |
| `proxy.tls.enabled` | `false` | Serve incoming proxy connections over TLS. Requires `proxy.tls.cert_file` and `proxy.tls.key_file`. |
| `proxy.tls.cert_file` | empty | PEM certificate chain for incoming TLS connections. |
| `proxy.tls.key_file` | empty | PEM private key for incoming TLS connections. |
| `proxy.tls.ca_file` | empty | Optional CA bundle used to verify an HTTPS upstream MCP server. |
| `proxy.tls.server_name` | empty | Optional TLS server name override for the upstream MCP server. |
| `proxy.tls.insecure_skip_verify` | `false` | Skip upstream TLS certificate verification. Intended only for local testing. |
Expand Down
1 change: 1 addition & 0 deletions 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 incoming TLS keys `proxy.tls.enabled`, `proxy.tls.cert_file`, and `proxy.tls.key_file` 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 Down
34 changes: 34 additions & 0 deletions cmd/mcp-audit/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,14 @@ func TestLoadConfigReadsUpstreamTimeout(t *testing.T) {
func TestLoadConfigReadsProxyTLSAndRetry(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yaml")
raw := []byte(`proxy:
transport: http
upstream: https://upstream.local
forward_headers:
- Authorization
tls:
enabled: true
cert_file: /tmp/server.crt
key_file: /tmp/server.key
ca_file: /tmp/ca.pem
server_name: mcp.internal
insecure_skip_verify: true
Expand All @@ -131,6 +135,9 @@ func TestLoadConfigReadsProxyTLSAndRetry(t *testing.T) {
if config.Proxy.TLS.CAFile != "/tmp/ca.pem" {
t.Fatalf("ca_file = %q", config.Proxy.TLS.CAFile)
}
if !config.Proxy.TLS.Enabled || config.Proxy.TLS.CertFile != "/tmp/server.crt" || config.Proxy.TLS.KeyFile != "/tmp/server.key" {
t.Fatalf("incoming tls = %t/%q/%q", config.Proxy.TLS.Enabled, config.Proxy.TLS.CertFile, config.Proxy.TLS.KeyFile)
}
if config.Proxy.TLS.ServerName != "mcp.internal" {
t.Fatalf("server_name = %q", config.Proxy.TLS.ServerName)
}
Expand Down Expand Up @@ -327,6 +334,33 @@ func TestValidateConfigRejectsPartialProxyMTLSConfig(t *testing.T) {
}
}

func TestValidateConfigRejectsInvalidIncomingTLS(t *testing.T) {
cases := []struct {
name string
transport string
enabled bool
certFile string
keyFile string
}{
{name: "enabled without certificate", transport: "http", enabled: true},
{name: "enabled without key", transport: "http", enabled: true, certFile: "server.crt"},
{name: "certificate while disabled", transport: "http", certFile: "server.crt", keyFile: "server.key"},
{name: "enabled on stdio", transport: "stdio", enabled: true, certFile: "server.crt", keyFile: "server.key"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
config := minimalValidHTTPConfig()
config.Proxy.Transport = tc.transport
config.Proxy.TLS.Enabled = tc.enabled
config.Proxy.TLS.CertFile = tc.certFile
config.Proxy.TLS.KeyFile = tc.keyFile
if err := validateConfig(config); err == nil {
t.Fatal("expected incoming TLS validation error, got nil")
}
})
}
}

func TestValidateConfigRejectsInvalidAuditRotation(t *testing.T) {
cases := []struct {
name string
Expand Down
22 changes: 21 additions & 1 deletion cmd/mcp-audit/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ type appConfig struct {
AllowedHosts []string `mapstructure:"allowed_hosts"`
} `mapstructure:"http"`
TLS struct {
Enabled bool `mapstructure:"enabled"`
CertFile string `mapstructure:"cert_file"`
KeyFile string `mapstructure:"key_file"`
CAFile string `mapstructure:"ca_file"`
ServerName string `mapstructure:"server_name"`
InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"`
Expand Down Expand Up @@ -272,6 +275,11 @@ func main() {
IdleTimeout: config.Proxy.HTTP.IdleTimeout,
AllowedOrigins: config.Proxy.HTTP.AllowedOrigins,
AllowedHosts: config.Proxy.HTTP.AllowedHosts,
ServerTLS: proxy.HTTPServerTLSConfig{
Enabled: config.Proxy.TLS.Enabled,
CertFile: config.Proxy.TLS.CertFile,
KeyFile: config.Proxy.TLS.KeyFile,
},
TLS: httpclient.TLSConfig{
CAFile: config.Proxy.TLS.CAFile,
ServerName: config.Proxy.TLS.ServerName,
Expand All @@ -296,7 +304,7 @@ func main() {
logger.Error("failed to create http proxy", "error", err)
os.Exit(1)
}
logger.Info("http proxy listening", "bind_address", config.Proxy.BindAddress, "port", config.Proxy.Port, "upstream", config.Proxy.Upstream)
logger.Info("http proxy listening", "bind_address", config.Proxy.BindAddress, "port", config.Proxy.Port, "tls", config.Proxy.TLS.Enabled, "upstream", config.Proxy.Upstream)
err = httpProxy.ListenAndServe(ctx)
default:
err = fmt.Errorf("main: unknown transport %q", config.Proxy.Transport)
Expand Down Expand Up @@ -414,6 +422,9 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("proxy.http.allowed_origins", []string{})
v.SetDefault("proxy.http.allowed_hosts", []string{})
v.SetDefault("proxy.tls.ca_file", "")
v.SetDefault("proxy.tls.enabled", false)
v.SetDefault("proxy.tls.cert_file", "")
v.SetDefault("proxy.tls.key_file", "")
v.SetDefault("proxy.tls.server_name", "")
v.SetDefault("proxy.tls.insecure_skip_verify", false)
v.SetDefault("proxy.tls.client_cert_file", "")
Expand Down Expand Up @@ -531,6 +542,15 @@ 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 config.Proxy.TLS.Enabled && config.Proxy.Transport != "http" {
return fmt.Errorf("main: proxy.tls.enabled requires proxy.transport=http")
}
if config.Proxy.TLS.Enabled && (config.Proxy.TLS.CertFile == "" || config.Proxy.TLS.KeyFile == "") {
return fmt.Errorf("main: proxy.tls.cert_file and proxy.tls.key_file are required when incoming TLS is enabled")
}
if !config.Proxy.TLS.Enabled && (config.Proxy.TLS.CertFile != "" || config.Proxy.TLS.KeyFile != "") {
return fmt.Errorf("main: proxy.tls.cert_file and proxy.tls.key_file require proxy.tls.enabled=true")
}
if config.Metrics.Path == "" || !strings.HasPrefix(config.Metrics.Path, "/") {
return fmt.Errorf("main: metrics.path must start with /")
}
Expand Down
3 changes: 3 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ proxy:
- 127.0.0.1
- "::1"
tls:
enabled: false
cert_file: ""
key_file: ""
ca_file: ""
server_name: ""
insecure_skip_verify: false
Expand Down
44 changes: 43 additions & 1 deletion internal/proxy/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -48,6 +49,13 @@ type HTTPRetryConfig struct {
MaxIntervalMS int
}

// HTTPServerTLSConfig configures TLS for incoming proxy connections.
type HTTPServerTLSConfig struct {
Enabled bool
CertFile string
KeyFile string
}

// HTTPConfig configures an HTTP MCP proxy.
type HTTPConfig struct {
Upstream string
Expand All @@ -64,6 +72,7 @@ type HTTPConfig struct {
IdleTimeout time.Duration
AllowedOrigins []string
AllowedHosts []string
ServerTLS HTTPServerTLSConfig
TLS httpclient.TLSConfig
Retry HTTPRetryConfig
Audit *audit.Logger
Expand All @@ -84,6 +93,7 @@ type HTTPProxy struct {
forwardHeaders map[string]struct{}
allowedOrigins map[string]struct{}
allowedHosts map[string]struct{}
serverTLS *tls.Config
}

// NewHTTPProxy creates an HTTP proxy.
Expand Down Expand Up @@ -147,6 +157,10 @@ func NewHTTPProxy(config HTTPConfig) (*HTTPProxy, error) {
if err != nil {
return nil, err
}
serverTLS, err := newHTTPServerTLSConfig(config.ServerTLS)
if err != nil {
return nil, err
}
return &HTTPProxy{
config: config,
upstream: upstream,
Expand All @@ -155,6 +169,7 @@ func NewHTTPProxy(config HTTPConfig) (*HTTPProxy, error) {
forwardHeaders: normalizedForwardHeaders(config.ForwardHeaders),
allowedOrigins: allowedOrigins,
allowedHosts: allowedHosts,
serverTLS: serverTLS,
}, nil
}

Expand All @@ -164,7 +179,13 @@ func (p *HTTPProxy) ListenAndServe(ctx context.Context) error {

errs := make(chan error, 1)
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
var err error
if p.serverTLS == nil {
err = server.ListenAndServe()
} else {
err = server.ListenAndServeTLS("", "")
}
if err != nil && err != http.ErrServerClosed {
errs <- err
return
}
Expand Down Expand Up @@ -196,9 +217,30 @@ func (p *HTTPProxy) httpServer() *http.Server {
ReadTimeout: p.config.ReadTimeout,
WriteTimeout: p.config.WriteTimeout,
IdleTimeout: p.config.IdleTimeout,
TLSConfig: p.serverTLS,
}
}

func newHTTPServerTLSConfig(config HTTPServerTLSConfig) (*tls.Config, error) {
if !config.Enabled {
if config.CertFile != "" || config.KeyFile != "" {
return nil, fmt.Errorf("proxy: http: incoming TLS certificate requires tls.enabled=true")
}
return nil, nil
}
if config.CertFile == "" || config.KeyFile == "" {
return nil, fmt.Errorf("proxy: http: tls.cert_file and tls.key_file are required when TLS is enabled")
}
certificate, err := tls.LoadX509KeyPair(config.CertFile, config.KeyFile)
if err != nil {
return nil, fmt.Errorf("proxy: http: load incoming TLS certificate: %w", err)
}
return &tls.Config{
MinVersion: tls.VersionTLS12,
Certificates: []tls.Certificate{certificate},
}, nil
}

// ServeHTTP forwards a request to the upstream server and audits JSON-RPC messages.
func (p *HTTPProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if !p.hostAllowed(r.Host) {
Expand Down
95 changes: 95 additions & 0 deletions internal/proxy/http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,22 @@ package proxy

import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"math/big"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -561,6 +572,90 @@ func TestNewHTTPProxyRejectsInvalidTLSCAFile(t *testing.T) {
}
}

func TestHTTPProxyIncomingTLSHandshake(t *testing.T) {
certFile, keyFile := writeHTTPServerCertificate(t)
proxy, err := NewHTTPProxy(HTTPConfig{
Upstream: "http://upstream.local",
ServerTLS: HTTPServerTLSConfig{
Enabled: true,
CertFile: certFile,
KeyFile: keyFile,
},
})
if err != nil {
t.Fatalf("new http proxy: %v", err)
}
if proxy.serverTLS == nil || proxy.serverTLS.MinVersion != tls.VersionTLS12 {
t.Fatalf("server TLS config = %#v, want TLS 1.2 minimum", proxy.serverTLS)
}

serverSide, clientSide := net.Pipe()
deadline := time.Now().Add(5 * time.Second)
_ = serverSide.SetDeadline(deadline)
_ = clientSide.SetDeadline(deadline)
serverConn := tls.Server(serverSide, proxy.serverTLS.Clone())
clientConn := tls.Client(clientSide, &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12}) //nolint:gosec -- self-signed test certificate
serverErr := make(chan error, 1)
go func() { serverErr <- serverConn.Handshake() }()
if err := clientConn.Handshake(); err != nil {
t.Fatalf("client handshake: %v", err)
}
if err := <-serverErr; err != nil {
t.Fatalf("server handshake: %v", err)
}
_ = clientConn.Close()
_ = serverConn.Close()
}

func TestHTTPProxyRejectsInvalidIncomingTLSConfig(t *testing.T) {
cases := []HTTPServerTLSConfig{
{Enabled: true},
{Enabled: true, CertFile: "server.crt"},
{CertFile: "server.crt", KeyFile: "server.key"},
{Enabled: true, CertFile: "missing.crt", KeyFile: "missing.key"},
}
for _, config := range cases {
if _, err := NewHTTPProxy(HTTPConfig{Upstream: "http://upstream.local", ServerTLS: config}); err == nil {
t.Fatalf("expected error for incoming TLS config %#v", config)
}
}
}

func writeHTTPServerCertificate(t *testing.T) (string, string) {
t.Helper()
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate private key: %v", err)
}
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "localhost"},
DNSNames: []string{"localhost"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
certificateDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
keyDER, err := x509.MarshalECPrivateKey(privateKey)
if err != nil {
t.Fatalf("marshal private key: %v", err)
}
directory := t.TempDir()
certFile := filepath.Join(directory, "server.crt")
keyFile := filepath.Join(directory, "server.key")
if err := os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificateDER}), 0600); err != nil {
t.Fatalf("write certificate: %v", err)
}
if err := os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0600); err != nil {
t.Fatalf("write private key: %v", err)
}
return certFile, keyFile
}

type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
Expand Down
Loading