Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ Gatekeeper is a standalone credential-injecting TLS-intercepting proxy. It trans

Gatekeeper is pre-1.0. The configuration schema and credential source interface may change between minor versions.

## v0.20.0 — 2026-07-15

### Added

- **The Postgres data-plane request log now captures the client's `application_name` startup parameter as a tracing slug** — the Postgres analogue of the HTTP `capture_headers` feature. A run can open many Postgres connections over its lifetime, and until now the canonical log line's only per-connection handle was `run_id`, which identifies the *run* but not which caller within it opened a given connection. Standard Postgres clients already let a caller label a connection via `PGAPPNAME`, a driver's `application_name=` connection option, or `libpq`'s `application_name` keyword; gatekeeper now reads that value out of the client's `StartupMessage` parameters (`proxy.RunContextData` continues to forward it upstream unchanged, so it still surfaces in Neon's own `pg_stat_activity` too) and records it on `RequestLogData.ApplicationName` (`proxy/proxy.go`), which `serveAuthenticated` (`proxy/postgres.go`) populates on every audit-log exit path — including denied connections (no resolver, network-policy denial), so a rejected connection is still traceable back to its origin, not just a successful one. `gatekeeper.go`'s canonical-log-line callback emits it as an `application_name` slog attribute, gated on non-empty exactly like `run_id`/`client_ip`.
Because `application_name` is client-supplied, unauthenticated free text with no protocol grammar constraining it (unlike an HTTP header value), it is sanitized before it ever reaches a log line: a new shared helper, `proxy.SanitizeLogValue`, discards invalid UTF-8, strips control characters (newlines, carriage returns, NUL, tabs, ...) so a crafted value can't forge additional log lines or otherwise corrupt structured log output, and bounds the result to 256 bytes, truncating at a valid UTF-8 boundary rather than splitting a multi-byte rune. `gatekeeper.go`'s existing `capture_headers` truncation logic — previously inlined and only bounding length, never stripping control characters — now calls the same helper, so both capture paths share one bound and one sanitization behavior.
`application_name` is a correlation slug the client controls, not a trusted identity — `run_id`, populated only from the authenticated run token, remains the trusted identity for anything security-relevant. Documented in the [observability](docs/content/concepts/06-observability.md) canonical-log-field table and a new "Tracing a connection to its origin" section in the [Postgres data plane](docs/content/concepts/08-postgres-data-plane.md) and [Postgres + Neon](docs/content/guides/13-postgres-neon.md) docs

## v0.19.1 — 2026-07-15

### Fixed
Expand Down
1 change: 1 addition & 0 deletions docs/content/concepts/06-observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Gatekeeper emits one wide structured log entry per request at completion. Each l
| `error` | Error message, when the request ended in an error |
| `run_id` | Per-run identifier (daemon mode) |
| `user_id` | User ID from proxy auth username |
| `application_name` | Postgres connections only: the client's `application_name` startup parameter, sanitized and length-bounded. A correlation slug the client sets, not a trusted identity — see [Postgres Data Plane](./08-postgres-data-plane.md#tracing-a-connection-to-its-origin). Omitted when the client didn't set one. |

Log level is determined by outcome: `ERROR` for server errors or transport failures, `WARN` for policy denials or client errors, `INFO` for successful requests.

Expand Down
8 changes: 8 additions & 0 deletions docs/content/concepts/08-postgres-data-plane.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ A credential with a `postgres` block selects how the upstream password is resolv

The API key is itself a credential source, so it can come from an environment variable, AWS Secrets Manager, or GCP Secret Manager. See [Credential Sources](./03-credential-sources.md).

## Tracing a connection to its origin

The audit entry's `run_id` is the trusted identity: it comes from the authenticated run token, so a client cannot forge it. But `run_id` alone doesn't say *which* connection within a run produced a given log line — a single run can open many Postgres connections over its lifetime.

