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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 29 additions & 2 deletions proxy/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
146 changes: 146 additions & 0 deletions proxy/postgres_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
}
}
18 changes: 18 additions & 0 deletions proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading