diff --git a/CHANGELOG.md b/CHANGELOG.md index ed1e700..f87a80f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ 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.17.3 — 2026-07-15 + +### Fixed + +- **A portless `network.allow` pattern denied every Postgres data-plane connection under strict policy — including the shipped example** — `hostPattern.port == 0` (an unspecified pattern port) is documented and enforced by `matchesPattern` as "match only ports 80 and 443," an HTTP-scheme assumption. The Postgres data plane's policy check (`serveAuthenticated`, `proxy/postgres.go`) called that same HTTP-oriented `matchHost`/`checkNetworkPolicy` with the literal Postgres port (5432), so a pattern like `*.neon.tech` — which matches neither 80 nor 443 — could never match a live Postgres connection: every connection was denied with `FATAL: connection not allowed by network policy` (SQLSTATE 28000), regardless of how the allow list was written. This left the network-policy matcher inconsistent with the credential-resolver matcher for the identical connection: `postgresResolverFromEntries` already defaults an unspecified pattern port to 5432 when matching Postgres credential resolvers, so a host could be accepted for credential resolution and simultaneously rejected by policy. `examples/gatekeeper-postgres.yaml` and `docs/content/guides/13-postgres-neon.md` both document exactly this configuration (`policy: strict` + `allow: ["*.neon.tech"]`), so the shipped example was broken as written. Fixed by giving the Postgres data plane its own matcher, `matchHostPostgres` (`proxy/postgres.go`), and a corresponding `Proxy.checkNetworkPolicyPostgres` — both mirror the existing HTTP-facing functions exactly, but apply the same port-0-defaults-to-5432 override `postgresResolverFromEntries` already uses, so the two matchers can no longer disagree about the same connection. `matchHost`, `matchesPattern`, `checkNetworkPolicy`, and `checkNetworkPolicyForRequest` — the shared matcher and its HTTP/CONNECT callers — are untouched: a portless allow pattern still means "ports 80 and 443 only" for HTTP traffic, and an allow pattern pinned to an explicit non-5432 port still denies Postgres connections, exactly as before + ## v0.17.2 — 2026-07-15 ### Added diff --git a/proxy/postgres.go b/proxy/postgres.go index 1b1224b..a710328 100644 --- a/proxy/postgres.go +++ b/proxy/postgres.go @@ -139,6 +139,33 @@ func postgresResolverFromEntries(entries []PostgresResolverEntry, host string) P return nil } +// matchHostPostgres reports whether host matches any of patterns for +// Postgres data-plane traffic, which is always evaluated at the Postgres +// default port (5432). It applies the same port-default override as +// postgresResolverFromEntries above: a pattern with no explicit port (e.g. +// "*.neon.tech") is treated as pinned to 5432 rather than matchesPattern's +// HTTP-centric default of 80/443. Without this override, matchHost's shared +// HTTP semantics would make a portless allow pattern unmatchable for any +// Postgres connection — exactly the same trap postgresResolverFromEntries +// works around for resolver lookups, and the two matchers must agree: a host +// with a configured resolver but a denying policy (or vice versa) would be +// an internally inconsistent proxy. explicit-port patterns (e.g. +// "*.neon.tech:5433") are untouched, so a pattern pinned to a non-5432 port +// still never matches. This is Postgres-specific: matchHost/matchesPattern +// and their HTTP/CONNECT callers (checkNetworkPolicy, +// checkNetworkPolicyForRequest) are unchanged. +func matchHostPostgres(patterns []hostPattern, host string) bool { + for _, pattern := range patterns { + if pattern.port == 0 { + pattern.port = postgresDefaultPort + } + if matchesPattern(pattern, host, postgresDefaultPort) { + return true + } + } + return false +} + // upstreamParams describes how to reach and authenticate to the upstream // Postgres server. type upstreamParams struct { @@ -723,9 +750,9 @@ func (s *PostgresServer) serveAuthenticated(ctx context.Context, clientConn net. // public DNS, so host-gateway routing does not apply to the Postgres data plane. var allowed bool if rc != nil { - allowed = rc.Policy != "strict" || matchHost(rc.AllowedHosts, sniHost, postgresDefaultPort) + allowed = rc.Policy != "strict" || matchHostPostgres(rc.AllowedHosts, sniHost) } else { - allowed = s.proxy.checkNetworkPolicy(sniHost, postgresDefaultPort) + allowed = s.proxy.checkNetworkPolicyPostgres(sniHost) } if !allowed { if s.proxy.policyLogger != nil { diff --git a/proxy/postgres_test.go b/proxy/postgres_test.go index feb1ba7..8550809 100644 --- a/proxy/postgres_test.go +++ b/proxy/postgres_test.go @@ -716,3 +716,149 @@ func TestPostgresPolicyDeniesHost(t *testing.T) { t.Fatal("connect succeeded, want a policy-denial error") } } + +// TestPostgresPolicyAllowsPortlessPattern verifies that a portless allow +// pattern -- as shipped in examples/gatekeeper-postgres.yaml +// ("network.policy: strict" + "allow: [\"*.neon.tech\"]") -- permits a +// Postgres data-plane connection. Before the fix, matchHost/matchesPattern's +// HTTP-centric default (an unspecified pattern port matches only 80/443) +// applied here too, so the connection was denied even though +// postgresResolverForHost (via postgresResolverFromEntries, postgres.go) +// already defaults an unspecified pattern port to 5432 when matching +// resolvers -- the same connection was accepted by the resolver but rejected +// by network policy. +func TestPostgresPolicyAllowsPortlessPattern(t *testing.T) { + fake := startFakePostgres(t, "ep-foo.aws.neon.tech", "app_rw", "real-password") + + ca, err := generateCA() + if err != nil { + t.Fatalf("generateCA: %v", err) + } + p := NewProxy() + p.SetCA(ca) + p.SetUpstreamCAs(fake.certPool) + p.SetAuthToken("run-token") + p.SetNetworkPolicy("strict", []string{"*.neon.tech"}, nil) + p.SetPostgresResolver("*.neon.tech", NewStaticPostgresResolver("real-password")) + + srv := newTestPostgresListener(t, p) + srv.dialUpstream = func(ctx context.Context, h string) (string, error) { + return fake.addr, nil + } + + conn, err := connectThroughGatekeeper(t, srv, caTrustPool(t, ca), + "ep-foo.aws.neon.tech", "app_rw", "appdb", "run-token") + if err != nil { + t.Fatalf("connect through gatekeeper: %v -- want strict policy with portless allow pattern %q to allow a Postgres connection to %q", err, "*.neon.tech", "ep-foo.aws.neon.tech") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := conn.Close(ctx); err != nil { + t.Errorf("Close: %v", err) + } +} + +// TestPostgresPolicyDeniesUnlistedHostUnderPortlessPattern proves the fix for +// TestPostgresPolicyAllowsPortlessPattern doesn't widen the allow surface: a +// host that doesn't match the portless pattern is still denied. +func TestPostgresPolicyDeniesUnlistedHostUnderPortlessPattern(t *testing.T) { + ca, err := generateCA() + if err != nil { + t.Fatalf("generateCA: %v", err) + } + p := NewProxy() + p.SetCA(ca) + p.SetAuthToken("run-token") + p.SetNetworkPolicy("strict", []string{"*.neon.tech"}, nil) + p.SetPostgresResolver("*.neon.tech", NewStaticPostgresResolver("real-password")) + + srv := newTestPostgresListener(t, p) + + conn, err := connectThroughGatekeeper(t, srv, caTrustPool(t, ca), + "evil.com", "app_rw", "appdb", "run-token") + if err == nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + conn.Close(ctx) + t.Fatal("connect succeeded, want evil.com denied under strict policy with allow *.neon.tech") + } +} + +// TestPostgresPolicyExplicitWrongPortDenied proves the fix doesn't relax +// explicit-port patterns: a pattern pinned to a port other than 5432 must +// still deny a Postgres connection (which is always evaluated at 5432). +func TestPostgresPolicyExplicitWrongPortDenied(t *testing.T) { + ca, err := generateCA() + if err != nil { + t.Fatalf("generateCA: %v", err) + } + p := NewProxy() + p.SetCA(ca) + p.SetAuthToken("run-token") + p.SetNetworkPolicy("strict", []string{"*.neon.tech:5433"}, nil) + p.SetPostgresResolver("*.neon.tech", NewStaticPostgresResolver("real-password")) + + srv := newTestPostgresListener(t, p) + + conn, err := connectThroughGatekeeper(t, srv, caTrustPool(t, ca), + "ep-foo.aws.neon.tech", "app_rw", "appdb", "run-token") + if err == nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + conn.Close(ctx) + t.Fatal("connect succeeded, want denial: allow pattern is pinned to port 5433, connection is on 5432") + } +} + +// TestPostgresPolicyExplicitCorrectPortAllowed proves an explicit ":5432" +// pattern keeps working exactly as before the fix. +func TestPostgresPolicyExplicitCorrectPortAllowed(t *testing.T) { + fake := startFakePostgres(t, "ep-foo.aws.neon.tech", "app_rw", "real-password") + + ca, err := generateCA() + if err != nil { + t.Fatalf("generateCA: %v", err) + } + p := NewProxy() + p.SetCA(ca) + p.SetUpstreamCAs(fake.certPool) + p.SetAuthToken("run-token") + p.SetNetworkPolicy("strict", []string{"*.neon.tech:5432"}, nil) + p.SetPostgresResolver("*.neon.tech", NewStaticPostgresResolver("real-password")) + + srv := newTestPostgresListener(t, p) + srv.dialUpstream = func(ctx context.Context, h string) (string, error) { + return fake.addr, nil + } + + conn, err := connectThroughGatekeeper(t, srv, caTrustPool(t, ca), + "ep-foo.aws.neon.tech", "app_rw", "appdb", "run-token") + if err != nil { + t.Fatalf("connect through gatekeeper: %v -- want explicit *.neon.tech:5432 pattern to allow a connection on port 5432", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := conn.Close(ctx); err != nil { + t.Errorf("Close: %v", err) + } +} + +// TestCheckNetworkPolicyHTTPPortDefaultsUnchanged proves the fix for Postgres +// data-plane matching does not touch checkNetworkPolicy, the function the +// HTTP/CONNECT path shares with the Postgres plane's fallback (no run +// context) case. A portless allow pattern must still mean "matches only 80 +// and 443" here -- it must not also match the Postgres port. +func TestCheckNetworkPolicyHTTPPortDefaultsUnchanged(t *testing.T) { + p := NewProxy() + p.SetNetworkPolicy("strict", []string{"api.github.com"}, nil) + + if !p.checkNetworkPolicy("api.github.com", 443) { + t.Error(`checkNetworkPolicy("api.github.com", 443) = false, want true (HTTPS default port)`) + } + if !p.checkNetworkPolicy("api.github.com", 80) { + t.Error(`checkNetworkPolicy("api.github.com", 80) = false, want true (HTTP default port)`) + } + if p.checkNetworkPolicy("api.github.com", postgresDefaultPort) { + t.Error(`checkNetworkPolicy("api.github.com", 5432) = true, want false -- a portless HTTP allow pattern must not match the Postgres port`) + } +} diff --git a/proxy/proxy.go b/proxy/proxy.go index bd01467..1c8e7eb 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -2036,6 +2036,24 @@ func (p *Proxy) checkNetworkPolicy(host string, port int) bool { return matchHost(p.allowedHosts, host, port) } +// checkNetworkPolicyPostgres checks if host is allowed by the network policy +// for Postgres data-plane traffic, evaluated at the Postgres default port +// (5432). It is the Postgres-plane counterpart to checkNetworkPolicy: same +// policy and allowedHosts state, but matched with matchHostPostgres +// (postgres.go) instead of matchHost, so a portless allow pattern (e.g. +// "*.neon.tech") means "matches port 5432" here instead of checkNetworkPolicy's +// HTTP-centric "matches ports 80/443". checkNetworkPolicy itself, and the +// HTTP/CONNECT path that calls it, are unaffected by this method's existence. +func (p *Proxy) checkNetworkPolicyPostgres(host string) bool { + p.mu.RLock() + defer p.mu.RUnlock() + + if p.policy != "strict" { + return true + } + return matchHostPostgres(p.allowedHosts, host) +} + // writeProxyAuthRequired writes a 407 with a Proxy-Authenticate challenge. // Without the challenge header, clients like git's libcurl treat 407 as fatal // and never retry with credentials.