Skip to content

Commit f507e3a

Browse files
fuziontechclaude
andauthored
fix: constant-time auth check + uniform unknown-user handshake flow (#719) (#726)
* fix: constant-time auth + uniform unknown-user flow in standalone and child-worker paths (#719) Two auth hardening fixes: - server/conn.go (standalone handleStartup): the cleartext-password check used a plain non-constant-time string compare that also short-circuited on unknown users. Route it through the existing constant-time, existence-hiding auth.ValidateUserPassword helper, matching the control-plane and Flight SQL ingress paths. - server/worker.go (process-isolation child worker): an unknown user got SQLSTATE 28P01 *before* the password request, while a known user got a password request first — a user-existence oracle visible in protocol flow, independent of timing. Always request the password, then validate via auth.ValidateUserPassword, so unknown-user and wrong-password are byte-for-byte indistinguishable to the client. The handshake is extracted into authenticateChildClient so it is unit-testable. Defense in depth, not an emergency: the timing signal was already practically unexploitable because the IP-ban rate limiter caps failed attempts per source IP. Fixes #719 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: use wire.MsgErrorResponse constant instead of 'E' literal Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 2313c6f commit f507e3a

3 files changed

Lines changed: 154 additions & 51 deletions

File tree

server/conn.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1269,9 +1269,8 @@ func (c *clientConn) handleStartup() error {
12691269
// Password is null-terminated
12701270
password := string(bytes.TrimRight(body, "\x00"))
12711271

1272-
// Validate password
1273-
expectedPassword, ok := c.server.cfg.Users[c.username]
1274-
if !ok || expectedPassword != password {
1272+
// Validate password (constant-time; does not leak whether the user exists)
1273+
if !auth.ValidateUserPassword(c.server.cfg.Users, c.username, password) {
12751274
// Record failed authentication attempt
12761275
banned := c.server.rateLimiter.RecordFailedAuth(c.conn.RemoteAddr())
12771276
if banned {

server/worker.go

Lines changed: 54 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,58 @@ func RunChildMode() {
127127
os.Exit(exitCode)
128128
}
129129

130+
// authenticateChildClient runs the cleartext-password handshake for a child
131+
// worker connection. The password is always requested before any credential
132+
// check, and validation is constant-time via auth.ValidateUserPassword, so an
133+
// unknown user and a wrong password are indistinguishable to the client in
134+
// both protocol flow and error shape. Returns ExitSuccess after sending
135+
// AuthOK, or the exit code to terminate with on failure.
136+
func authenticateChildClient(reader *bufio.Reader, writer *bufio.Writer, users map[string]string, username, remoteAddr string) int {
137+
// Request password
138+
if err := wire.WriteAuthCleartextPassword(writer); err != nil {
139+
slog.Error("Failed to request password", "error", err)
140+
return ExitError
141+
}
142+
if err := writer.Flush(); err != nil {
143+
slog.Error("Failed to flush writer", "error", err)
144+
return ExitError
145+
}
146+
147+
// Read password response
148+
msgType, body, err := wire.ReadMessage(reader)
149+
if err != nil {
150+
slog.Error("Failed to read password message", "error", err)
151+
return ExitError
152+
}
153+
154+
if msgType != wire.MsgPassword {
155+
slog.Error("Expected password message", "got", string(msgType))
156+
_ = wire.WriteErrorResponse(writer, "FATAL", "28000", "expected password message")
157+
_ = writer.Flush()
158+
return ExitError
159+
}
160+
161+
// Password is null-terminated
162+
password := string(bytes.TrimRight(body, "\x00"))
163+
164+
// Validate password (constant-time; does not leak whether the user exists)
165+
if !auth.ValidateUserPassword(users, username, password) {
166+
slog.Warn("Authentication failed", "user", username, "remote_addr", remoteAddr)
167+
auth.AuthFailuresCounter.Inc()
168+
_ = wire.WriteErrorResponse(writer, "FATAL", "28P01", "password authentication failed")
169+
_ = writer.Flush()
170+
return ExitAuthFailure
171+
}
172+
173+
// Send auth OK
174+
if err := wire.WriteAuthOK(writer); err != nil {
175+
slog.Error("Failed to send auth OK", "error", err)
176+
return ExitError
177+
}
178+
179+
return ExitSuccess
180+
}
181+
130182
// runChildWorker handles a single client connection in a child process.
131183
// Returns the appropriate exit code.
132184
func runChildWorker(tcpConn *net.TCPConn, cfg *ChildConfig) int {
@@ -220,54 +272,8 @@ func runChildWorker(tcpConn *net.TCPConn, cfg *ChildConfig) int {
220272
return ExitError
221273
}
222274

223-
// Look up expected password for this user
224-
expectedPassword, ok := cfg.Users[username]
225-
if !ok {
226-
slog.Warn("Unknown user", "user", username, "remote_addr", cfg.RemoteAddr)
227-
auth.AuthFailuresCounter.Inc()
228-
_ = wire.WriteErrorResponse(writer, "FATAL", "28P01", "password authentication failed")
229-
_ = writer.Flush()
230-
return ExitAuthFailure
231-
}
232-
233-
// Request password
234-
if err := wire.WriteAuthCleartextPassword(writer); err != nil {
235-
slog.Error("Failed to request password", "error", err)
236-
return ExitError
237-
}
238-
if err := writer.Flush(); err != nil {
239-
slog.Error("Failed to flush writer", "error", err)
240-
return ExitError
241-
}
242-
243-
// Read password response
244-
msgType, body, err := wire.ReadMessage(reader)
245-
if err != nil {
246-
slog.Error("Failed to read password message", "error", err)
247-
return ExitError
248-
}
249-
250-
if msgType != wire.MsgPassword {
251-
slog.Error("Expected password message", "got", string(msgType))
252-
_ = wire.WriteErrorResponse(writer, "FATAL", "28000", "expected password message")
253-
_ = writer.Flush()
254-
return ExitError
255-
}
256-
257-
// Password is null-terminated
258-
password := string(bytes.TrimRight(body, "\x00"))
259-
if password != expectedPassword {
260-
slog.Warn("Authentication failed", "user", username, "remote_addr", cfg.RemoteAddr)
261-
auth.AuthFailuresCounter.Inc()
262-
_ = wire.WriteErrorResponse(writer, "FATAL", "28P01", "password authentication failed")
263-
_ = writer.Flush()
264-
return ExitAuthFailure
265-
}
266-
267-
// Send auth OK
268-
if err := wire.WriteAuthOK(writer); err != nil {
269-
slog.Error("Failed to send auth OK", "error", err)
270-
return ExitError
275+
if exitCode := authenticateChildClient(reader, writer, cfg.Users, username, cfg.RemoteAddr); exitCode != ExitSuccess {
276+
return exitCode
271277
}
272278

273279
slog.Info("User authenticated", "user", username, "remote_addr", cfg.RemoteAddr)

server/worker_auth_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package server
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"testing"
7+
8+
"github.com/posthog/duckgres/server/wire"
9+
)
10+
11+
// passwordMessage builds a client PasswordMessage ('p') for the given password.
12+
func passwordMessage(t *testing.T, password string) []byte {
13+
t.Helper()
14+
var buf bytes.Buffer
15+
if err := wire.WriteMessage(&buf, wire.MsgPassword, append([]byte(password), 0)); err != nil {
16+
t.Fatalf("failed to build password message: %v", err)
17+
}
18+
return buf.Bytes()
19+
}
20+
21+
// runChildAuth feeds clientBytes to authenticateChildClient and returns the
22+
// exit code plus everything the server wrote to the client.
23+
func runChildAuth(t *testing.T, users map[string]string, username string, clientBytes []byte) (int, []byte) {
24+
t.Helper()
25+
var out bytes.Buffer
26+
reader := bufio.NewReader(bytes.NewReader(clientBytes))
27+
writer := bufio.NewWriter(&out)
28+
exitCode := authenticateChildClient(reader, writer, users, username, "test:5432")
29+
if err := writer.Flush(); err != nil {
30+
t.Fatalf("failed to flush writer: %v", err)
31+
}
32+
return exitCode, out.Bytes()
33+
}
34+
35+
func TestAuthenticateChildClientSuccess(t *testing.T) {
36+
users := map[string]string{"alice": "secret"}
37+
38+
exitCode, out := runChildAuth(t, users, "alice", passwordMessage(t, "secret"))
39+
if exitCode != ExitSuccess {
40+
t.Fatalf("expected ExitSuccess, got %d", exitCode)
41+
}
42+
43+
reader := bytes.NewReader(out)
44+
msgType, _, err := wire.ReadMessage(reader)
45+
if err != nil || msgType != wire.MsgAuth {
46+
t.Fatalf("expected cleartext password request, got %c (err %v)", msgType, err)
47+
}
48+
msgType, body, err := wire.ReadMessage(reader)
49+
if err != nil || msgType != wire.MsgAuth {
50+
t.Fatalf("expected AuthOK, got %c (err %v)", msgType, err)
51+
}
52+
if !bytes.Equal(body, []byte{0, 0, 0, 0}) {
53+
t.Fatalf("expected AuthOK body, got %v", body)
54+
}
55+
}
56+
57+
// TestAuthenticateChildClientUnknownUserIndistinguishable is a regression test
58+
// for the user-existence oracle: the worker used to return 28P01 for unknown
59+
// users before requesting a password, while known users got a password request
60+
// first. Both failure paths must now be byte-for-byte identical to the client.
61+
func TestAuthenticateChildClientUnknownUserIndistinguishable(t *testing.T) {
62+
users := map[string]string{"alice": "secret"}
63+
64+
unknownCode, unknownOut := runChildAuth(t, users, "mallory", passwordMessage(t, "secret"))
65+
wrongPwCode, wrongPwOut := runChildAuth(t, users, "alice", passwordMessage(t, "wrong"))
66+
67+
if unknownCode != ExitAuthFailure {
68+
t.Fatalf("unknown user: expected ExitAuthFailure, got %d", unknownCode)
69+
}
70+
if wrongPwCode != ExitAuthFailure {
71+
t.Fatalf("wrong password: expected ExitAuthFailure, got %d", wrongPwCode)
72+
}
73+
74+
// Same message flow, same error code/message shape — no existence oracle.
75+
if !bytes.Equal(unknownOut, wrongPwOut) {
76+
t.Fatalf("unknown-user and wrong-password byte streams differ:\nunknown: %q\nwrong-pw: %q", unknownOut, wrongPwOut)
77+
}
78+
79+
// Both flows: password request first, then a generic 28P01 error.
80+
reader := bytes.NewReader(unknownOut)
81+
msgType, _, err := wire.ReadMessage(reader)
82+
if err != nil || msgType != wire.MsgAuth {
83+
t.Fatalf("expected cleartext password request before failure, got %c (err %v)", msgType, err)
84+
}
85+
msgType, body, err := wire.ReadMessage(reader)
86+
if err != nil || msgType != wire.MsgErrorResponse {
87+
t.Fatalf("expected ErrorResponse, got %c (err %v)", msgType, err)
88+
}
89+
if !bytes.Contains(body, []byte("28P01")) {
90+
t.Fatalf("expected SQLSTATE 28P01 in error, got %q", body)
91+
}
92+
if !bytes.Contains(body, []byte("password authentication failed")) {
93+
t.Fatalf("expected generic auth failure message, got %q", body)
94+
}
95+
if bytes.Contains(body, []byte("mallory")) || bytes.Contains(body, []byte("alice")) {
96+
t.Fatalf("error message must not echo the username, got %q", body)
97+
}
98+
}

0 commit comments

Comments
 (0)