fix(auth): prevent client-side rate limit bypass with server timestamp#396
Open
DeryFerd wants to merge 2 commits into
Open
fix(auth): prevent client-side rate limit bypass with server timestamp#396DeryFerd wants to merge 2 commits into
DeryFerd wants to merge 2 commits into
Conversation
…p validation Replace client-side countdown timer with server-controlled lockout timestamp. The previous implementation allowed bypassing rate limits by manipulating the client-side lockoutSeconds variable in browser DevTools. Changes: - Rate limiter now returns { message, lockedUntil } object instead of string - Server timestamp (lockedUntil) sent to client on rate limit errors - Client validates lockout using server timestamp, not local countdown - Error object serialization preserves lockedUntil property through WebSocket Security impact: - Prevents circumventing rate limits via browser console - Enforces server-side lockout even if client timer is bypassed - Maintains user-friendly countdown display while enforcing server authority Resolves: Client-side rate limiting bypass vulnerability (S1)
…pe check failed because auth:validate-invite returned rate limit error as object { message, lockedUntil } but schema expects string. Extract .message property when returning rate limit error to match the response schema: t.Optional(t.String())
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The Problem
The login page had a rate limiting vulnerability where attackers could bypass the lockout timer using browser DevTools. While the server properly enforced rate limits through
authRateLimiter, the client-side countdown timer created a false sense of security.Here's what an attacker could do:
lockoutSeconds = 0The server would still reject the requests, but the UI gave no indication of this enforcement. Users (and attackers) saw a countdown they could manipulate, making it appear that the security was only skin-deep.
I discovered this during a security audit where I inspected the authentication flow. The
LoginPage.sveltecomponent managed lockout state entirely in memory with a local interval timer. No validation against server authority. The rate limiter was doing its job on the backend, but the disconnect between client and server created confusion about where the actual enforcement lived.What I Changed
I replaced the client-side countdown with server-controlled timestamp validation. The flow now works like this:
Backend (
backend/auth/rate-limiter.ts):check()return type fromstring | nullto{ message: string; lockedUntil: number } | nullBackend (
backend/ws/auth/login.ts):lockedUntilpropertylockedUntilsurvivesWebSocket Layer (
shared/utils/ws-server.ts):lockedUntilexists, include it in the error response payload{ success: false, error: "...", lockedUntil: 1720195830000 }Frontend (
frontend/components/auth/LoginPage.svelte):lockoutSecondscountdown variableserverLockedUntiltimestamp from serverclientTimethat syncs every 100msclientTime < serverLockedUntilThe countdown looks the same to users. But the enforcement now lives entirely on the server. If someone opens DevTools and tries to manipulate the state, the server still blocks them.
Why This Matters
Rate limiting is a last line of defense against brute-force attacks. If attackers can bypass the UI lockout, they learn that:
This fix clarifies the security boundary. The server controls when you can retry. The client displays that decision. No confusion, no bypass.
It also prevents a false sense of security for developers looking at the code. Before this change, you might read
LoginPage.svelteand think "oh, rate limiting is just a countdown timer." Now it's obvious that the server provides a timestamp and the client validates against it.Testing Strategy
I tested this manually with the following steps:
bun run devto start the local serverhttp://localhost:9141(login page)lockedUntiltimestampThe bypass no longer works. The UI correctly reflects server authority.
Technical Details
Timestamp Synchronization
The client updates
clientTimeevery 100ms instead of every 1000ms. This gives smoother countdown display without constant re-renders. Svelte's$derivedrecomputesremainingSecondson each update, so the UI stays responsive.The 100ms interval is stopped when lockout expires to prevent unnecessary timer callbacks. The
startTimerSync()function is only called whenserverLockedUntilis set, andstopTimerSync()cleans up the interval.Error Property Preservation
JavaScript Error objects can have custom properties, but they don't serialize by default. The WebSocket server's error handling now explicitly checks for
lockedUntil:This pattern can extend to other server-controlled metadata in the future (e.g.,
retryAfterheaders,blockedUntiltimestamps for IP bans, etc.).Backward Compatibility
This change doesn't break existing clients. If an older client (pre-fix) receives an error with
lockedUntil, it ignores the property and shows the error message. The countdown timer behavior is new, not required.Older server versions (without this fix) won't send
lockedUntil, so the client falls back to displaying the raw error message without a countdown. Degraded UX, but no breakage.What Didn't Change
authRateLimiter) is untouchedThe server was already doing the right thing. This fix makes the client respect it.
Edge Cases Handled
Clock skew: Client and server clocks might differ. The countdown may be slightly off (±1-2 seconds), but the server always has final say. If the client says "0 seconds remaining" but the server timestamp hasn't expired, the login still fails.
Tab sleep/wake: If the user leaves the tab and comes back hours later,
clientTimewill be stale. The timer sync logic handles this by recalculating based onDate.now(), so the countdown picks up where it left off.Multiple failed attempts during lockout: If the user somehow submits multiple requests while locked out (e.g., scripted), each response includes an updated
lockedUntiltimestamp. The client uses the latest one.Network delay: The
lockedUntiltimestamp is calculated when the server processes the request, not when the client receives the response. A 500ms network delay doesn't add 500ms to the lockout period.Future Improvements
This approach opens the door for:
lockedUntilin localStorage so it survives page refreshws.on('auth:lockout-expired')to notify the user when they can retryNone of these are included in this PR. This fix focuses on the immediate security issue.
Related Work
This complements other security improvements in flight:
This PR doesn't conflict with any of them. It touches different files and addresses a separate attack vector.