Skip to content

Commit 855b234

Browse files
authored
Merge pull request #3 from PostHog/feature/rate-limiting
Add connection rate limiting to prevent brute-force attacks
2 parents 86d4e1c + 68391ba commit 855b234

File tree

7 files changed

+451
-17
lines changed

7 files changed

+451
-17
lines changed

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
- [x] **TLS/SSL Support**: Add encrypted connections for production use
77
- [ ] **MD5 Password Authentication**: Support PostgreSQL's MD5-based auth for better security
88
- [ ] **SCRAM-SHA-256 Authentication**: Modern PostgreSQL authentication method
9-
- [ ] **Connection Rate Limiting**: Prevent brute-force attacks
9+
- [x] **Connection Rate Limiting**: Prevent brute-force attacks
1010

1111
### Configuration
1212
- [x] **Config File Support**: Load configuration from YAML/TOML file instead of hardcoding

duckgres.example.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,14 @@ users:
1818
postgres: "postgres"
1919
alice: "alice123"
2020
bob: "bob123"
21+
22+
# Rate limiting configuration (optional - these are the defaults)
23+
rate_limit:
24+
# Max failed auth attempts before banning an IP
25+
max_failed_attempts: 5
26+
# Time window for counting failed attempts (e.g., "5m", "1h")
27+
failed_attempt_window: "5m"
28+
# How long to ban an IP after too many failed attempts
29+
ban_duration: "15m"
30+
# Max concurrent connections from a single IP (0 = unlimited)
31+
max_connections_per_ip: 100

main.go

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,34 @@ import (
88
"os/signal"
99
"strconv"
1010
"syscall"
11+
"time"
1112

1213
"github.com/posthog/duckgres/server"
1314
"gopkg.in/yaml.v3"
1415
)
1516

1617
// FileConfig represents the YAML configuration file structure
1718
type FileConfig struct {
18-
Host string `yaml:"host"`
19-
Port int `yaml:"port"`
20-
DataDir string `yaml:"data_dir"`
21-
TLS TLSConfig `yaml:"tls"`
22-
Users map[string]string `yaml:"users"`
19+
Host string `yaml:"host"`
20+
Port int `yaml:"port"`
21+
DataDir string `yaml:"data_dir"`
22+
TLS TLSConfig `yaml:"tls"`
23+
Users map[string]string `yaml:"users"`
24+
RateLimit RateLimitFileConfig `yaml:"rate_limit"`
2325
}
2426

2527
type TLSConfig struct {
2628
Cert string `yaml:"cert"`
2729
Key string `yaml:"key"`
2830
}
2931

