Skip to content

fix(auth): prevent client-side rate limit bypass with server timestamp#396

Open
DeryFerd wants to merge 2 commits into
myrialabs:mainfrom
DeryFerd:fix/auth-rate-limit-server-timestamp
Open

fix(auth): prevent client-side rate limit bypass with server timestamp#396
DeryFerd wants to merge 2 commits into
myrialabs:mainfrom
DeryFerd:fix/auth-rate-limit-server-timestamp

Conversation

@DeryFerd

@DeryFerd DeryFerd commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

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:

  1. Fail 5 login attempts → locked out for 30 seconds
  2. Open browser DevTools console
  3. Type: lockoutSeconds = 0
  4. Login button becomes enabled again
  5. Continue brute-force attempts despite "lockout"

The 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.svelte component 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):

  • Changed check() return type from string | null to { message: string; lockedUntil: number } | null
  • When rate limited, return Unix timestamp of when lockout expires
  • This timestamp becomes the source of truth

Backend (backend/ws/auth/login.ts):

  • Catch rate limit errors and preserve lockedUntil property
  • Forward the timestamp through the error chain
  • Even after multiple error transformations, lockedUntil survives

WebSocket Layer (shared/utils/ws-server.ts):

  • Error serialization now checks for custom properties on Error objects
  • If lockedUntil exists, include it in the error response payload
  • Client receives: { success: false, error: "...", lockedUntil: 1720195830000 }

Frontend (frontend/components/auth/LoginPage.svelte):

  • Removed local lockoutSeconds countdown variable
  • Added serverLockedUntil timestamp from server
  • Added clientTime that syncs every 100ms
  • Lockout validation: clientTime < serverLockedUntil
  • Countdown display still shows remaining seconds for UX

The 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:

  1. The rate limit is only server-side (not enforced in the UI contract)
  2. They can script around it by ignoring the client countdown
  3. The system treats UI state as authoritative (even when it's not)

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.svelte and 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:

  1. Run bun run dev to start the local server
  2. Navigate to http://localhost:9141 (login page)
  3. Fail 5 login attempts with invalid PAT tokens
  4. Verify lockout message: "Too many failed attempts. Try again in 30 seconds."
  5. Open DevTools console
  6. Try to bypass:
    serverLockedUntil = 0;
    clientTime = Date.now();
  7. Verify login button stays disabled
  8. Try to submit anyway (button is disabled, but double-check)
  9. Verify server still returns rate limit error with lockedUntil timestamp
  10. Wait for actual lockout to expire
  11. Verify login works normally after expiration

The bypass no longer works. The UI correctly reflects server authority.

Technical Details

Timestamp Synchronization

The client updates clientTime every 100ms instead of every 1000ms. This gives smoother countdown display without constant re-renders. Svelte's $derived recomputes remainingSeconds on 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 when serverLockedUntil is set, and stopTimerSync() 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:

if (err instanceof Error && 'lockedUntil' in err) {
	errorResponse.lockedUntil = (err as any).lockedUntil;
}

This pattern can extend to other server-controlled metadata in the future (e.g., retryAfter headers, blockedUntil timestamps 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

  • Server-side rate limiting logic (authRateLimiter) is untouched
  • Lockout thresholds (5/10/20 failures → 30s/2m/10m) remain the same
  • Error messages still include human-readable countdown ("Try again in 30 seconds")
  • No breaking changes to the WebSocket API contract

The server was already doing the right thing. This fix makes the client respect it.

Edge Cases Handled

  1. 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.

  2. Tab sleep/wake: If the user leaves the tab and comes back hours later, clientTime will be stale. The timer sync logic handles this by recalculating based on Date.now(), so the countdown picks up where it left off.

  3. Multiple failed attempts during lockout: If the user somehow submits multiple requests while locked out (e.g., scripted), each response includes an updated lockedUntil timestamp. The client uses the latest one.

  4. Network delay: The lockedUntil timestamp 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:

  • Persistent lockout tracking: Store lockedUntil in localStorage so it survives page refresh
  • IP-based lockout display: Show when the server has blocked the IP entirely (beyond just rate limiting)
  • Progressive backoff visualization: Display "next failure adds 2 minutes" warnings before the lockout tiers kick in
  • Unlock notifications: Use ws.on('auth:lockout-expired') to notify the user when they can retry

None 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.

DeryFerd added 2 commits July 5, 2026 23:40
…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())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant