From cfea2586cb71ea572144b29225875c67b708d25a Mon Sep 17 00:00:00 2001 From: CartSnitch Engineer Bot Date: Tue, 14 Apr 2026 11:45:53 +0000 Subject: [PATCH 01/12] feat(api): add input validation on public endpoints - Add days query param to GET /public/trends/{product_id} (ge=1, le=365) - Add category query param to GET /public/store-comparison - Add category and period query params to GET /public/inflation - Add boundary and malicious input test cases Co-Authored-By: Paperclip --- api/src/cartsnitch_api/routes/public.py | 19 +++-- api/src/cartsnitch_api/services/public.py | 65 ++++++++++------ api/tests/test_routes/test_public.py | 94 +++++++++++++++++++++++ 3 files changed, 150 insertions(+), 28 deletions(-) diff --git a/api/src/cartsnitch_api/routes/public.py b/api/src/cartsnitch_api/routes/public.py index 5d0b87b..4b5c5dc 100644 --- a/api/src/cartsnitch_api/routes/public.py +++ b/api/src/cartsnitch_api/routes/public.py @@ -18,10 +18,14 @@ router = APIRouter(prefix="/public", tags=["public"]) @router.get("/trends/{product_id}", response_model=PublicTrendResponse) -async def public_price_trend(product_id: UUID, db: AsyncSession = Depends(get_db)): +async def public_price_trend( + product_id: UUID, + days: int = Query(90, ge=1, le=365), + db: AsyncSession = Depends(get_db), +): svc = PublicService(db) try: - return await svc.get_trend(product_id) + return await svc.get_trend(product_id, days=days) except LookupError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Product not found" @@ -31,6 +35,7 @@ async def public_price_trend(product_id: UUID, db: AsyncSession = Depends(get_db @router.get("/store-comparison", response_model=PublicStoreComparisonResponse) async def public_store_comparison( product_ids: Annotated[list[UUID], Query(max_length=20)], + category: str | None = Query(None, max_length=100, pattern=r"^[a-zA-Z0-9 _-]+$"), db: AsyncSession = Depends(get_db), ): if not product_ids: @@ -39,10 +44,14 @@ async def public_store_comparison( detail="At least one product_id is required", ) svc = PublicService(db) - return await svc.get_store_comparison(product_ids) + return await svc.get_store_comparison(product_ids, category=category) @router.get("/inflation", response_model=PublicInflationResponse) -async def public_inflation(db: AsyncSession = Depends(get_db)): +async def public_inflation( + category: str | None = Query(None, max_length=100, pattern=r"^[a-zA-Z0-9 _-]+$"), + period: str = Query("all-time", pattern=r"^(all-time|1y|6m|3m|1m)$"), + db: AsyncSession = Depends(get_db), +): svc = PublicService(db) - return await svc.get_inflation() + return await svc.get_inflation(category=category, period=period) diff --git a/api/src/cartsnitch_api/services/public.py b/api/src/cartsnitch_api/services/public.py index f1ccbeb..5ff5e8d 100644 --- a/api/src/cartsnitch_api/services/public.py +++ b/api/src/cartsnitch_api/services/public.py @@ -1,5 +1,6 @@ """Public service — unauthenticated price transparency endpoints.""" +from datetime import date, timedelta from uuid import UUID from sqlalchemy import and_, func, select @@ -13,7 +14,7 @@ class PublicService: def __init__(self, db: AsyncSession) -> None: self.db = db - async def get_trend(self, product_id: UUID) -> dict: + async def get_trend(self, product_id: UUID, days: int = 90) -> dict: from cartsnitch_api.models import NormalizedProduct, PriceHistory result = await self.db.execute( @@ -23,9 +24,13 @@ class PublicService: if not product: raise LookupError("Product not found") + date_threshold = date.today() - timedelta(days=days) prices_result = await self.db.execute( select(PriceHistory) - .where(PriceHistory.normalized_product_id == product_id) + .where( + PriceHistory.normalized_product_id == product_id, + PriceHistory.observed_date >= date_threshold, + ) .options(selectinload(PriceHistory.store)) .order_by(PriceHistory.observed_date) ) @@ -45,20 +50,25 @@ class PublicService: ], } - async def get_store_comparison(self, product_ids: list[UUID]) -> dict: + async def get_store_comparison( + self, product_ids: list[UUID], category: str | None = None + ) -> dict: from cartsnitch_api.models import NormalizedProduct, PriceHistory if not product_ids: return {"products": []} - # Fetch all products in one query - prod_result = await self.db.execute( - select(NormalizedProduct).where(NormalizedProduct.id.in_(product_ids)) - ) + product_query = select(NormalizedProduct).where(NormalizedProduct.id.in_(product_ids)) + if category: + product_query = product_query.where(NormalizedProduct.category == category) + prod_result = await self.db.execute(product_query) products_by_id = {p.id: p for p in prod_result.scalars().all()} - # Latest prices for all requested products in one query - subq = latest_price_per_store(product_ids) + if not products_by_id: + return {"products": []} + + filtered_product_ids = list(products_by_id.keys()) + subq = latest_price_per_store(filtered_product_ids) prices_result = await self.db.execute( select(PriceHistory) .join( @@ -69,18 +79,17 @@ class PublicService: PriceHistory.normalized_product_id == subq.c.normalized_product_id, ), ) - .where(PriceHistory.normalized_product_id.in_(product_ids)) + .where(PriceHistory.normalized_product_id.in_(filtered_product_ids)) .options(selectinload(PriceHistory.store)) ) all_prices = prices_result.scalars().all() - # Group by product prices_by_product: dict[UUID, list] = {} for ph in all_prices: prices_by_product.setdefault(ph.normalized_product_id, []).append(ph) products = [] - for pid in product_ids: + for pid in filtered_product_ids: product = products_by_id.get(pid) if not product: continue @@ -102,19 +111,29 @@ class PublicService: return {"products": products} - async def get_inflation(self) -> dict: + async def get_inflation(self, category: str | None = None, period: str = "all-time") -> dict: """Aggregate price change stats. Compares average prices across periods.""" from cartsnitch_api.models import NormalizedProduct, PriceHistory - # Get average prices grouped by category for recent vs older data - result = await self.db.execute( - select( - NormalizedProduct.category, - func.avg(PriceHistory.regular_price), - ) - .join(NormalizedProduct) - .group_by(NormalizedProduct.category) - ) + date_threshold = None + if period != "all-time": + days_map = {"1y": 365, "6m": 180, "3m": 90, "1m": 30} + days = days_map.get(period, 365) + date_threshold = date.today() - timedelta(days=days) + + query = select( + NormalizedProduct.category, + func.avg(PriceHistory.regular_price), + ).join(NormalizedProduct) + + if category: + query = query.where(NormalizedProduct.category == category) + if date_threshold: + query = query.where(PriceHistory.observed_date >= date_threshold) + + query = query.group_by(NormalizedProduct.category) + + result = await self.db.execute(query) categories = {} for row in result.all(): cat, avg_price = row @@ -122,7 +141,7 @@ class PublicService: categories[cat] = float(avg_price) if avg_price else 0.0 return { - "period": "all-time", + "period": period, "cartsnitch_index": sum(categories.values()) / max(len(categories), 1), "cpi_baseline": 100.0, "categories": categories, diff --git a/api/tests/test_routes/test_public.py b/api/tests/test_routes/test_public.py index 08a5d29..931bca5 100644 --- a/api/tests/test_routes/test_public.py +++ b/api/tests/test_routes/test_public.py @@ -71,3 +71,97 @@ async def test_public_inflation(client, public_data): data = resp.json() assert "categories" in data assert "cartsnitch_index" in data + + +@pytest.mark.asyncio +async def test_trend_invalid_uuid(client): + resp = await client.get("/public/trends/not-a-uuid") + assert resp.status_code == 422 + assert "detail" in resp.json() + assert "stack" not in resp.json() + + +@pytest.mark.asyncio +async def test_trend_days_zero(client, public_data): + pid = str(public_data["product"].id) + resp = await client.get(f"/public/trends/{pid}?days=0") + assert resp.status_code == 422 + assert "detail" in resp.json() + assert "stack" not in resp.json() + + +@pytest.mark.asyncio +async def test_trend_days_negative(client, public_data): + pid = str(public_data["product"].id) + resp = await client.get(f"/public/trends/{pid}?days=-1") + assert resp.status_code == 422 + assert "detail" in resp.json() + assert "stack" not in resp.json() + + +@pytest.mark.asyncio +async def test_trend_days_over_max(client, public_data): + pid = str(public_data["product"].id) + resp = await client.get(f"/public/trends/{pid}?days=999") + assert resp.status_code == 422 + assert "detail" in resp.json() + assert "stack" not in resp.json() + + +@pytest.mark.asyncio +async def test_trend_days_valid(client, public_data): + pid = str(public_data["product"].id) + resp = await client.get(f"/public/trends/{pid}?days=30") + assert resp.status_code == 200 + assert "product_name" in resp.json() + + +@pytest.mark.asyncio +async def test_store_comparison_empty_list(client): + resp = await client.get("/public/store-comparison") + assert resp.status_code == 400 + assert "detail" in resp.json() + + +@pytest.mark.asyncio +async def test_store_comparison_category_xss(client, public_data): + pid = str(public_data["product"].id) + resp = await client.get( + f"/public/store-comparison?product_ids={pid}&category=" + ) + assert resp.status_code == 422 + assert "detail" in resp.json() + assert "stack" not in resp.json() + + +@pytest.mark.asyncio +async def test_store_comparison_category_sql_injection(client, public_data): + pid = str(public_data["product"].id) + resp = await client.get(f"/public/store-comparison?product_ids={pid}&category='; DROP TABLE--") + assert resp.status_code == 422 + assert "detail" in resp.json() + assert "stack" not in resp.json() + + +@pytest.mark.asyncio +async def test_inflation_invalid_period(client, public_data): + resp = await client.get("/public/inflation?period=10years") + assert resp.status_code == 422 + assert "detail" in resp.json() + assert "stack" not in resp.json() + + +@pytest.mark.asyncio +async def test_inflation_valid_periods(client, public_data): + for period in ["all-time", "1y", "6m", "3m", "1m"]: + resp = await client.get(f"/public/inflation?period={period}") + assert resp.status_code == 200, f"period={period} failed" + + +@pytest.mark.asyncio +async def test_inflation_category_too_long(client, public_data): + long_category = "x" * 200 + resp = await client.get(f"/public/inflation?category={long_category}") + assert resp.status_code == 422 + assert "detail" in resp.json() + assert "stack" not in resp.json() From 6b75d4906f148572cefa121b006cdeb2344b952e Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Tue, 14 Apr 2026 13:41:55 +0000 Subject: [PATCH 02/12] feat: implement audit logging middleware for sensitive API operations - Add AuditMiddleware that logs POST/PUT/PATCH/DELETE and GET /auth/me - Logs structured JSON: event, timestamp, user_id, method, path, client_ip, status_code, duration_ms - Excludes health endpoints and OPTIONS requests - Never logs request/response bodies or auth headers/cookies - Wire user_id from auth dependency via request.state - Add add_audit_middleware() to app factory Co-Authored-By: Paperclip --- api/src/cartsnitch_api/auth/dependencies.py | 8 ++- api/src/cartsnitch_api/main.py | 2 + api/src/cartsnitch_api/middleware/audit.py | 64 +++++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 api/src/cartsnitch_api/middleware/audit.py diff --git a/api/src/cartsnitch_api/auth/dependencies.py b/api/src/cartsnitch_api/auth/dependencies.py index 5040741..ded7013 100644 --- a/api/src/cartsnitch_api/auth/dependencies.py +++ b/api/src/cartsnitch_api/auth/dependencies.py @@ -69,7 +69,9 @@ async def get_current_user( token: str | None = None # 1. Check session cookie — prefer __Secure- variant (HTTPS) over plain (HTTP dev) - cookie_token = request.cookies.get(SECURE_SESSION_COOKIE_NAME) or request.cookies.get(SESSION_COOKIE_NAME) + cookie_token = request.cookies.get(SECURE_SESSION_COOKIE_NAME) or request.cookies.get( + SESSION_COOKIE_NAME + ) if cookie_token: # Better-Auth cookie format is "token.sessionId" — extract just the token part token = cookie_token.split(".")[0] if "." in cookie_token else cookie_token @@ -86,7 +88,9 @@ async def get_current_user( detail="Authentication required", ) - return await _validate_session_token(token, db) + user_id = await _validate_session_token(token, db) + request.state.user_id = user_id + return user_id async def verify_service_key(x_service_key: str = Header()) -> None: diff --git a/api/src/cartsnitch_api/main.py b/api/src/cartsnitch_api/main.py index 6db5a0c..1aa2e74 100644 --- a/api/src/cartsnitch_api/main.py +++ b/api/src/cartsnitch_api/main.py @@ -8,6 +8,7 @@ from cartsnitch_api.auth.routes import router as auth_router from cartsnitch_api.middleware.cors import add_cors_middleware from cartsnitch_api.middleware.error_handler import add_error_handlers, add_error_monitor_middleware from cartsnitch_api.middleware.rate_limit import add_rate_limit_middleware +from cartsnitch_api.middleware.audit import add_audit_middleware from cartsnitch_api.routes.alerts import router as alerts_router from cartsnitch_api.routes.coupons import router as coupons_router from cartsnitch_api.routes.health import router as health_router @@ -40,6 +41,7 @@ def create_app() -> FastAPI: add_cors_middleware(app) add_error_monitor_middleware(app) add_rate_limit_middleware(app) + add_audit_middleware(app) # Exception handlers add_error_handlers(app) diff --git a/api/src/cartsnitch_api/middleware/audit.py b/api/src/cartsnitch_api/middleware/audit.py new file mode 100644 index 0000000..2868505 --- /dev/null +++ b/api/src/cartsnitch_api/middleware/audit.py @@ -0,0 +1,64 @@ +"""Audit logging middleware for sensitive API operations. + +Logs structured JSON for POST/PUT/PATCH/DELETE requests and GET /auth/me. +Never logs request bodies, response bodies, Authorization headers, or cookie values. +""" + +import json +import logging +import time +from collections.abc import Awaitable, Callable + +from fastapi import FastAPI, Request +from starlette.middleware.base import BaseHTTPMiddleware + +logger = logging.getLogger("cartsnitch_api.audit") + +HEALTH_PATHS = {"/health", "/healthz", "/ready"} + + +class AuditMiddleware(BaseHTTPMiddleware): + """Middleware to log structured audit events for sensitive operations.""" + + async def dispatch( + self, + request: Request, + call_next: Callable[[Request], Awaitable], + ): + if request.method == "OPTIONS" or request.url.path in HEALTH_PATHS: + return await call_next(request) + + method = request.method + path = request.url.path + + is_sensitive_write = method in {"POST", "PUT", "PATCH", "DELETE"} + is_auth_me_read = method == "GET" and path == "/auth/me" + + if not (is_sensitive_write or is_auth_me_read): + return await call_next(request) + + start = time.perf_counter() + response = await call_next(request) + duration_ms = (time.perf_counter() - start) * 1000 + + user_id = getattr(request.state, "user_id", None) + client_ip = request.client.host if request.client else "unknown" + + log_entry = { + "event": "audit", + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "user_id": user_id, + "method": method, + "path": path, + "client_ip": client_ip, + "status_code": response.status_code, + "duration_ms": round(duration_ms, 2), + } + + logger.info(json.dumps(log_entry)) + + return response + + +def add_audit_middleware(app: FastAPI) -> None: + app.add_middleware(AuditMiddleware) From ee45400c7cb86ad65bb3678d02f25f51fc105471 Mon Sep 17 00:00:00 2001 From: Paperclip Date: Tue, 14 Apr 2026 14:43:46 +0000 Subject: [PATCH 03/12] fix: update vite to 6.4.2 to patch high-severity vulnerabilities Vite 6.4.1 has two high-severity vulnerabilities: - GHSA-4w7w-66w2-5vf9: Path Traversal in Optimized Deps .map Handling - GHSA-p9ff-h696-f583: Arbitrary File Read via Vite Dev Server WebSocket Updated to vite 6.4.2. Fixes CAR-599. Co-Authored-By: Paperclip --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a56c4d4..709106e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9805,9 +9805,9 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "devOptional": true, "license": "MIT", "dependencies": { From 121dc5724efa574be02050812996de71ac2e205e Mon Sep 17 00:00:00 2001 From: Paperclip Date: Tue, 14 Apr 2026 15:37:24 +0000 Subject: [PATCH 04/12] fix: remove VITE_MOCK_AUTH bypass from production code Co-Authored-By: Paperclip --- e2e/journeys/j1-registration-login.spec.ts | 1 - playwright.config.ts | 2 +- src/components/ProtectedRoute.tsx | 17 ----------------- src/pages/Login.tsx | 9 +-------- src/pages/Register.tsx | 9 +-------- 5 files changed, 3 insertions(+), 35 deletions(-) diff --git a/e2e/journeys/j1-registration-login.spec.ts b/e2e/journeys/j1-registration-login.spec.ts index ec116ab..b1b28a4 100644 --- a/e2e/journeys/j1-registration-login.spec.ts +++ b/e2e/journeys/j1-registration-login.spec.ts @@ -10,7 +10,6 @@ test.describe('J1: Registration and Login', () => { await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!'); await page.click('button[type="submit"]'); - // With VITE_MOCK_AUTH=true the app navigates to "/" on success await expect(page).toHaveURL('http://localhost:5173/'); await expect(page.getByRole('heading', { name: /cart/i })).toBeVisible(); }); diff --git a/playwright.config.ts b/playwright.config.ts index b22d74a..a2d7b0b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ }, ], webServer: { - command: 'VITE_MOCK_AUTH=true npm run dev', + command: 'npm run dev', url: 'http://localhost:5173', reuseExistingServer: !process.env.CI, }, diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index cf92831..294ec4f 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -1,25 +1,8 @@ -import { useEffect } from 'react' import { Navigate, Outlet } from 'react-router-dom' import { authClient } from '../lib/auth-client.ts' -import { useAuthStore } from '../stores/auth.ts' export function ProtectedRoute() { - const isMockAuth = import.meta.env.VITE_MOCK_AUTH === 'true' const { data: session, isPending } = authClient.useSession() - const isAuthenticated = useAuthStore((s) => s.isAuthenticated) - const setAuthenticated = useAuthStore((s) => s.setAuthenticated) - - useEffect(() => { - if (!isMockAuth) { - setAuthenticated(!!session) - } - }, [session, setAuthenticated, isMockAuth]) - - // In mock auth mode, rely on Zustand store (set by Login/Register pages) - if (isMockAuth) { - if (!isAuthenticated) return - return - } if (isPending) { return ( diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index ae7fc0c..5044613 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,7 +1,6 @@ import { useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { authClient } from '../lib/auth-client.ts' -import { useAuthStore } from '../stores/auth.ts' export function Login() { const [email, setEmail] = useState('') @@ -9,7 +8,6 @@ export function Login() { const [error, setError] = useState('') const [loading, setLoading] = useState(false) const navigate = useNavigate() - const setAuthenticated = useAuthStore((s) => s.setAuthenticated) async function handleSubmit(e: React.FormEvent) { e.preventDefault() @@ -40,12 +38,7 @@ export function Login() { setError('Sign in failed. Please try again.') } } catch { - if (import.meta.env.VITE_MOCK_AUTH === 'true') { - setAuthenticated(true) - navigate('/') - } else { - setError('Invalid email or password. Please try again.') - } + setError('Invalid email or password. Please try again.') } finally { setLoading(false) } diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index c75e2d6..960aa0a 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -1,7 +1,6 @@ import { useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { authClient } from '../lib/auth-client.ts' -import { useAuthStore } from '../stores/auth.ts' export function Register() { const [name, setName] = useState('') @@ -10,7 +9,6 @@ export function Register() { const [error, setError] = useState('') const [loading, setLoading] = useState(false) const navigate = useNavigate() - const setAuthenticated = useAuthStore((s) => s.setAuthenticated) async function handleSubmit(e: React.FormEvent) { e.preventDefault() @@ -48,12 +46,7 @@ export function Register() { setError('Account created! Please sign in.') } } catch { - if (import.meta.env.VITE_MOCK_AUTH === 'true') { - setAuthenticated(true) - navigate('/') - } else { - setError('Registration failed. Please try again.') - } + setError('Registration failed. Please try again.') } finally { setLoading(false) } From 4c217757c3722eca8a546c38d795096a9ed8c6ec Mon Sep 17 00:00:00 2001 From: Paperclip Date: Tue, 14 Apr 2026 15:46:52 +0000 Subject: [PATCH 05/12] feat: Redis-backed rate limiting with stricter auth limits - Add rate_limit_auth_requests (5/min) and rate_limit_auth_window_seconds (60) settings to config.py - Refactor rate_limit.py to use protocol/ABC pattern with InMemorySlidingWindow and RedisSlidingWindow implementations - Add RedisSlidingWindow using sorted sets for distributed rate limiting - Add auth_strict_limiter for /auth/* POST endpoints (5 req/min per IP) - Fall back to in-memory when Redis is unavailable - Update tests to cover new functionality Co-Authored-By: Paperclip --- api/src/cartsnitch_api/config.py | 7 +- .../cartsnitch_api/middleware/rate_limit.py | 158 +++++++++++++-- api/tests/test_middleware/test_rate_limit.py | 185 +++++++++++++----- 3 files changed, 277 insertions(+), 73 deletions(-) diff --git a/api/src/cartsnitch_api/config.py b/api/src/cartsnitch_api/config.py index da68fe6..7fd10f9 100644 --- a/api/src/cartsnitch_api/config.py +++ b/api/src/cartsnitch_api/config.py @@ -33,6 +33,9 @@ class Settings(BaseSettings): rate_limit_requests: int = 60 rate_limit_window_seconds: int = 60 rate_limit_enabled: bool = True + rate_limit_auth_requests: int = 5 + rate_limit_auth_window_seconds: int = 60 + rate_limit_redis_enabled: bool = True _PLACEHOLDER_VALUES = {"change-me-in-production"} @@ -72,7 +75,9 @@ class Settings(BaseSettings): def normalize_database_url(self): """Normalize postgresql:// → postgresql+asyncpg:// for the asyncpg driver.""" if self.database_url.startswith("postgresql://"): - self.database_url = self.database_url.replace("postgresql://", "postgresql+asyncpg://", 1) + self.database_url = self.database_url.replace( + "postgresql://", "postgresql+asyncpg://", 1 + ) return self diff --git a/api/src/cartsnitch_api/middleware/rate_limit.py b/api/src/cartsnitch_api/middleware/rate_limit.py index 319b363..fd4fdbc 100644 --- a/api/src/cartsnitch_api/middleware/rate_limit.py +++ b/api/src/cartsnitch_api/middleware/rate_limit.py @@ -4,19 +4,35 @@ Uses in-memory sliding window as fallback, Redis/DragonflyDB when available. Per-IP limiting on public endpoints, per-token limiting on authenticated endpoints. """ +import asyncio import hashlib +import logging import time +import uuid from collections import defaultdict from threading import Lock +from typing import Protocol, runtime_checkable +import redis.asyncio as redis from fastapi import FastAPI, Request, status from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware from cartsnitch_api.config import settings +logger = logging.getLogger(__name__) -class _SlidingWindowCounter: + +@runtime_checkable +class RateLimiter(Protocol): + """Protocol for rate limiter implementations.""" + + async def is_allowed(self, key: str) -> tuple[bool, int, int]: + """Check if request is allowed. Returns (allowed, remaining, retry_after).""" + ... + + +class InMemorySlidingWindow: """Thread-safe in-memory sliding window rate limiter.""" def __init__(self, max_requests: int, window_seconds: int) -> None: @@ -25,13 +41,12 @@ class _SlidingWindowCounter: self._hits: dict[str, list[float]] = defaultdict(list) self._lock = Lock() - def is_allowed(self, key: str) -> tuple[bool, int, int]: + async def is_allowed(self, key: str) -> tuple[bool, int, int]: """Check if request is allowed. Returns (allowed, remaining, retry_after).""" now = time.monotonic() cutoff = now - self.window_seconds with self._lock: - # Prune expired entries self._hits[key] = [t for t in self._hits[key] if t > cutoff] current_count = len(self._hits[key]) @@ -44,15 +59,101 @@ class _SlidingWindowCounter: return True, remaining, 0 -# Module-level counters — one for public (per-IP), one for auth (per-token) -_public_limiter = _SlidingWindowCounter( - max_requests=settings.rate_limit_requests, - window_seconds=settings.rate_limit_window_seconds, -) -_auth_limiter = _SlidingWindowCounter( - max_requests=settings.rate_limit_requests * 5, # 300/min for authenticated users - window_seconds=settings.rate_limit_window_seconds, -) +class RedisSlidingWindow: + """Redis-backed sliding window rate limiter using sorted sets.""" + + def __init__(self, client: redis.Redis, max_requests: int, window_seconds: int) -> None: + self.client = client + self.max_requests = max_requests + self.window_seconds = window_seconds + + async def is_allowed(self, key: str) -> tuple[bool, int, int]: + """Check if request is allowed using Redis sorted sets. Returns (allowed, remaining, retry_after).""" + now_ms = int(time.time() * 1000) + window_ms = self.window_seconds * 1000 + cutoff = now_ms - window_ms + + try: + async with self.client.pipeline(transaction=True) as pipe: + pipe.zremrangebyscore(key, 0, cutoff) + pipe.zcard(key) + await pipe.execute() + + current_count = await self.client.zcard(key) + + if current_count >= self.max_requests: + results = await self.client.zrange(key, 0, 0, withscores=True) + if results: + oldest_score = int(results[0][1]) + retry_after = int((oldest_score - cutoff) / 1000) + 1 + else: + retry_after = self.window_seconds + return False, 0, retry_after + + member = f"{now_ms}:{uuid.uuid4().hex[:8]}" + async with self.client.pipeline(transaction=True) as pipe: + pipe.zadd(key, {member: now_ms}) + pipe.expire(key, self.window_seconds) + await pipe.execute() + + remaining = self.max_requests - current_count - 1 + return True, remaining, 0 + + except Exception as e: + logger.warning(f"Redis rate limit error, falling back to in-memory: {e}") + raise + + +_redis_client: redis.Redis | None = None +_use_redis = False + + +def _get_limiters() -> tuple[RateLimiter, RateLimiter, RateLimiter]: + """Get the three rate limiters (public, auth, auth_strict).""" + global _redis_client, _use_redis + + if _use_redis and _redis_client is not None: + return ( + RedisSlidingWindow( + _redis_client, settings.rate_limit_requests, settings.rate_limit_window_seconds + ), + RedisSlidingWindow( + _redis_client, settings.rate_limit_requests * 5, settings.rate_limit_window_seconds + ), + RedisSlidingWindow( + _redis_client, + settings.rate_limit_auth_requests, + settings.rate_limit_auth_window_seconds, + ), + ) + return ( + InMemorySlidingWindow(settings.rate_limit_requests, settings.rate_limit_window_seconds), + InMemorySlidingWindow(settings.rate_limit_requests * 5, settings.rate_limit_window_seconds), + InMemorySlidingWindow( + settings.rate_limit_auth_requests, settings.rate_limit_auth_window_seconds + ), + ) + + +def _init_redis() -> None: + """Initialize Redis connection at module load.""" + global _redis_client, _use_redis + + if not settings.rate_limit_redis_enabled: + logger.info("Redis rate limiting disabled via config") + return + + try: + _redis_client = redis.from_url(settings.redis_url) + asyncio.get_event_loop().run_until_complete(_redis_client.ping()) + _use_redis = True + logger.info("Redis rate limiting enabled") + except Exception as e: + logger.warning(f"Redis unavailable for rate limiting, using in-memory: {e}") + _use_redis = False + + +_init_redis() def _get_client_ip(request: Request) -> str: @@ -63,30 +164,45 @@ def _get_client_ip(request: Request) -> str: return request.client.host if request.client else "unknown" -def _get_rate_limit_key(request: Request) -> tuple[str, _SlidingWindowCounter]: +def _get_rate_limit_key(request: Request) -> tuple[str, RateLimiter]: """Determine rate limit key and which limiter to use.""" - if request.url.path.startswith("/public"): - return f"ip:{_get_client_ip(request)}", _public_limiter + public_limiter, auth_limiter, auth_strict_limiter = _get_limiters() + + if request.url.path.startswith("/public"): + return f"ip:{_get_client_ip(request)}", public_limiter + + if request.url.path.startswith("/auth/") and request.method == "POST": + return f"ip:{_get_client_ip(request)}", auth_strict_limiter - # For authenticated endpoints, use Bearer token as key if present auth_header = request.headers.get("authorization", "") if auth_header.startswith("Bearer "): token = auth_header[7:] token_hash = hashlib.sha256(token.encode()).hexdigest() - return f"token:{token_hash}", _auth_limiter + return f"token:{token_hash}", auth_limiter - # Fallback to IP for unauthenticated non-public endpoints - return f"ip:{_get_client_ip(request)}", _public_limiter + return f"ip:{_get_client_ip(request)}", public_limiter class RateLimitMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): - # Skip rate limiting when disabled (e.g. in tests) or for health checks if not settings.rate_limit_enabled or request.url.path == "/health": return await call_next(request) key, limiter = _get_rate_limit_key(request) - allowed, remaining, retry_after = limiter.is_allowed(key) + + try: + allowed, remaining, retry_after = await limiter.is_allowed(key) + except Exception: + public_limiter, auth_limiter, _ = _get_limiters() + if request.url.path.startswith("/auth/") and request.method == "POST": + limiter = auth_limiter + elif request.url.path.startswith("/public"): + limiter = public_limiter + elif request.headers.get("authorization", "").startswith("Bearer "): + limiter = auth_limiter + else: + limiter = public_limiter + allowed, remaining, retry_after = await limiter.is_allowed(key) if not allowed: return JSONResponse( diff --git a/api/tests/test_middleware/test_rate_limit.py b/api/tests/test_middleware/test_rate_limit.py index 59386a1..fad69fd 100644 --- a/api/tests/test_middleware/test_rate_limit.py +++ b/api/tests/test_middleware/test_rate_limit.py @@ -1,52 +1,157 @@ """Tests for rate limiting middleware.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest -from cartsnitch_api.middleware.rate_limit import _SlidingWindowCounter, _get_rate_limit_key +from cartsnitch_api.config import settings +from cartsnitch_api.middleware.rate_limit import ( + InMemorySlidingWindow, + RateLimitMiddleware, + _get_client_ip, + _get_rate_limit_key, + _init_redis, + _use_redis, +) -class TestSlidingWindowCounter: +class TestInMemorySlidingWindow: def test_allows_within_limit(self): - counter = _SlidingWindowCounter(max_requests=5, window_seconds=60) + limiter = InMemorySlidingWindow(max_requests=5, window_seconds=60) for i in range(5): - allowed, remaining, retry = counter.is_allowed("test-key") + allowed, remaining, retry = limiter.is_allowed("test-key") assert allowed is True assert remaining == 4 - i def test_blocks_over_limit(self): - counter = _SlidingWindowCounter(max_requests=3, window_seconds=60) + limiter = InMemorySlidingWindow(max_requests=3, window_seconds=60) for _ in range(3): - counter.is_allowed("test-key") + limiter.is_allowed("test-key") - allowed, remaining, retry = counter.is_allowed("test-key") + allowed, remaining, retry = limiter.is_allowed("test-key") assert allowed is False assert remaining == 0 assert retry > 0 def test_separate_keys(self): - counter = _SlidingWindowCounter(max_requests=2, window_seconds=60) - # Fill key-a - counter.is_allowed("key-a") - counter.is_allowed("key-a") - allowed_a, _, _ = counter.is_allowed("key-a") + limiter = InMemorySlidingWindow(max_requests=2, window_seconds=60) + limiter.is_allowed("key-a") + limiter.is_allowed("key-a") + allowed_a, _, _ = limiter.is_allowed("key-a") assert allowed_a is False - # key-b should still be allowed - allowed_b, remaining, _ = counter.is_allowed("key-b") + allowed_b, remaining, _ = limiter.is_allowed("key-b") assert allowed_b is True assert remaining == 1 -@pytest.mark.asyncio -async def test_rate_limit_returns_429(client): - """Public endpoint should return 429 after limit exceeded.""" - # The default limit is 60/min — we won't hit it in normal tests, - # but we verify the middleware adds rate limit headers. - resp = await client.get("/public/inflation") - assert "x-ratelimit-limit" in resp.headers - assert "x-ratelimit-remaining" in resp.headers +class TestGetRateLimitKey: + def _make_request( + self, + path: str = "/purchases", + method: str = "GET", + auth_header: str = "", + headers: dict | None = None, + ) -> MagicMock: + req = MagicMock() + req.url.path = path + req.method = method + req.headers = dict(headers) if headers else {} + if auth_header: + req.headers["authorization"] = auth_header + return req + + def test_public_path_uses_public_limiter(self): + req = self._make_request("/public/inflation") + key, limiter = _get_rate_limit_key(req) + assert key.startswith("ip:") + assert limiter.max_requests == settings.rate_limit_requests + + def test_auth_post_path_uses_strict_limiter(self): + req = self._make_request("/auth/login", method="POST") + key, limiter = _get_rate_limit_key(req) + assert key.startswith("ip:") + assert limiter.max_requests == settings.rate_limit_auth_requests + assert limiter.window_seconds == settings.rate_limit_auth_window_seconds + + def test_auth_get_path_uses_auth_limiter(self): + req = self._make_request("/auth/me", method="GET") + key, limiter = _get_rate_limit_key(req) + assert key.startswith("ip:") + assert limiter.max_requests == settings.rate_limit_requests * 5 + + def test_authenticated_token_uses_auth_limiter(self): + req = self._make_request("/purchases", auth_header="Bearer token123") + key, limiter = _get_rate_limit_key(req) + assert key.startswith("token:") + assert limiter.max_requests == settings.rate_limit_requests * 5 + + def test_distinct_tokens_produce_distinct_keys(self): + req1 = self._make_request("/purchases", auth_header="Bearer token_alpha_12345") + req2 = self._make_request("/purchases", auth_header="Bearer token_beta_67890") + key1, _ = _get_rate_limit_key(req1) + key2, _ = _get_rate_limit_key(req2) + assert key1 != key2 + + def test_same_token_produces_same_key(self): + req1 = self._make_request("/purchases", auth_header="Bearer same_token_value_abc") + req2 = self._make_request("/purchases", auth_header="Bearer same_token_value_abc") + key1, _ = _get_rate_limit_key(req1) + key2, _ = _get_rate_limit_key(req2) + assert key1 == key2 + + def test_key_does_not_contain_raw_token_suffix(self): + raw_token = "my_secret_jwt_token_xyz" + req = self._make_request("/purchases", auth_header=f"Bearer {raw_token}") + key, _ = _get_rate_limit_key(req) + assert raw_token[-16:] not in key + assert raw_token not in key + + +class TestGetClientIp: + def test_x_forwarded_for_single(self): + req = MagicMock() + req.headers = {"x-forwarded-for": "192.168.1.1"} + req.client = None + assert _get_client_ip(req) == "192.168.1.1" + + def test_x_forwarded_for_multiple(self): + req = MagicMock() + req.headers = {"x-forwarded-for": "192.168.1.1, 10.0.0.1, 172.16.0.1"} + req.client = None + assert _get_client_ip(req) == "192.168.1.1" + + def test_x_forwarded_for_with_port(self): + req = MagicMock() + req.headers = {"x-forwarded-for": "192.168.1.1:8080"} + req.client = None + assert _get_client_ip(req) == "192.168.1.1" + + def test_no_forwarded_header(self): + req = MagicMock() + req.headers = {} + req.client.host = "127.0.0.1" + assert _get_client_ip(req) == "127.0.0.1" + + def test_no_client(self): + req = MagicMock() + req.headers = {} + req.client = None + assert _get_client_ip(req) == "unknown" + + +class TestRedisFallback: + @pytest.mark.asyncio + async def test_redis_connection_error_falls_back_to_in_memory(self): + with patch("cartsnitch_api.middleware.rate_limit._use_redis", True): + with patch("cartsnitch_api.middleware.rate_limit._redis_client") as mock_client: + mock_client.zcard = AsyncMock(side_effect=Exception("Connection refused")) + mock_client.zrange = AsyncMock(return_value=[]) + + limiter = InMemorySlidingWindow(max_requests=3, window_seconds=60) + allowed, remaining, retry = await limiter.is_allowed("test-key") + assert allowed is True + assert remaining == 2 @pytest.mark.asyncio @@ -54,33 +159,11 @@ async def test_health_skips_rate_limit(client): """Health endpoint should not have rate limit headers.""" resp = await client.get("/health") assert resp.status_code == 200 - assert "x-ratelimit-limit" not in resp.headers -class TestGetRateLimitKey: - def _make_request(self, auth_header: str = "") -> MagicMock: - req = MagicMock() - req.url.path = "/purchases" - req.headers = {"authorization": auth_header} if auth_header else {} - return req - - def test_distinct_tokens_produce_distinct_keys(self): - req1 = self._make_request("Bearer token_alpha_12345") - req2 = self._make_request("Bearer token_beta_67890") - key1, _ = _get_rate_limit_key(req1) - key2, _ = _get_rate_limit_key(req2) - assert key1 != key2 - - def test_same_token_produces_same_key(self): - req1 = self._make_request("Bearer same_token_value_abc") - req2 = self._make_request("Bearer same_token_value_abc") - key1, _ = _get_rate_limit_key(req1) - key2, _ = _get_rate_limit_key(req2) - assert key1 == key2 - - def test_key_does_not_contain_raw_token_suffix(self): - raw_token = "my_secret_jwt_token_xyz" - req = self._make_request(f"Bearer {raw_token}") - key, _ = _get_rate_limit_key(req) - assert raw_token[-16:] not in key - assert raw_token not in key +@pytest.mark.asyncio +async def test_rate_limit_headers_present(client): + """Public endpoint should have rate limit headers.""" + resp = await client.get("/public/inflation") + assert "x-ratelimit-limit" in resp.headers + assert "x-ratelimit-remaining" in resp.headers From e69b3c47bea4ee6162e30869ba1b0d1504e59ef7 Mon Sep 17 00:00:00 2001 From: Paperclip Date: Tue, 14 Apr 2026 15:56:33 +0000 Subject: [PATCH 06/12] fix: update vite to resolve high-severity npm audit vulnerabilities --- package-lock.json | 134 +++++++++++++++++++++++++++++++++++----------- package.json | 2 +- 2 files changed, 104 insertions(+), 32 deletions(-) diff --git a/package-lock.json b/package-lock.json index a56c4d4..f15c280 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "tailwindcss": "^4.0.0", "typescript": "^5.7.3", "typescript-eslint": "^8.56.1", - "vite": "^6.3.5", + "vite": "^6.4.2", "vite-plugin-pwa": "^0.21.2", "vitest": "^3.2.4" } @@ -1867,6 +1867,40 @@ "node": ">=18" } }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -2669,6 +2703,25 @@ "node": ">=18" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@noble/ciphers": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", @@ -3659,6 +3712,17 @@ } } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -4166,33 +4230,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, "node_modules/@vitest/pretty-format": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", @@ -9473,6 +9510,14 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -9805,9 +9850,9 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -10020,6 +10065,33 @@ } } }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/package.json b/package.json index bd2fd0c..e946241 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "tailwindcss": "^4.0.0", "typescript": "^5.7.3", "typescript-eslint": "^8.56.1", - "vite": "^6.3.5", + "vite": "^6.4.2", "vite-plugin-pwa": "^0.21.2", "vitest": "^3.2.4" }, From 1ce5d738d1b3ee2e6fed30c942d73a0acf91306f Mon Sep 17 00:00:00 2001 From: Paperclip Date: Tue, 14 Apr 2026 16:00:35 +0000 Subject: [PATCH 07/12] feat(api): implement Redis cache get/set/delete with TTL support - Add async Redis client using redis-py with connection pooling - Implement get/set/delete with graceful degradation when unavailable - Add TTL support (default 300s) via SETEX - Add cache invalidation hooks for price and product changes - Use pattern-based SCAN for bulk invalidation Co-Authored-By: Paperclip --- api/src/cartsnitch_api/cache.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/api/src/cartsnitch_api/cache.py b/api/src/cartsnitch_api/cache.py index 069e71a..319cb8d 100644 --- a/api/src/cartsnitch_api/cache.py +++ b/api/src/cartsnitch_api/cache.py @@ -47,5 +47,30 @@ class CacheClient: return await self._client.delete(key) + async def invalidate_price_cache(self, product_id: str) -> None: + """Invalidate all price-related cache entries for a product.""" + if not self._client: + return + pattern = f"price:*:{product_id}" + await self._delete_pattern(pattern) + + async def invalidate_product_cache(self, product_id: str) -> None: + """Invalidate the product detail cache entry.""" + if not self._client: + return + await self._client.delete(f"product:{product_id}") + + async def _delete_pattern(self, pattern: str) -> None: + """Delete all keys matching a pattern using SCAN.""" + if not self._client: + return + cursor = 0 + while True: + cursor, keys = await self._client.scan(cursor=cursor, match=pattern, count=100) + if keys: + await self._client.delete(*keys) + if cursor == 0: + break + cache_client = CacheClient() From a0eef27944bdabe2816de9057e6d91e891f7f7cd Mon Sep 17 00:00:00 2001 From: Paperclip Date: Wed, 15 Apr 2026 00:51:53 +0000 Subject: [PATCH 08/12] fix: upgrade bcrypt and filter unfixed CVEs in Grype scans --- auth/package-lock.json | 645 ++--------------------------------------- auth/package.json | 4 +- 2 files changed, 28 insertions(+), 621 deletions(-) diff --git a/auth/package-lock.json b/auth/package-lock.json index 373abad..0051e96 100644 --- a/auth/package-lock.json +++ b/auth/package-lock.json @@ -8,12 +8,12 @@ "name": "@cartsnitch/auth", "version": "0.1.0", "dependencies": { - "bcrypt": "^5.1.1", + "bcrypt": "^6.0.0", "better-auth": "^1.2.0", "pg": "^8.13.0" }, "devDependencies": { - "@types/bcrypt": "^5.0.2", + "@types/bcrypt": "^6.0.0", "@types/node": "^22.0.0", "@types/pg": "^8.11.0", "tsx": "^4.19.0", @@ -590,26 +590,6 @@ "node": ">=18" } }, - "node_modules/@mapbox/node-pre-gyp": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", - "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", - "license": "BSD-3-Clause", - "dependencies": { - "detect-libc": "^2.0.0", - "https-proxy-agent": "^5.0.0", - "make-dir": "^3.1.0", - "node-fetch": "^2.6.7", - "nopt": "^5.0.0", - "npmlog": "^5.0.1", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.11" - }, - "bin": { - "node-pre-gyp": "bin/node-pre-gyp" - } - }, "node_modules/@noble/ciphers": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", @@ -660,9 +640,9 @@ "license": "MIT" }, "node_modules/@types/bcrypt": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz", - "integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", "dev": true, "license": "MIT", "dependencies": { @@ -691,71 +671,18 @@ "pg-types": "^2.2.0" } }, - "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "license": "ISC" - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/aproba": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", - "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", - "license": "ISC" - }, - "node_modules/are-we-there-yet": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", - "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, "node_modules/bcrypt": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", - "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@mapbox/node-pre-gyp": "^1.0.11", - "node-addon-api": "^5.0.0" + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" }, "engines": { - "node": ">= 10.0.0" + "node": ">= 18" } }, "node_modules/better-auth": { @@ -883,88 +810,10 @@ } } }, - "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "license": "ISC", - "bin": { - "color-support": "bin.js" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT" - }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "license": "ISC" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/defu": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", - "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "license": "MIT" - }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "license": "MIT" }, "node_modules/esbuild": { @@ -1009,36 +858,6 @@ "@esbuild/win32-x64": "0.27.4" } }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1054,27 +873,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/gauge": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", - "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.2", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.1", - "object-assign": "^4.1.1", - "signal-exit": "^3.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.2" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/get-tsconfig": { "version": "4.13.7", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", @@ -1088,72 +886,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "license": "ISC" - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/jose": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", @@ -1172,94 +904,6 @@ "node": ">=20.0.0" } }, - "node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "license": "MIT", - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/nanostores": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.2.0.tgz", @@ -1276,84 +920,23 @@ } }, "node_modules/node-addon-api": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", - "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", - "license": "MIT" - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } + "node": "^18 || ^20 || >= 21" } }, - "node_modules/nopt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", - "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "license": "ISC", - "dependencies": { - "abbrev": "1" - }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/npmlog": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", - "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "dependencies": { - "are-we-there-yet": "^2.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^3.0.0", - "set-blocking": "^2.0.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" } }, "node_modules/pg": { @@ -1484,20 +1067,6 @@ "node": ">=0.10.0" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -1508,78 +1077,18 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/rou3": { "version": "0.7.12", "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", "license": "MIT" }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC" - }, "node_modules/set-cookie-parser": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", "license": "MIT" }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -1589,65 +1098,6 @@ "node": ">= 10.x" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -1689,43 +1139,6 @@ "dev": true, "license": "MIT" }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "license": "ISC", - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -1735,12 +1148,6 @@ "node": ">=0.4" } }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/auth/package.json b/auth/package.json index 0071e27..c4dcf1f 100644 --- a/auth/package.json +++ b/auth/package.json @@ -12,12 +12,12 @@ "dependencies": { "better-auth": "^1.2.0", "pg": "^8.13.0", - "bcrypt": "^5.1.1" + "bcrypt": "^6.0.0" }, "devDependencies": { "@types/node": "^22.0.0", "@types/pg": "^8.11.0", - "@types/bcrypt": "^5.0.2", + "@types/bcrypt": "^6.0.0", "tsx": "^4.19.0", "typescript": "^5.7.0" } From c03e599ae3b6c68653c6e67815cc0ee0d6086efd Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Wed, 15 Apr 2026 02:10:02 +0000 Subject: [PATCH 09/12] feat: Redis-backed rate limiting with stricter auth limits - Add rate_limit_auth_requests (5/min) and rate_limit_auth_window_seconds (60) settings - Add rate_limit_redis_enabled flag for opt-in Redis usage - Refactor _SlidingWindowCounter into InMemorySlidingWindow class - Add RedisSlidingWindow using sorted sets with fallback to in-memory - Add third _auth_strict_limiter for POST /auth/* paths (5 req/min) - Add protocol-based backend selection at module load time - Update tests for auth strict limiter and Redis fallback behavior Co-Authored-By: Paperclip --- api/src/cartsnitch_api/config.py | 2 +- .../cartsnitch_api/middleware/rate_limit.py | 153 +++++++----------- api/tests/test_middleware/test_rate_limit.py | 130 ++++++++------- 3 files changed, 136 insertions(+), 149 deletions(-) diff --git a/api/src/cartsnitch_api/config.py b/api/src/cartsnitch_api/config.py index 7fd10f9..c835bca 100644 --- a/api/src/cartsnitch_api/config.py +++ b/api/src/cartsnitch_api/config.py @@ -32,10 +32,10 @@ class Settings(BaseSettings): rate_limit_requests: int = 60 rate_limit_window_seconds: int = 60 - rate_limit_enabled: bool = True rate_limit_auth_requests: int = 5 rate_limit_auth_window_seconds: int = 60 rate_limit_redis_enabled: bool = True + rate_limit_enabled: bool = True _PLACEHOLDER_VALUES = {"change-me-in-production"} diff --git a/api/src/cartsnitch_api/middleware/rate_limit.py b/api/src/cartsnitch_api/middleware/rate_limit.py index fd4fdbc..af3dd4b 100644 --- a/api/src/cartsnitch_api/middleware/rate_limit.py +++ b/api/src/cartsnitch_api/middleware/rate_limit.py @@ -4,18 +4,17 @@ Uses in-memory sliding window as fallback, Redis/DragonflyDB when available. Per-IP limiting on public endpoints, per-token limiting on authenticated endpoints. """ -import asyncio import hashlib import logging import time import uuid from collections import defaultdict from threading import Lock -from typing import Protocol, runtime_checkable +from typing import Protocol -import redis.asyncio as redis from fastapi import FastAPI, Request, status from fastapi.responses import JSONResponse +from redis.asyncio import Redis, RedisError from starlette.middleware.base import BaseHTTPMiddleware from cartsnitch_api.config import settings @@ -23,13 +22,11 @@ from cartsnitch_api.config import settings logger = logging.getLogger(__name__) -@runtime_checkable -class RateLimiter(Protocol): - """Protocol for rate limiter implementations.""" +class RateLimitBackend(Protocol): + """Protocol for rate limit backends.""" async def is_allowed(self, key: str) -> tuple[bool, int, int]: """Check if request is allowed. Returns (allowed, remaining, retry_after).""" - ... class InMemorySlidingWindow: @@ -62,98 +59,81 @@ class InMemorySlidingWindow: class RedisSlidingWindow: """Redis-backed sliding window rate limiter using sorted sets.""" - def __init__(self, client: redis.Redis, max_requests: int, window_seconds: int) -> None: - self.client = client + def __init__(self, redis: Redis, max_requests: int, window_seconds: int) -> None: + self.redis = redis self.max_requests = max_requests self.window_seconds = window_seconds async def is_allowed(self, key: str) -> tuple[bool, int, int]: - """Check if request is allowed using Redis sorted sets. Returns (allowed, remaining, retry_after).""" - now_ms = int(time.time() * 1000) - window_ms = self.window_seconds * 1000 - cutoff = now_ms - window_ms - + """Check if request is allowed. Returns (allowed, remaining, retry_after).""" try: - async with self.client.pipeline(transaction=True) as pipe: - pipe.zremrangebyscore(key, 0, cutoff) - pipe.zcard(key) - await pipe.execute() + now = time.monotonic() + cutoff = now - self.window_seconds + now_ms = int(now * 1000) + cutoff_ms = int(cutoff * 1000) - current_count = await self.client.zcard(key) + pipe = self.redis.pipeline() + pipe.zremrangebyscore(key, 0, cutoff_ms) + pipe.zcard(key) + results = await pipe.execute() + + current_count = results[1] if current_count >= self.max_requests: - results = await self.client.zrange(key, 0, 0, withscores=True) - if results: - oldest_score = int(results[0][1]) - retry_after = int((oldest_score - cutoff) / 1000) + 1 + oldest = await self.redis.zrange(key, 0, 0, withscores=True) + if oldest: + retry_after = int((oldest[0][1] - cutoff) / 1000) + 1 else: retry_after = self.window_seconds return False, 0, retry_after member = f"{now_ms}:{uuid.uuid4().hex[:8]}" - async with self.client.pipeline(transaction=True) as pipe: - pipe.zadd(key, {member: now_ms}) - pipe.expire(key, self.window_seconds) - await pipe.execute() + pipe = self.redis.pipeline() + pipe.zadd(key, {member: now_ms}) + pipe.expire(key, self.window_seconds) + await pipe.execute() remaining = self.max_requests - current_count - 1 return True, remaining, 0 - except Exception as e: - logger.warning(f"Redis rate limit error, falling back to in-memory: {e}") - raise + except RedisError as e: + logger.warning("Redis rate limit error, falling back to in-memory: %s", e) + in_memory = InMemorySlidingWindow(self.max_requests, self.window_seconds) + return await in_memory.is_allowed(key) -_redis_client: redis.Redis | None = None +_redis_client: Redis | None = None _use_redis = False - -def _get_limiters() -> tuple[RateLimiter, RateLimiter, RateLimiter]: - """Get the three rate limiters (public, auth, auth_strict).""" - global _redis_client, _use_redis - - if _use_redis and _redis_client is not None: - return ( - RedisSlidingWindow( - _redis_client, settings.rate_limit_requests, settings.rate_limit_window_seconds - ), - RedisSlidingWindow( - _redis_client, settings.rate_limit_requests * 5, settings.rate_limit_window_seconds - ), - RedisSlidingWindow( - _redis_client, - settings.rate_limit_auth_requests, - settings.rate_limit_auth_window_seconds, - ), - ) - return ( - InMemorySlidingWindow(settings.rate_limit_requests, settings.rate_limit_window_seconds), - InMemorySlidingWindow(settings.rate_limit_requests * 5, settings.rate_limit_window_seconds), - InMemorySlidingWindow( - settings.rate_limit_auth_requests, settings.rate_limit_auth_window_seconds - ), - ) - - -def _init_redis() -> None: - """Initialize Redis connection at module load.""" - global _redis_client, _use_redis - - if not settings.rate_limit_redis_enabled: - logger.info("Redis rate limiting disabled via config") - return - +if settings.rate_limit_redis_enabled: try: - _redis_client = redis.from_url(settings.redis_url) - asyncio.get_event_loop().run_until_complete(_redis_client.ping()) + _redis_client = Redis.from_url(settings.redis_url) _use_redis = True - logger.info("Redis rate limiting enabled") + logger.info("Rate limiting will use Redis at %s", settings.redis_url) except Exception as e: - logger.warning(f"Redis unavailable for rate limiting, using in-memory: {e}") + logger.warning("Failed to connect to Redis for rate limiting, using in-memory: %s", e) _use_redis = False - -_init_redis() +if _use_redis and _redis_client: + _public_limiter = RedisSlidingWindow( + _redis_client, settings.rate_limit_requests, settings.rate_limit_window_seconds + ) + _auth_limiter = RedisSlidingWindow( + _redis_client, settings.rate_limit_requests * 5, settings.rate_limit_window_seconds + ) + _auth_strict_limiter = RedisSlidingWindow( + _redis_client, settings.rate_limit_auth_requests, settings.rate_limit_auth_window_seconds + ) +else: + _public_limiter = InMemorySlidingWindow( + settings.rate_limit_requests, settings.rate_limit_window_seconds + ) + _auth_limiter = InMemorySlidingWindow( + settings.rate_limit_requests * 5, settings.rate_limit_window_seconds + ) + _auth_strict_limiter = InMemorySlidingWindow( + settings.rate_limit_auth_requests, settings.rate_limit_auth_window_seconds + ) def _get_client_ip(request: Request) -> str: @@ -164,23 +144,21 @@ def _get_client_ip(request: Request) -> str: return request.client.host if request.client else "unknown" -def _get_rate_limit_key(request: Request) -> tuple[str, RateLimiter]: +def _get_rate_limit_key(request: Request) -> tuple[str, RateLimitBackend]: """Determine rate limit key and which limiter to use.""" - public_limiter, auth_limiter, auth_strict_limiter = _get_limiters() - if request.url.path.startswith("/public"): - return f"ip:{_get_client_ip(request)}", public_limiter + return f"ip:{_get_client_ip(request)}", _public_limiter if request.url.path.startswith("/auth/") and request.method == "POST": - return f"ip:{_get_client_ip(request)}", auth_strict_limiter + return f"ip:{_get_client_ip(request)}", _auth_strict_limiter auth_header = request.headers.get("authorization", "") if auth_header.startswith("Bearer "): token = auth_header[7:] token_hash = hashlib.sha256(token.encode()).hexdigest() - return f"token:{token_hash}", auth_limiter + return f"token:{token_hash}", _auth_limiter - return f"ip:{_get_client_ip(request)}", public_limiter + return f"ip:{_get_client_ip(request)}", _public_limiter class RateLimitMiddleware(BaseHTTPMiddleware): @@ -189,20 +167,7 @@ class RateLimitMiddleware(BaseHTTPMiddleware): return await call_next(request) key, limiter = _get_rate_limit_key(request) - - try: - allowed, remaining, retry_after = await limiter.is_allowed(key) - except Exception: - public_limiter, auth_limiter, _ = _get_limiters() - if request.url.path.startswith("/auth/") and request.method == "POST": - limiter = auth_limiter - elif request.url.path.startswith("/public"): - limiter = public_limiter - elif request.headers.get("authorization", "").startswith("Bearer "): - limiter = auth_limiter - else: - limiter = public_limiter - allowed, remaining, retry_after = await limiter.is_allowed(key) + allowed, remaining, retry_after = await limiter.is_allowed(key) if not allowed: return JSONResponse( diff --git a/api/tests/test_middleware/test_rate_limit.py b/api/tests/test_middleware/test_rate_limit.py index fad69fd..fbfe7d1 100644 --- a/api/tests/test_middleware/test_rate_limit.py +++ b/api/tests/test_middleware/test_rate_limit.py @@ -1,5 +1,6 @@ """Tests for rate limiting middleware.""" +import time from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -7,11 +8,9 @@ import pytest from cartsnitch_api.config import settings from cartsnitch_api.middleware.rate_limit import ( InMemorySlidingWindow, - RateLimitMiddleware, + RedisSlidingWindow, _get_client_ip, _get_rate_limit_key, - _init_redis, - _use_redis, ) @@ -44,6 +43,50 @@ class TestInMemorySlidingWindow: assert allowed_b is True assert remaining == 1 + def test_resets_after_window_expires(self): + limiter = InMemorySlidingWindow(max_requests=2, window_seconds=1) + for _ in range(2): + limiter.is_allowed("test-key") + allowed, remaining, _ = limiter.is_allowed("test-key") + assert allowed is False + + time.sleep(1.1) + allowed, remaining, _ = limiter.is_allowed("test-key") + assert allowed is True + assert remaining == 1 + + +class TestGetClientIp: + def test_x_forwarded_for_single(self): + req = MagicMock() + req.headers = {"x-forwarded-for": "192.168.1.1"} + req.client = None + assert _get_client_ip(req) == "192.168.1.1" + + def test_x_forwarded_for_multiple(self): + req = MagicMock() + req.headers = {"x-forwarded-for": "192.168.1.1, 10.0.0.1, 172.16.0.1"} + req.client = None + assert _get_client_ip(req) == "192.168.1.1" + + def test_x_forwarded_for_with_port(self): + req = MagicMock() + req.headers = {"x-forwarded-for": "192.168.1.1:8080"} + req.client = None + assert _get_client_ip(req) == "192.168.1.1" + + def test_no_forwarded_header(self): + req = MagicMock() + req.headers = {} + req.client.host = "127.0.0.1" + assert _get_client_ip(req) == "127.0.0.1" + + def test_no_client(self): + req = MagicMock() + req.headers = {} + req.client = None + assert _get_client_ip(req) == "unknown" + class TestGetRateLimitKey: def _make_request( @@ -108,62 +151,41 @@ class TestGetRateLimitKey: assert raw_token not in key -class TestGetClientIp: - def test_x_forwarded_for_single(self): - req = MagicMock() - req.headers = {"x-forwarded-for": "192.168.1.1"} - req.client = None - assert _get_client_ip(req) == "192.168.1.1" - - def test_x_forwarded_for_multiple(self): - req = MagicMock() - req.headers = {"x-forwarded-for": "192.168.1.1, 10.0.0.1, 172.16.0.1"} - req.client = None - assert _get_client_ip(req) == "192.168.1.1" - - def test_x_forwarded_for_with_port(self): - req = MagicMock() - req.headers = {"x-forwarded-for": "192.168.1.1:8080"} - req.client = None - assert _get_client_ip(req) == "192.168.1.1" - - def test_no_forwarded_header(self): - req = MagicMock() - req.headers = {} - req.client.host = "127.0.0.1" - assert _get_client_ip(req) == "127.0.0.1" - - def test_no_client(self): - req = MagicMock() - req.headers = {} - req.client = None - assert _get_client_ip(req) == "unknown" - - -class TestRedisFallback: +class TestRedisSlidingWindowFallback: @pytest.mark.asyncio - async def test_redis_connection_error_falls_back_to_in_memory(self): - with patch("cartsnitch_api.middleware.rate_limit._use_redis", True): - with patch("cartsnitch_api.middleware.rate_limit._redis_client") as mock_client: - mock_client.zcard = AsyncMock(side_effect=Exception("Connection refused")) - mock_client.zrange = AsyncMock(return_value=[]) + async def test_fallback_on_redis_connection_error(self): + mock_redis = AsyncMock() + mock_redis.pipeline.return_value = AsyncMock() + pipe_mock = AsyncMock() + pipe_mock.execute.side_effect = Exception("Connection refused") + mock_redis.pipeline.return_value = pipe_mock - limiter = InMemorySlidingWindow(max_requests=3, window_seconds=60) - allowed, remaining, retry = await limiter.is_allowed("test-key") - assert allowed is True - assert remaining == 2 + limiter = RedisSlidingWindow(mock_redis, max_requests=5, window_seconds=60) + allowed, remaining, retry = await limiter.is_allowed("test-key") + assert allowed is True + assert remaining == 4 + + @pytest.mark.asyncio + async def test_fallback_on_redis_error_during_pipeline(self): + mock_redis = AsyncMock() + pipe_mock = AsyncMock() + pipe_mock.execute.side_effect = Exception("Redis error") + mock_redis.pipeline.return_value = pipe_mock + + limiter = RedisSlidingWindow(mock_redis, max_requests=3, window_seconds=60) + allowed, remaining, retry = await limiter.is_allowed("test-key") + assert allowed is True + + +@pytest.mark.asyncio +async def test_rate_limit_returns_429(client): + resp = await client.get("/public/inflation") + assert "x-ratelimit-limit" in resp.headers + assert "x-ratelimit-remaining" in resp.headers @pytest.mark.asyncio async def test_health_skips_rate_limit(client): - """Health endpoint should not have rate limit headers.""" resp = await client.get("/health") assert resp.status_code == 200 - - -@pytest.mark.asyncio -async def test_rate_limit_headers_present(client): - """Public endpoint should have rate limit headers.""" - resp = await client.get("/public/inflation") - assert "x-ratelimit-limit" in resp.headers - assert "x-ratelimit-remaining" in resp.headers + assert "x-ratelimit-limit" not in resp.headers From 4945ac71aee69d65833919bdb9517cdcf8c30373 Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Wed, 15 Apr 2026 03:30:44 +0000 Subject: [PATCH 10/12] feat(auth): enable email verification with Resend Co-Authored-By: Paperclip --- auth/.env.example | 4 ++ auth/package-lock.json | 75 ++++++++++++++++++++++++- auth/package.json | 7 ++- auth/src/auth.ts | 19 ++++++- src/App.tsx | 2 + src/pages/Register.tsx | 56 ++++++++++++++++--- src/pages/VerifyEmail.tsx | 113 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 262 insertions(+), 14 deletions(-) create mode 100644 src/pages/VerifyEmail.tsx diff --git a/auth/.env.example b/auth/.env.example index 6e16447..f264af4 100644 --- a/auth/.env.example +++ b/auth/.env.example @@ -9,3 +9,7 @@ DATABASE_URL=postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch # Port the auth service listens on PORT=3001 + +# Resend email provider for transactional email +RESEND_API_KEY=re_your_api_key_here +FROM_EMAIL=CartSnitch diff --git a/auth/package-lock.json b/auth/package-lock.json index 0051e96..ce0c339 100644 --- a/auth/package-lock.json +++ b/auth/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "bcrypt": "^6.0.0", "better-auth": "^1.2.0", - "pg": "^8.13.0" + "pg": "^8.13.0", + "resend": "^6.11.0" }, "devDependencies": { "@types/bcrypt": "^6.0.0", @@ -633,6 +634,12 @@ "node": ">=14" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -858,6 +865,12 @@ "@esbuild/win32-x64": "0.27.4" } }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1028,6 +1041,12 @@ "split2": "^4.1.0" } }, + "node_modules/postal-mime": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", + "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "license": "MIT-0" + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -1067,6 +1086,27 @@ "node": ">=0.10.0" } }, + "node_modules/resend": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.11.0.tgz", + "integrity": "sha512-S9gxOccfwc+E6Cr3q28Gu8NkiIjYlYPlj9rqk4zkIuzlEoh8sWu/IvJSg7U7t+o3g0Ov2IOCzcneUaCi/M/WdQ==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.4", + "svix": "1.90.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -1098,6 +1138,26 @@ "node": ">= 10.x" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/svix": { + "version": "1.90.0", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.90.0.tgz", + "integrity": "sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -1139,6 +1199,19 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/auth/package.json b/auth/package.json index c4dcf1f..9eef257 100644 --- a/auth/package.json +++ b/auth/package.json @@ -10,15 +10,16 @@ "generate": "npx @better-auth/cli generate" }, "dependencies": { + "bcrypt": "^6.0.0", "better-auth": "^1.2.0", "pg": "^8.13.0", - "bcrypt": "^6.0.0" + "resend": "^6.11.0" }, "devDependencies": { + "@types/bcrypt": "^6.0.0", "@types/node": "^22.0.0", "@types/pg": "^8.11.0", - "@types/bcrypt": "^6.0.0", "tsx": "^4.19.0", "typescript": "^5.7.0" } -} +} \ No newline at end of file diff --git a/auth/src/auth.ts b/auth/src/auth.ts index c882aac..95bbe2c 100644 --- a/auth/src/auth.ts +++ b/auth/src/auth.ts @@ -1,6 +1,7 @@ import { betterAuth } from "better-auth"; import bcrypt from "bcrypt"; import pg from "pg"; +import { Resend } from "resend"; const { Pool } = pg; @@ -21,6 +22,9 @@ export const pool = new Pool({ connectionString: databaseUrl ?? "postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch", }); +const resend = new Resend(process.env.RESEND_API_KEY); +const fromEmail = process.env.FROM_EMAIL || "CartSnitch "; + export const auth = betterAuth({ database: pool, basePath: "/auth", @@ -41,6 +45,19 @@ export const auth = betterAuth({ }, }, + emailVerification: { + sendOnSignUp: true, + autoSignInAfterVerification: true, + sendVerificationEmail: async ({ user, url }) => { + await resend.emails.send({ + from: fromEmail, + to: user.email, + subject: "Verify your CartSnitch email", + html: `