Clients can additionally set the standard Postgres `application_name` startup parameter (via `PGAPPNAME`, a driver's `application_name=` connection option, or `libpq`'s `application_name` keyword) to a short slug identifying the connection's origin, e.g. a box or worker ID. Gatekeeper captures it into the canonical log line as `application_name` (see [Canonical log lines](./06-observability.md#canonical-log-lines)) — sanitized (control characters stripped) and length-bounded before logging, the Postgres analogue of the HTTP [`capture_headers`](./06-observability.md#canonical-log-lines) feature. The raw value is still forwarded upstream unchanged, so it also surfaces in Neon's own `pg_stat_activity`, giving the same slug on both sides of the proxy.

Unlike `run_id`, `application_name` is not authenticated: the client sets it, so treat it as a correlation hint for debugging, not as proof of origin.

## Security properties

- Run-token comparison uses the same constant-time path as the HTTP plane.
Expand Down
12 changes: 12 additions & 0 deletions docs/content/guides/13-postgres-neon.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,18 @@ PGPASSWORD=local-test-token psql \

`host` never touches DNS here — it only sets SNI — so this works even for endpoint hostnames that don't resolve from your machine at all.

## Tracing a connection to its origin

Set `application_name` to identify which caller opened a given connection in gatekeeper's logs:

```bash
PGAPPNAME=box-abc123 PGPASSWORD=local-test-token psql \
"host=ep-cool-darkness-123456.us-east-2.aws.neon.tech \
dbname=neondb user=neondb_owner sslmode=require"
```

Gatekeeper captures it (sanitized) as `application_name` on the canonical log line, alongside the authenticated `run_id`. It's forwarded upstream unchanged too, so it also shows up in Neon's `pg_stat_activity` — but unlike `run_id`, it's client-set and not authenticated. See [Tracing a connection to its origin](../concepts/08-postgres-data-plane.md#tracing-a-connection-to-its-origin).

## Static resolver alternative

For a non-Neon Postgres server, or to pin a single fixed password instead of calling the Neon API, use `resolver: static`. The source supplies the password directly and gatekeeper fetches it once at startup:
Expand Down
17 changes: 8 additions & 9 deletions gatekeeper.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"

"github.com/majorcontext/gatekeeper/credentialsource"
"github.com/majorcontext/gatekeeper/proxy"
Expand Down Expand Up @@ -313,6 +312,11 @@ func New(ctx context.Context, cfg *Config, version string) (*Server, error) {
}
attrs = append(attrs, slog.String("client_ip", clientIP))
}
if data.ApplicationName != "" {
// A correlation slug the client set (e.g. via PGAPPNAME), not a
// trusted identity — run_id, above, is the trusted identity.
attrs = append(attrs, slog.String("application_name", data.ApplicationName))
}
if data.AuthInjected {
attrs = append(attrs, slog.Bool("credential_injected", true))
var headerNames []string
Expand Down Expand Up @@ -348,18 +352,13 @@ func New(ctx context.Context, cfg *Config, version string) (*Server, error) {
}

// Append captured request headers as structured log attributes.
// SanitizeLogValue bounds and cleans the value the same way
// data.ApplicationName above already was — see its doc comment.
if data.RequestHeaders != nil {
for _, h := range cfg.Log.CaptureHeaders {
if v := data.RequestHeaders.Get(h); v != "" {
if len(v) > 256 {
// Truncate at a valid UTF-8 boundary to avoid splitting multi-byte characters.
v = v[:256]
for len(v) > 0 && !utf8.ValidString(v) {
v = v[:len(v)-1]
}
}
key := strings.ReplaceAll(strings.ToLower(h), "-", "_")
attrs = append(attrs, slog.String(key, v))
attrs = append(attrs, slog.String(key, proxy.SanitizeLogValue(v)))
}
}
}
Expand Down
156 changes: 156 additions & 0 deletions gatekeeper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2929,6 +2929,162 @@ func TestServerPostgresProxyProtocol(t *testing.T) {
}
}

