feat: add per-IP rate limiting to login endpoint #78
Reference in New Issue
Block a user
Delete Branch "feature/login-rate-limit"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Adds per-IP rate limiting to
POST /api/v1/loginto prevent brute-force password attacks.closes #35
What Changed
New package:
internal/ratelimit/A generic per-key token-bucket rate limiter built on
golang.org/x/time/rate:New(ratePerSec, burst)creates a limiter with automatic background cleanup of stale entriesAllow(key)checks if a request from the given key should be permittedStop()terminates the background sweep goroutineLogin handler integration
The login handler (
internal/handlers/auth.go) now:X-Forwarded-For,X-Real-IP, orRemoteAddrRetry-After: 1header when the limit is exceededConfiguration
Two new environment variables (via Viper):
LOGIN_RATE_LIMIT1LOGIN_RATE_BURST5Scope
Per sneak's instruction, only the login endpoint is rate-limited. Session creation and registration use hashcash proof-of-work instead.
Tests
ratelimitpackage (constructor, burst, burst exceeded, key isolation, key tracking, stop)api_test.go:TestLoginRateLimitExceeded: exhausts burst with rapid requests, verifies 429 response andRetry-AfterheaderTestLoginRateLimitAllowsNormalUse: verifies normal login still worksREADME
LOGIN_RATE_LIMITandLOGIN_RATE_BURSTto the Configuration tableReview: PR #78 — Login Rate Limiting
Requirements Checklist (#35)
POST /api/v1/loginonly (per sneak's instruction)golang.org/x/time/rateRetry-Afterheader on limit exceededPolicy Compliance
golang.org/x/time v0.6.0hash presentcmd/internal/internal/ratelimit/testing.T, no assertion librariesif val := ...; val != ""is value-checking, not error handling)nolintdirectives justifiedmndforSplitN(,2),exhaustructfor existing patternSecurity Analysis: IP Extraction
clientIP()checksX-Forwarded-For→X-Real-IP→RemoteAddr. Without a trusted reverse proxy, these headers are client-controlled — an attacker could rotateX-Forwarded-Forvalues to bypass rate limiting, or set a victim's IP to deny them service.Assessment: This is the standard trade-off for IP-based rate limiting. Using only
RemoteAddrwould break rate limiting behind any reverse proxy (all clients share the proxy IP). The codebase has no existing proxy trust configuration. This is a reasonable first implementation. If sneak wants hardened proxy trust (trusted CIDR allowlist for header stripping), that's a follow-up issue, not a blocker for this PR.Build Result
✅
docker build .— PASSEDratelimitunit tests passTestLoginRateLimitExceeded,TestLoginRateLimitAllowsNormalUse)Code Quality Notes
ratelimit.Limiterininternal/ratelimit/, login-specific wiring inhandlers.goStop()called instopCleanup()via fx OnStop hookloginRate <= 0falls back toDefaultRateentrytype is unexported (correctly private)selecton both ticker and stop channel — clean shutdownVerdict: PASS ✅
Clean implementation, correct scoping per sneak's instructions, comprehensive tests, full policy compliance, and the build is green.
fix the conflict, and update the README in this PR to document the security (client-controlled req headers) issue when using this without a properly configured reverse proxy.
ba943d95edtod0e925bf70Rework Complete
What was done
Rebased onto
main— resolved merge conflict ininternal/handlers/handlers.gowhere PR #80 (runtime stats) and this PR both modified imports, struct fields, and the constructor. Bothstatsandratelimitare now present.Added reverse proxy security note to the "Login Rate Limiting" section of README.md, documenting:
clientIP()trustsX-Forwarded-ForandX-Real-IPheadersBuild verification
✅
docker build .passes — all tests green (including ratelimit unit tests and login rate limit integration tests), lint/format clean, binary builds.Post-Rework Review: PR #78 — Login Rate Limiting
Rework Verification
main(resolve conflict with PR #80)ratelimitandstatsimports, struct fields, and constructor presentRebase Integrity
The merge conflict in
internal/handlers/handlers.gowas resolved correctly:ratelimitandstatsimports present in the import blockHandlersstruct has bothloginLimiter *ratelimit.Limiterandstats *stats.TrackerfieldsParamsstruct includesStats *stats.Tracker(from PR #80)loginLimiter: ratelimit.New(...)andstats: params.StatsstopCleanup()callsloginLimiter.Stop()— lifecycle management intactSecurity Documentation Verification
The README security warning accurately matches the
clientIP()implementation ininternal/handlers/auth.go:X-Forwarded-Forfirstrequest.Header.Get("X-Forwarded-For")checked firstX-Real-IPRemoteAddrnet.SplitHostPort(request.RemoteAddr)as final fallbackX-Forwarded-ForvaluesThe documentation is clear, technically accurate, and appropriately scoped. The blockquote callout format with ⚠️ makes the warning visually prominent.
Policy Compliance
golang.org/x/time v0.6.0hash in go.sum)testing.T, no assertion librariesnolintdirectives justifiedmndforSplitN(,2),exhaustructfor existing patterncmd/internal/Build Result
✅
docker build .— PASSEDTestLoginRateLimitExceeded— confirms 429 withRetry-AfterheaderTestLoginRateLimitAllowsNormalUse— confirms normal login worksVerdict: PASS ✅
Rebase is clean, security documentation accurately reflects the code, all tests green, build passes. The rework addresses both of sneak's requests (conflict resolution + security documentation).
@clawbot fix conflict pls
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.