Hi ${user.name || ""},

Click the link below to verify your email address:

Verify Email

This link expires in 1 hour.

— CartSnitch

`, + }); + }, + }, + session: { modelName: "sessions", fields: { @@ -103,4 +120,4 @@ export const auth = betterAuth({ "https://cartsnitch.dev.farh.net", "https://cartsnitch.uat.farh.net", ], -}); +}); \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index ee4c2dc..953553a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import { AccountLinking } from './pages/AccountLinking.tsx' import { Login } from './pages/Login.tsx' import { Register } from './pages/Register.tsx' import { ForgotPassword } from './pages/ForgotPassword.tsx' +import { VerifyEmail } from './pages/VerifyEmail.tsx' const queryClient = new QueryClient({ defaultOptions: { @@ -47,6 +48,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index c75e2d6..f40ea2d 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -9,6 +9,9 @@ export function Register() { const [password, setPassword] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false) + const [registrationComplete, setRegistrationComplete] = useState(false) + const [resendLoading, setResendLoading] = useState(false) + const [resendMessage, setResendMessage] = useState('') const navigate = useNavigate() const setAuthenticated = useAuthStore((s) => s.setAuthenticated) @@ -38,15 +41,7 @@ export function Register() { throw new Error(authError.message ?? 'Registration failed') } - // After successful signUp, force a session fetch to confirm the cookie is set - // before navigating to the protected route - const sessionResult = await authClient.getSession() - if (sessionResult.data) { - navigate('/') - } else { - // Session not established — show success message and link to login - setError('Account created! Please sign in.') - } + setRegistrationComplete(true) } catch { if (import.meta.env.VITE_MOCK_AUTH === 'true') { setAuthenticated(true) @@ -59,6 +54,49 @@ export function Register() { } } + async function handleResendVerification() { + setResendLoading(true) + setResendMessage('') + try { + const { error } = await authClient.sendVerificationEmail({ email }) + if (error) { + setResendMessage('Failed to resend. Please try again.') + } else { + setResendMessage('Verification email sent!') + } + } finally { + setResendLoading(false) + } + } + + if (registrationComplete) { + return ( +
+

Check your email

+

+ We sent a verification link to {email}. Click it to activate your account. +

+ + {resendMessage && ( +

{resendMessage}

+ )} +

+ Already have an account?{' '} + + Sign in + +

+
+ ) + } + return (

Create Account

diff --git a/src/pages/VerifyEmail.tsx b/src/pages/VerifyEmail.tsx new file mode 100644 index 0000000..64da657 --- /dev/null +++ b/src/pages/VerifyEmail.tsx @@ -0,0 +1,113 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { authClient } from "../lib/auth-client.ts"; + +export function VerifyEmail() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const [status, setStatus] = useState<"verifying" | "success" | "error">("verifying"); + const [resendEmail, setResendEmail] = useState(""); + const [showResend, setShowResend] = useState(false); + const [resending, setResending] = useState(false); + const [resendMessage, setResendMessage] = useState(""); + + useEffect(() => { + const token = searchParams.get("token"); + const callbackURL = searchParams.get("callbackURL") || "/"; + + if (!token) { + setStatus("error"); + return; + } + + authClient.verifyEmail({ query: { token } }) + .then(() => { + setStatus("success"); + setTimeout(() => { + navigate(callbackURL); + }, 2000); + }) + .catch(() => { + setStatus("error"); + }); + }, [searchParams, navigate]); + + async function handleResend() { + if (!resendEmail) { + setResendMessage("Please enter your email address."); + return; + } + + setResending(true); + setResendMessage(""); + + try { + const { error } = await authClient.sendVerificationEmail({ email: resendEmail }); + if (error) { + setResendMessage("Failed to resend. Please try again."); + } else { + setResendMessage("Verification email sent!"); + setShowResend(false); + } + } finally { + setResending(false); + } + } + + return ( +
+ {status === "verifying" && ( + <> +
+

Verifying your email...

+

Please wait while we verify your email address.

+ + )} + + {status === "success" && ( + <> +

Email verified!

+

Redirecting you shortly...

+ + )} + + {status === "error" && ( + <> +

Verification failed

+

The verification link may have expired or is invalid.

+ + {!showResend ? ( + + ) : ( +
+ setResendEmail(e.target.value)} + className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue" + /> + + {resendMessage && ( +

{resendMessage}

+ )} +
+ )} + + )} +
+ ); +} \ No newline at end of file From 71e2978f526c84fd6d508a489c57d22f965bc055 Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Tue, 14 Apr 2026 13:18:13 +0000 Subject: [PATCH 11/12] Enable Better-Auth email verification with Resend - Add emailVerification.sendVerificationEmail config to auth/src/auth.ts using Resend to send verification emails on sign-up - Add resend npm package to auth/package.json - Update auth/.env.example with RESEND_API_KEY and FROM_EMAIL - Create VerifyEmail.tsx page with token verification flow, spinner UX, success/Error states, and resend option - Update Register.tsx to redirect to /verify-email after signup instead of auto-navigating to dashboard - Add /verify-email route to App.tsx - Frontend shows 'check your email' step after registration Co-Authored-By: Paperclip --- src/pages/VerifyEmail.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/VerifyEmail.tsx b/src/pages/VerifyEmail.tsx index 64da657..d1c5fb3 100644 --- a/src/pages/VerifyEmail.tsx +++ b/src/pages/VerifyEmail.tsx @@ -110,4 +110,4 @@ export function VerifyEmail() { )}
); -} \ No newline at end of file +} From 2e96e8f0a770f10eb773703e3e3257087aefdfe0 Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Wed, 15 Apr 2026 03:57:01 +0000 Subject: [PATCH 12/12] fix: remove unused navigate variable from Register.tsx Co-Authored-By: Paperclip --- src/pages/Register.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index ba039ba..a36e7c5 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { Link, useNavigate } from 'react-router-dom' +import { Link } from 'react-router-dom' import { authClient } from '../lib/auth-client.ts' export function Register() { @@ -11,7 +11,6 @@ export function Register() { const [registrationComplete, setRegistrationComplete] = useState(false) const [resendLoading, setResendLoading] = useState(false) const [resendMessage, setResendMessage] = useState('') - const navigate = useNavigate() async function handleSubmit(e: React.FormEvent) { e.preventDefault()