// TestServerPostgresLogsApplicationName verifies gatekeeper's canonical
// "request" log line -- the slog attrs wired in New()'s p.SetLogger callback,
// not just the RequestLogData the proxy package hands it -- carries an
// application_name attribute for a client-set Postgres application_name
// startup parameter (the Postgres analogue of the HTTP capture_headers
// feature), and that the attribute is entirely absent, not merely empty,
// when the client never set one -- the same non-empty gating run_id and
// client_ip already get.
func TestServerPostgresLogsApplicationName(t *testing.T) {
tests := []struct {
name string
applicationName string
wantSubstring string
wantAbsent string
}{
{
name: "present",
applicationName: "box-abc123",
wantSubstring: "application_name=box-abc123",
},
{
name: "absent when the client does not set it",
wantAbsent: "application_name=",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
caDir := t.TempDir()
ca, err := proxy.NewCA(caDir)
if err != nil {
t.Fatalf("NewCA: %v", err)
}
caPool := x509.NewCertPool()
if !caPool.AppendCertsFromPEM(ca.CertPEM()) {
t.Fatal("failed to add CA cert to pool")
}

cfg := &Config{
Proxy: ProxyConfig{Port: 0, Host: "127.0.0.1"},
TLS: TLSConfig{
CACert: filepath.Join(caDir, "ca.crt"),
CAKey: filepath.Join(caDir, "ca.key"),
},
Postgres: &PostgresConfig{Port: 0},
Credentials: []CredentialConfig{
{
Host: "*.neon.tech",
Postgres: &PostgresCredentialConfig{Resolver: "static"},
Source: SourceConfig{Type: "static", Value: "pw"},
},
},
}

srv, err := New(context.Background(), cfg, "")
if err != nil {
t.Fatalf("New: %v", err)
}
// Deliberately do NOT override SetLogger here (unlike
// captureServerLog elsewhere in this file): this test exercises the
// real slog-emitting logger New() installs.
logBuf := captureDefaultSlog(t)

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() { _ = srv.Start(ctx) }()

deadline := time.Now().Add(2 * time.Second)
var pgAddr string
for {
pgAddr = srv.PostgresAddr()
if pgAddr != "" {
break
}
if time.Now().After(deadline) {
t.Fatal("postgres listener did not start in time")
}
time.Sleep(10 * time.Millisecond)
}

conn, err := net.DialTimeout("tcp", pgAddr, time.Second)
if err != nil {
t.Fatalf("dial postgres listener: %v", err)
}
defer conn.Close()
conn.SetDeadline(time.Now().Add(2 * time.Second))

frontend := pgproto3.NewFrontend(conn, conn)
frontend.Send(&pgproto3.SSLRequest{})
if err := frontend.Flush(); err != nil {
t.Fatalf("send SSLRequest: %v", err)
}
buf := make([]byte, 1)
if _, err := io.ReadFull(conn, buf); err != nil {
t.Fatalf("read SSLRequest response: %v", err)
}
if buf[0] != 'S' {
t.Fatalf("SSLRequest response = %q, want 'S'", buf[0])
}

// db.test.local matches no configured credential host, so the
// connection is denied for lack of a resolver -- but only after the
// deny path logs the canonical "request" line, which is all this
// test needs (see TestServerPostgresProxyProtocol for the same
// pattern).
tlsConn := tls.Client(conn, &tls.Config{ServerName: "db.test.local", RootCAs: caPool})
if err := tlsConn.Handshake(); err != nil {
t.Fatalf("TLS handshake: %v", err)
}
defer tlsConn.Close()

params := map[string]string{"user": "app", "database": "appdb"}
if tt.applicationName != "" {
params["application_name"] = tt.applicationName
}
fe := pgproto3.NewFrontend(tlsConn, tlsConn)
fe.Send(&pgproto3.StartupMessage{
ProtocolVersion: pgproto3.ProtocolVersionNumber,
Parameters: params,
})
if err := fe.Flush(); err != nil {
t.Fatalf("send startup: %v", err)
}
if _, err := fe.Receive(); err != nil {
t.Fatalf("receive auth request: %v", err)
}
fe.Send(&pgproto3.PasswordMessage{Password: "any-token"})
if err := fe.Flush(); err != nil {
t.Fatalf("send password: %v", err)
}
if _, err := fe.Receive(); err != nil {
t.Fatalf("receive auth result: %v", err)
}

// The log line is written by a goroutine handling the connection,
// racing this one; poll briefly for it to land.
var text string
logDeadline := time.Now().Add(2 * time.Second)
for time.Now().Before(logDeadline) {
text = logBuf.String()
if strings.Contains(text, "proxy_type=postgres") {
break
}
time.Sleep(10 * time.Millisecond)
}

if tt.wantSubstring != "" && !strings.Contains(text, tt.wantSubstring) {
t.Errorf("log output does not contain %q; got:\n%s", tt.wantSubstring, text)
}
if tt.wantAbsent != "" && strings.Contains(text, tt.wantAbsent) {
t.Errorf("log output unexpectedly contains %q; got:\n%s", tt.wantAbsent, text)
}
})
}
}