32+
type RateLimitFileConfig struct {
33+
MaxFailedAttempts int `yaml:"max_failed_attempts"`
34+
FailedAttemptWindow string `yaml:"failed_attempt_window"` // e.g., "5m"
35+
BanDuration string `yaml:"ban_duration"` // e.g., "15m"
36+
MaxConnectionsPerIP int `yaml:"max_connections_per_ip"`
37+
}
38+
3039
// loadConfigFile loads configuration from a YAML file
3140
func loadConfigFile(path string) (*FileConfig, error) {
3241
data, err := os.ReadFile(path)
@@ -129,6 +138,28 @@ func main() {
129138
if len(fileCfg.Users) > 0 {
130139
cfg.Users = fileCfg.Users
131140
}
141+
142+
// Apply rate limit config
143+
if fileCfg.RateLimit.MaxFailedAttempts > 0 {
144+
cfg.RateLimit.MaxFailedAttempts = fileCfg.RateLimit.MaxFailedAttempts
145+
}
146+
if fileCfg.RateLimit.MaxConnectionsPerIP > 0 {
147+
cfg.RateLimit.MaxConnectionsPerIP = fileCfg.RateLimit.MaxConnectionsPerIP
148+
}
149+
if fileCfg.RateLimit.FailedAttemptWindow != "" {
150+
if d, err := time.ParseDuration(fileCfg.RateLimit.FailedAttemptWindow); err == nil {
151+
cfg.RateLimit.FailedAttemptWindow = d
152+
} else {
153+
log.Printf("Warning: invalid failed_attempt_window duration: %v", err)
154+
}
155+
}
156+
if fileCfg.RateLimit.BanDuration != "" {
157+
if d, err := time.ParseDuration(fileCfg.RateLimit.BanDuration); err == nil {
158+
cfg.RateLimit.BanDuration = d
159+
} else {
160+
log.Printf("Warning: invalid ban_duration duration: %v", err)
161+
}
162+
}
132163
}
133164

134165
// Apply environment variables (override config file)

scripts/test_ratelimit.sh

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
#!/bin/bash
2+
# Test rate limiting functionality
3+
4+
set -e
5+
6+
echo "=== Testing Rate Limiting ==="
7+
8+
# Create a test config with aggressive rate limiting
9+
cat > /tmp/test-ratelimit.yaml <<EOF
10+
host: "127.0.0.1"
11+
port: 35433
12+
data_dir: "./data"
13+
tls:
14+
cert: "./certs/server.crt"
15+
key: "./certs/server.key"
16+
users:
17+
postgres: "postgres"
18+
rate_limit:
19+
max_failed_attempts: 3
20+
failed_attempt_window: "1m"
21+
ban_duration: "10s"
22+
max_connections_per_ip: 5
23+
EOF
24+
25+
# Kill any existing duckgres
26+
pkill -f "duckgres.*35433" 2>/dev/null || true
27+
sleep 1
28+
29+
# Start server
30+
echo "Starting server with rate limiting config..."
31+
./duckgres --config /tmp/test-ratelimit.yaml &
32+
SERVER_PID=$!
33+
sleep 2
34+
35+
cleanup() {
36+
kill $SERVER_PID 2>/dev/null || true
37+
rm -f /tmp/test-ratelimit.yaml
38+
}
39+
trap cleanup EXIT
40+
41+
PASS_COUNT=0
42+
FAIL_COUNT=0
43+
44+
echo ""
45+
echo "Test 1: Successful authentication should work"
46+
if PGPASSWORD=postgres psql "host=127.0.0.1 port=35433 user=postgres dbname=test sslmode=require" -c "SELECT 'auth works' as result;" 2>&1 | grep -q "auth works"; then
47+
echo " PASS: Successful auth works"
48+
((PASS_COUNT++))
49+
else
50+
echo " FAIL: Successful auth failed"
51+
((FAIL_COUNT++))
52+
fi
53+
54+
echo ""
55+
echo "Test 2: Failed auth attempts should be counted (3 attempts)"
56+
for i in 1 2 3; do
57+
echo " Attempt $i with wrong password..."
58+
PGPASSWORD=wrongpassword psql "host=127.0.0.1 port=35433 user=postgres dbname=test sslmode=require" -c "SELECT 1" 2>&1 | head -1 || true
59+
done
60+
61+
echo ""
62+
echo "Test 3: After 3 failures, connection should be rejected"
63+
RESULT=$(PGPASSWORD=postgres psql "host=127.0.0.1 port=35433 user=postgres dbname=test sslmode=require" -c "SELECT 'should fail'" 2>&1 || true)
64+
if echo "$RESULT" | grep -q "server closed the connection unexpectedly"; then
65+
echo " PASS: Connection rejected after too many failures"
66+
((PASS_COUNT++))
67+
else
68+
echo " FAIL: Connection was not rejected as expected"
69+
echo " Result: $RESULT"
70+
((FAIL_COUNT++))
71+
fi
72+
73+
echo ""
74+
echo "Test 4: Wait for ban to expire (10s) and try again"
75+
echo " Waiting 12 seconds for ban to expire..."
76+
sleep 12
77+
78+
if PGPASSWORD=postgres psql "host=127.0.0.1 port=35433 user=postgres dbname=test sslmode=require" -c "SELECT 'unbanned' as result;" 2>&1 | grep -q "unbanned"; then
79+
echo " PASS: Connection works after ban expires"
80+
((PASS_COUNT++))
81+
else
82+
echo " FAIL: Connection still blocked after ban should have expired"
83+
((FAIL_COUNT++))
84+
fi
85+
86+
echo ""
87+
echo "=== Rate Limiting Tests Complete ==="
88+
echo "Results: $PASS_COUNT passed, $FAIL_COUNT failed"
89+
90+
if [ $FAIL_COUNT -gt 0 ]; then
91+
exit 1
92+
fi

server/conn.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,10 +151,18 @@ func (c *clientConn) handleStartup() error {
151151
// Validate password
152152
expectedPassword, ok := c.server.cfg.Users[c.username]
153153
if !ok || expectedPassword != password {
154+
// Record failed authentication attempt
155+
banned := c.server.rateLimiter.RecordFailedAuth(c.conn.RemoteAddr())
156+
if banned {
157+
log.Printf("IP %s banned after too many failed auth attempts", c.conn.RemoteAddr())
158+
}
154159
c.sendError("FATAL", "28P01", "password authentication failed")
155160
return fmt.Errorf("authentication failed for user %q", c.username)
156161
}
157162

163+
// Record successful authentication (clears failed attempt counter)
164+
c.server.rateLimiter.RecordSuccessfulAuth(c.conn.RemoteAddr())
165+
158166
// Send auth OK
159167
if err := writeAuthOK(c.writer); err != nil {
160168
return err

0 commit comments

Comments
 (0)