Skip to content

Commit a621f52

Browse files
Raoof128claude
andcommitted
perf(backend): full backend audit — offload blocking SDK calls, harden rate limiter + AI cache
- Offload all remaining synchronous Supabase/PostgREST SDK calls to worker threads via run_blocking: auth endpoints (login, refresh, register, logout, forgot/reset-password), lighter chat handlers, ~13 email-controller sites (ownership helpers now async), and the OAuth callback upsert — slow upstream calls no longer stall concurrent requests - Rate limiter: compaction sweep for the in-memory sliding window — keys embed caller-controlled input (login:{ip}:{email}) and previously grew without bound (memory-exhaustion DoS vector) - AI suggestion cache: SHA-256 keys (built-in 64-bit hash() collision could serve one user another user's cached suggestions) + expired-entry sweep on write instead of leaking until a same-key read Verification: ruff check/format clean, pytest 102/102, bandit 0 issues, frontend build OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 661e2e5 commit a621f52

8 files changed

Lines changed: 164 additions & 73 deletions

File tree

AGENT.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ description: Foundational agent rules for the Gemini + LiteStar + React project.
55

66
# Agent Rules
77

8+
### 2026-06-13 (Australia/Sydney) — Full Backend Audit + Hardening/Performance Pass
9+
10+
**Raouf:**
11+
12+
- **Scope:** Audit all 21 backend modules (auth middleware, controllers, services, rate limiting, data protection, email service/poller, OAuth, config, security headers, observability) + schemas/tests, then fix findings directly.
13+
- **Summary:** The backend was already strongly hardened (alg-confusion-guarded JWT with issuer/audience checks, RLS-scoped per-user PostgREST clients, PKCE+state OAuth with Host validation, Fernet-encrypted tokens/takeaways, prompt scrubbing + circuit breaker, Sentry scrubbing, production config guards). Four real issues fixed: **(1) Event-loop blocking on auth hot paths** — every synchronous Supabase auth SDK call (`/auth/login`, `/auth/refresh` which fires on every silent refresh, `register`, `logout` revocation, `forgot/reset-password`) ran directly on the event loop, stalling all concurrent requests during slow upstream calls; all now offloaded via `run_blocking`. Same completion for the remaining sync `.execute()` sites in `chat_controller` (list/create/delete sessions, get_messages), `email_controller` (~13 sites incl. the `_get_account`/`_get_email` ownership helpers, now async), and the OAuth callback upsert — this clears the deferred follow-up from the 2026-05-31 performance pass. **(2) In-memory rate limiter unbounded key growth (DoS)** — keys embed caller-controlled input (`login:{ip}:{email}`) and were never evicted; an attacker spraying unique emails could grow the dict without bound. Added a compaction sweep (threshold 10k keys) that drops keys whose entries have aged out. **(3) AI suggestion cache cross-user collision risk** — cache keys used Python's built-in 64-bit `hash()`; a collision would hand one user another user's cached suggestions. Switched to SHA-256. **(4) Suggestion cache slow leak** — expired entries were only removed on same-key reads; now swept on every write.
14+
- **Files Changed:** `backend/auth_controller.py`, `backend/chat_controller.py`, `backend/email_controller.py`, `backend/oauth_controller.py`, `backend/rate_limit.py`, `backend/services.py`.
15+
- **Verification:** `ruff check backend tests` ✓ (all checks passed), `ruff format --check` ✓ (38 files), `pytest` **102/102 pass**, `bandit -r backend` 0 issues, frontend `npm run build` ✓.
16+
- **Follow-ups (manual/optional):** Gmail N+1 message fetch + sequential account polling in `email_poller.py` could be parallelised (background path, affects mail freshness only); shared httpx transport for per-request PostgREST clients; streaming Gemini chat responses. **Not deployed** — droplet redeploy pending user go-ahead.
17+
818
### 2026-06-13 (Australia/Sydney) — Full UI/UX Audit + Accessibility/Polish Pass
919

1020
**Raouf:**

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Change Log
22

3+
### 2026-06-13 (Australia/Sydney) — Full Backend Audit + Hardening/Performance Pass
4+
5+
**Raouf:**
6+
7+
- **Scope:** Full audit of all 21 backend modules with direct fixes (security + performance + production-readiness).
8+
- **Summary:** Fixed four real issues in an otherwise heavily-hardened backend: (1) offloaded every remaining synchronous Supabase/PostgREST SDK call to worker threads via `run_blocking` — auth endpoints (login, refresh, register, logout, forgot/reset-password), the four lighter chat handlers, ~13 email-controller sites (ownership helpers `_get_account`/`_get_email` are now async), and the OAuth callback upsert — so slow upstream calls no longer stall every concurrent request (completes the deferred follow-up from the 2026-05-31 performance pass); (2) added a compaction sweep to the in-memory sliding-window rate limiter, whose keys embed caller-controlled input and previously grew without bound (memory-exhaustion DoS vector); (3) switched the AI suggestion cache key from Python's 64-bit `hash()` to SHA-256 — a collision could have served one user another user's cached suggestions; (4) the suggestion cache now sweeps expired entries on write instead of leaking them until a same-key read.
9+
- **Files Changed:** `backend/{auth_controller,chat_controller,email_controller,oauth_controller,rate_limit,services}.py`.
10+
- **Verification:** ruff check ✓, ruff format ✓ (38 files), pytest 102/102 ✓, bandit 0 issues, frontend build ✓.
11+
- **Follow-ups:** Optional: parallelise Gmail N+1 fetch + account polling in the poller; shared httpx transport for PostgREST clients; streaming chat responses. Not deployed to the droplet yet.
12+
313
### 2026-06-13 (Australia/Sydney) — Full UI/UX Audit + Accessibility/Polish Pass
414

515
**Raouf:**

backend/auth_controller.py

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
ResetPasswordRequest,
2222
SessionUser,
2323
)
24-
from .services import create_supabase_auth_client
24+
from .services import create_supabase_auth_client, run_blocking
2525
except ImportError: # pragma: no cover - supports backend cwd execution
2626
from auth import decode_supabase_token
2727
from config import get_settings
@@ -34,7 +34,7 @@
3434
ResetPasswordRequest,
3535
SessionUser,
3636
)
37-
from services import create_supabase_auth_client
37+
from services import create_supabase_auth_client, run_blocking
3838

3939
logger = logging.getLogger(__name__)
4040

@@ -173,8 +173,12 @@ async def login(self, data: LoginRequest, request: Request) -> Response:
173173

174174
enforce_auth_rate_limit(f"login:{_client_ip(request)}:{data.email.lower()}")
175175
try:
176-
auth_response = create_supabase_auth_client().auth.sign_in_with_password(
177-
{"email": data.email, "password": data.password}
176+
# The Supabase auth SDK is synchronous — offload its network calls
177+
# so one slow upstream call doesn't stall every concurrent request.
178+
client = create_supabase_auth_client()
179+
auth_response = await run_blocking(
180+
client.auth.sign_in_with_password,
181+
{"email": data.email, "password": data.password},
178182
)
179183
except Exception as exc: # pragma: no cover - upstream auth failure
180184
logger.exception("Supabase sign-in failed")
@@ -210,8 +214,9 @@ async def refresh(self, request: Request) -> Response:
210214
return Response(content={"authenticated": False})
211215

212216
try:
213-
auth_response = create_supabase_auth_client().auth.refresh_session(
214-
refresh_token
217+
client = create_supabase_auth_client()
218+
auth_response = await run_blocking(
219+
client.auth.refresh_session, refresh_token
215220
)
216221
except Exception as exc: # pragma: no cover - upstream auth failure
217222
logger.exception("Supabase token refresh failed")
@@ -241,9 +246,13 @@ async def logout(self, request: Request) -> Response:
241246
refresh_token = request.cookies.get(get_settings().refresh_cookie_name)
242247
if access_token and refresh_token:
243248
try:
244-
client = create_supabase_auth_client()
245-
client.auth.set_session(access_token, refresh_token)
246-
client.auth.sign_out()
249+
250+
def _revoke_session() -> None:
251+
client = create_supabase_auth_client()
252+
client.auth.set_session(access_token, refresh_token)
253+
client.auth.sign_out()
254+
255+
await run_blocking(_revoke_session)
247256
except Exception: # pragma: no cover - upstream auth failure
248257
logger.warning(
249258
"Supabase session revocation failed during logout",
@@ -259,8 +268,10 @@ async def register(self, data: RegisterRequest, request: Request) -> Response:
259268

260269
enforce_auth_rate_limit(f"register:{_client_ip(request)}")
261270
try:
262-
auth_response = create_supabase_auth_client().auth.sign_up(
263-
{"email": data.email, "password": data.password}
271+
client = create_supabase_auth_client()
272+
auth_response = await run_blocking(
273+
client.auth.sign_up,
274+
{"email": data.email, "password": data.password},
264275
)
265276
except Exception as exc: # pragma: no cover - upstream auth failure
266277
logger.exception("Supabase sign-up failed")
@@ -293,7 +304,9 @@ async def forgot_password(
293304
settings = get_settings()
294305
redirect_url = settings.password_reset_redirect_url
295306
try:
296-
create_supabase_auth_client().auth.reset_password_email(
307+
client = create_supabase_auth_client()
308+
await run_blocking(
309+
client.auth.reset_password_email,
297310
data.email,
298311
options={"redirect_to": redirect_url} if redirect_url else {},
299312
)
@@ -328,8 +341,10 @@ async def reset_password(
328341
raise HTTPException(status_code=400, detail="Recovery token is required.")
329342
try:
330343
client = create_supabase_auth_client()
331-
client.auth.set_session(access_token, refresh_token)
332-
user_response = client.auth.update_user({"password": data.new_password})
344+
await run_blocking(client.auth.set_session, access_token, refresh_token)
345+
user_response = await run_blocking(
346+
client.auth.update_user, {"password": data.new_password}
347+
)
333348
except Exception as exc: # pragma: no cover - upstream auth failure
334349
logger.exception("Password reset failed")
335350
raise HTTPException(
@@ -344,11 +359,12 @@ async def reset_password(
344359
)
345360

346361
try:
347-
login_response = client.auth.sign_in_with_password(
362+
login_response = await run_blocking(
363+
client.auth.sign_in_with_password,
348364
{
349365
"email": user_response.user.email,
350366
"password": data.new_password,
351-
}
367+
},
352368
)
353369
except Exception as exc: # pragma: no cover - upstream auth failure
354370
logger.exception("Post-reset sign-in failed")

backend/chat_controller.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,8 @@ async def list_sessions(
122122
query = _get_client(request).from_("chat_sessions").select("*")
123123
if category:
124124
query = query.eq("category", category)
125-
response = query.order("created_at", desc=True).execute()
125+
builder = query.order("created_at", desc=True)
126+
response = await run_blocking(builder.execute)
126127
except Exception as exc:
127128
logger.exception("Failed to list chat sessions")
128129
raise HTTPException(
@@ -136,7 +137,7 @@ async def create_session(self, data: ChatSessionCreate, request: Request) -> dic
136137

137138
user_id = request.state.user_id
138139
try:
139-
response = (
140+
builder = (
140141
_get_client(request)
141142
.from_("chat_sessions")
142143
.insert(
@@ -146,8 +147,8 @@ async def create_session(self, data: ChatSessionCreate, request: Request) -> dic
146147
"category": data.category,
147148
}
148149
)
149-
.execute()
150150
)
151+
response = await run_blocking(builder.execute)
151152
except Exception as exc:
152153
logger.exception("Failed to create chat session")
153154
raise HTTPException(
@@ -160,9 +161,13 @@ async def delete_session(self, session_id: str, request: Request) -> None:
160161
"""Delete a chat session and all its messages (cascade)."""
161162

162163
try:
163-
_get_client(request).from_("chat_sessions").delete().eq(
164-
"id", session_id
165-
).execute()
164+
builder = (
165+
_get_client(request)
166+
.from_("chat_sessions")
167+
.delete()
168+
.eq("id", session_id)
169+
)
170+
await run_blocking(builder.execute)
166171
except Exception as exc:
167172
logger.exception("Failed to delete chat session")
168173
raise HTTPException(
@@ -174,14 +179,14 @@ async def get_messages(self, session_id: str, request: Request) -> list[dict]:
174179
"""Get all messages for a chat session."""
175180

176181
try:
177-
response = (
182+
builder = (
178183
_get_client(request)
179184
.from_("chat_messages")
180185
.select("*")
181186
.eq("session_id", session_id)
182187
.order("created_at")
183-
.execute()
184188
)
189+
response = await run_blocking(builder.execute)
185190
except Exception as exc:
186191
logger.exception("Failed to load chat messages")
187192
raise HTTPException(

0 commit comments

Comments
 (0)