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
d0e925bf70to1446d6873bRebased
feature/login-rate-limitontomain, resolving merge conflicts ininternal/handlers/handlers.go.Conflict resolution: The
Handlersstruct andNew()constructor now include bothchannelHashcash(from #79) andloginLimiter(from this PR).README.mdandapi_test.goauto-merged cleanly.Build status:
docker build .fails due toTestLoginValidtiming out at 30s — but this is a pre-existing issue onmain(confirmed:mainHEADbf4d63balso fails the same way). This PR's changes did not introduce the failure.Review: PR #78 — Login Rate Limiting (post-rebase attempt)
Policy Divergences
1. Branch not rebased onto current
main— merge conflicts presentThe PR branch (
feature/login-rate-limit) is based onbf4d63b, butmainis now atdb3d23c(which includes PR #82 — username/hostname support with bcrypt MinCost for tests). Attempting to merge produces conflicts in:README.mdinternal/config/config.gointernal/handlers/api_test.goGitea confirms:
Mergeable: false. The PR cannot be merged as-is.sneak's last comment (2026-03-18) was "@clawbot fix conflict pls" — this remains unaddressed against current
main. The reworker's rebase resolved against stalemain(bf4d63b) instead of currentmain(db3d23c).Requirements Checklist (issue #35)
POST /api/v1/loginonly (per sneak's instruction)golang.org/x/time/rateRetry-Afterheader on limit exceededmainBuild Result
docker build .on the PR branch alone: ✅ PASSES (all tests green, lint clean, 22.5s handlers).However, the branch cannot be merged into current
maindue to conflicts. A successful isolated build is not sufficient — the branch must be mergeable.Code Quality Assessment (for reference — not sufficient to override FAIL)
The implementation itself is solid:
ratelimit.Limiterininternal/ratelimit/, login-specific wiring inhandlers.goStop()called instopCleanup()loginRate <= 0falls back toDefaultRategolang.org/x/time v0.6.0pinned ingo.sum✅testing.T, no assertion libs, parallel ✅.golangci.yml,Makefile, or CI config ✅nolintdirectives justified (mndforSplitN(,2),exhaustructfor existing pattern) ✅Verdict: FAIL ❌
The branch has merge conflicts with current
main(db3d23c) and cannot be merged. sneak's instruction to fix conflicts is not addressed against the currentmainHEAD. Needs a rebase ontomainthat incorporates PR #82 changes (username/hostname support, bcrypt MinCost for tests, conditional-vin Makefile).1446d6873bto0bcc711a92Rebased
feature/login-rate-limitonto current main (db3d23c).Conflicts resolved:
README.md— kept both OPER config vars (from PR #82) and rate-limit config varsinternal/config/config.go— merged OPER and rate-limit fields in struct, defaults, and config initialization (3 conflict regions)internal/handlers/api_test.go— kept all username/hostname test helpers and tests from main alongside rate-limit testsAdditional fixes:
clientIP()function fromauth.go(identical copy already exists inapi.gofrom PR #82)executeLogin()helper fromhandleLogin()to satisfyfunlenlint rule (was 86 lines, limit 80)docker build .passes — lint clean (0 issues), all tests green.Review: PR #78 — Login Rate Limiting (post-rebase onto
db3d23c)Rebase Integrity
db3d23c) is ancestor of PR branchgit merge-base --is-ancestorconfirmsfunc Test*from main present, plus 2 newOperName,OperPassword,LoginRateLimit,LoginRateBurstall presentclientIP()exists only inapi.go(no duplicate inauth.go)TestWhoisShowsHostInfo,TestWhoShowsHostInfo,TestSessionUsernameDefault,TestNamesShowsHostmask, OPER tests)Requirements Checklist (issue #35)
POST /api/v1/loginonly (per sneak's instruction)handleLoginreferencesloginLimiterapi.goorsession.gogolang.org/x/time/rate)LOGIN_RATE_LIMIT/LOGIN_RATE_BURSTenv varsRetry-After: 1header when limit exceededt.Parallel()TestLoginRateLimitExceeded,TestLoginRateLimitAllowsNormalUse)Policy Compliance
.golangci.ymlunmodifiedMakefileunmodifiedgolang.org/x/time v0.6.0pinned by hash ingo.sumtesting.T, no assertion librariesinternal/(notcmd/)internal/ratelimit/nolintdirectives: only pre-existing (exhaustruct,mnd,contextcheck)handleLogin55 lines,executeLogin44 lines — both under funlen 80Code Quality Notes
executeLoginextraction is clean — all original logic preserved,payload.Nick/payload.Passwordcorrectly replaced withnick/passwordparamsLimiter.Allow()holds mutex only for map lookup, callsrate.Limiter.Allow()outside lock — correct sincerate.Limiteris goroutine-safeStop()properly called instopCleanup()with nil guardloginRate <= 0falls back toDefaultRateBuild Result
✅
docker build .— PASSED (lint clean, all tests green, binary builds)Verdict: PASS ✅
Rebase onto
db3d23cis clean — no lost code, no conflict markers, all main branch features (username/hostname/OPER from PR #82) preserved alongside the rate limiting implementation. Build is green. Implementation is correct and scoped per sneak's instructions.0bcc711a92to1eee5f5508Rebased onto current
mainafter PR #84 merge (auth refactor: cookies replace Bearer tokens,/registerremoved, PASS command added).Conflicts resolved:
internal/handlers/auth.go—executeLogin()response: removedtokenfield from JSON body (now set viasetAuthCookie), usednickparameter instead ofpayload.Nick(correct scope in extracted function)Auto-merged files updated for compatibility:
internal/handlers/api_test.go—TestLoginRateLimitAllowsNormalUse: replaced removed/api/v1/registercall withcreateSession()+ PASS command (matching current test patterns)Verified:
docker build .passes — lint, fmt-check, all tests green including rate limit tests.Review: PR #78 — per-IP rate limiting on login endpoint
Verdict: PASS ✅
Rebase Integrity
Clean single commit (
1eee5f5) on top of currentmain. No conflict markers found. No stale references toBearertokens or/registerendpoint. All PR #84 auth refactor code (cookie-based auth,setAuthCookie,doRequestAuthwith cookies) is intact.Code Review
internal/ratelimit/ratelimit.go— Clean token-bucket implementation wrappinggolang.org/x/time/rate. Per-key map with mutex, background sweep goroutine with configurable TTL, properStop()for cleanup. TheAllow()method correctly releases the mutex before callingent.limiter.Allow()(safe becauserate.Limiteris goroutine-safe). No issues.internal/handlers/auth.go— Rate limit check is the first thing inhandleLogin, before JSON parsing or bcrypt. Returns 429 withRetry-After: 1header. TheexecuteLoginextraction is clean —handleLoginhandles parsing + validation + rate limiting, then delegates toexecuteLogin(nick, password). All references usenickparameter, notpayload.Nick. UsessetAuthCookie(cookie auth, not Bearer). Correct.internal/handlers/handlers.go—loginLimiterfield properly initialized inNew()with config values (with fallback to defaults if ≤0).Stop()called instopCleanup(). Clean integration.internal/config/config.go—LOGIN_RATE_LIMIT(float64) andLOGIN_RATE_BURST(int) with sensible defaults (1 req/s, burst 5).Scope — Rate limiting is applied ONLY to
handleLogin. Session creation endpoint is not rate-limited (protected by hashcash instead). Matches issue #35 scope.Tests
internal/ratelimit/ratelimit_test.go— 6 unit tests covering creation, burst allowance, burst exhaustion, key isolation, key counting, and stop safety. All uset.Parallel(). Tests with rate=0 for deterministic burst-only behavior — good approach.internal/handlers/api_test.go— Two integration tests:TestLoginRateLimitExceeded: Exhausts burst of 5 with bad logins, verifies 6th gets 429 +Retry-Afterheader.TestLoginRateLimitAllowsNormalUse: Creates session viacreateSession()+ PASS command (cookie-based, no/register), then verifies single login succeeds with 200.Build
docker build .passes — lint, fmt-check, tests, compilation all green.Documentation
README properly documents the env vars, the token-bucket algorithm, the 429 response, and includes a thorough security warning about X-Forwarded-For spoofing and reverse proxy requirements. The rationale for rate limiting login (bcrypt cost) vs. not rate limiting session creation (hashcash) is clearly explained.