func TestServerPostgresStartFailureCleansUpHTTP(t *testing.T) {
// Occupy a port so the postgres listener fails to bind to it.
occupied, err := net.Listen("tcp", "127.0.0.1:0")
Expand Down
22 changes: 18 additions & 4 deletions proxy/pgtest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,11 @@ type fakePostgresServer struct {
// assert the message is surfaced in gatekeeper's logs set it explicitly.
failPostAuthMessage string

mu sync.Mutex
authOK int
authFail int
lastQuery string
mu sync.Mutex
authOK int
authFail int
lastQuery string
lastStartupParams map[string]string
}

// fakePostgresOption customizes a fakePostgresServer before it starts serving.
Expand Down Expand Up @@ -140,6 +141,16 @@ func (f *fakePostgresServer) queriedLast() string {
return f.lastQuery
}

// lastApplicationName returns the "application_name" startup parameter the
// proxy forwarded upstream on the most recent connection, letting a test
// verify that capturing application_name for the request log does not
// mutate what actually gets forwarded to the real database.
func (f *fakePostgresServer) lastApplicationName() string {
f.mu.Lock()
defer f.mu.Unlock()
return f.lastStartupParams["application_name"]
}

// startFakePostgres starts a fake Postgres server on 127.0.0.1:0. The listener
// is closed via t.Cleanup.
func startFakePostgres(t *testing.T, dnsName, user, password string, opts ...fakePostgresOption) *fakePostgresServer {
Expand Down Expand Up @@ -231,6 +242,9 @@ func (f *fakePostgresServer) handle(conn net.Conn) {
if !ok {
return
}
f.mu.Lock()
f.lastStartupParams = sm.Parameters
f.mu.Unlock()
if sm.Parameters["user"] != f.user {
// 28000 invalid_authorization_specification: what real Postgres sends
// for an unknown role.
Expand Down
7 changes: 7 additions & 0 deletions proxy/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,13 @@ func (s *PostgresServer) serveAuthenticated(ctx context.Context, clientConn net.
RequestSize: -1,
ResponseSize: -1,
ClientAddr: clientConn.RemoteAddr().String(),
// ApplicationName is a tracing slug, not identity: it comes straight
// from the client's startup parameters, unauthenticated, so it is
// sanitized before it ever reaches a log line. The raw value in
// startupParams is left untouched — it is still forwarded upstream
// as-is (see connectWithRetry) so Neon's own pg_stat_activity keeps
// seeing exactly what the client sent.
ApplicationName: SanitizeLogValue(startupParams["application_name"]),
}
if rc != nil {
logEntry.RunID = rc.RunID
Expand Down
Loading
Loading