feat: implement API rate limiting (#318)

* feat: implement rate limiting with slowapi

Adds API rate limiting using slowapi. Configures a global default limit of 100/minute, with stricter limits for authentication (10/minute) and upload (5/minute) endpoints. Rate limits are configurable via environment variables.

* docs: add security and rate limiting documentation

* docs: restructure security documentation

Moves rate-limiting.md and security.md to top-level docs/ directory to sit alongside authentication.md. Updates cross-references.

* docs: clarify in-memory rate limiting limitations

* docs: remove internal reference from rate limiting docs

authored by zzstoatzz.io and committed by GitHub 66bc2436 69919da1

Changed files
+203 -9
docs
src
backend
+51
docs/rate-limiting.md
··· 1 + # Rate Limiting 2 + 3 + plyr.fm uses [slowapi](https://github.com/laurentS/slowapi) to implement application-side rate limiting. This protects the backend from abuse, brute-force attacks, and denial-of-service attempts. 4 + 5 + ## Configuration 6 + 7 + Rate limits are configured via environment variables. Defaults are set in `src/backend/config.py`. 8 + 9 + | Environment Variable | Default | Description | 10 + |---------------------|---------|-------------| 11 + | `RATE_LIMIT_ENABLED` | `true` | Enable/disable rate limiting globally. | 12 + | `RATE_LIMIT_DEFAULT_LIMIT` | `100/minute` | Global limit applied to all endpoints by default. | 13 + | `RATE_LIMIT_AUTH_LIMIT` | `10/minute` | Strict limit for auth endpoints (`/auth/start`, `/auth/exchange`). | 14 + | `RATE_LIMIT_UPLOAD_LIMIT` | `5/minute` | Strict limit for file uploads (`/tracks/`). | 15 + 16 + ## Architecture 17 + 18 + The current implementation uses **in-memory storage**. 19 + 20 + * **Per-Instance:** Limits are tracked per application instance (Fly Machine). 21 + * **Scaling:** With multiple replicas (e.g., 2 machines), the **effective global limit** scales linearly. 22 + * Example: A limit of `100/minute` with 2 machines results in a total capacity of roughly `200/minute`. 23 + * **Keying:** Limits are applied by **IP address** (`get_remote_address`). 24 + 25 + ### Why in-memory? 26 + For our current scale, in-memory is sufficient and avoids the complexity/cost of a dedicated Redis cluster. This provides effective protection against single-source flooding (DDoS/brute-force) directed at any specific instance. 27 + 28 + ### Future State (Redis) 29 + If strict global synchronization or complex tier-based limiting is required in the future, we will migrate to a Redis-backed limiter. `slowapi` supports Redis out of the box, which would allow maintaining shared counters across all application instances. 30 + 31 + ## Adding Limits to Endpoints 32 + 33 + To apply a specific limit to a route, use the `@limiter.limit` decorator: 34 + 35 + ```python 36 + from backend.utilities.rate_limit import limiter 37 + from backend.config import settings 38 + 39 + @router.post("/my-expensive-endpoint") 40 + @limiter.limit("5/minute") 41 + async def my_endpoint(request: Request): 42 + ... 43 + ``` 44 + 45 + **Requirements:** 46 + * The endpoint function **must** accept a `request: Request` parameter. 47 + * Use configuration settings instead of hardcoded strings where possible. 48 + 49 + ## Monitoring 50 + 51 + Rate limit hits return `429 Too Many Requests`. These events are logged and will appear in Logfire traces with the `429` status code.
+33
docs/security.md
··· 1 + # Security 2 + 3 + Overview of security mechanisms in plyr.fm. 4 + 5 + ## Authentication 6 + 7 + We use **HttpOnly Cookies** for session management to prevent XSS attacks. 8 + See [Authentication](authentication.md) for details on the OAuth flow, token management, and environment architecture. 9 + 10 + For backend implementation details regarding ATProto identity resolution, see [backend/atproto-identity.md](backend/atproto-identity.md). 11 + 12 + ## Rate Limiting 13 + 14 + We enforce application-side rate limits to prevent abuse. 15 + See [Rate Limiting](rate-limiting.md) for configuration and architecture details. 16 + 17 + ## HTTP Security Headers 18 + 19 + The `SecurityHeadersMiddleware` in `src/backend/main.py` automatically applies industry-standard security headers to all responses: 20 + 21 + * **`Strict-Transport-Security` (HSTS):** Enforces HTTPS (Production only). Max-age set to 1 year. 22 + * **`X-Content-Type-Options: nosniff`:** Prevents browsers from MIME-sniffing a response away from the declared content-type. 23 + * **`X-Frame-Options: DENY`:** Prevents the site from being embedded in iframes (clickjacking protection). 24 + * **`X-XSS-Protection: 1; mode=block`:** Enables browser cross-site scripting filters. 25 + * **`Referrer-Policy: strict-origin-when-cross-origin`:** Controls how much referrer information is included with requests. 26 + 27 + ## CORS 28 + 29 + Cross-Origin Resource Sharing (CORS) is configured to allow: 30 + * **Localhost:** For development (`http://localhost:5173`). 31 + * **Production/Staging Domains:** `plyr.fm`, `stg.plyr.fm`, and Cloudflare Pages preview URLs (via regex). 32 + 33 + Configuration is managed in `src/backend/config.py` under `FrontendSettings`.
+1
pyproject.toml
··· 24 24 "cachetools>=6.2.1", 25 25 "pytest-asyncio>=0.25.3", 26 26 "aioboto3>=15.5.0", 27 + "slowapi>=0.1.9", 27 28 ] 28 29 29 30 requires-python = ">=3.11"
+9 -7
src/backend/api/auth.py
··· 2 2 3 3 from typing import Annotated 4 4 5 - from fastapi import APIRouter, Depends, HTTPException, Query 5 + from fastapi import APIRouter, Depends, HTTPException, Query, Request 6 6 from fastapi.responses import JSONResponse, RedirectResponse 7 7 from pydantic import BaseModel 8 - from starlette.requests import Request 9 8 from starlette.responses import Response 10 9 11 10 from backend._internal import ( ··· 20 19 start_oauth_flow, 21 20 ) 22 21 from backend.config import settings 22 + from backend.utilities.rate_limit import limiter 23 23 24 24 router = APIRouter(prefix="/auth", tags=["auth"]) 25 25 ··· 32 32 33 33 34 34 @router.get("/start") 35 - async def start_login(handle: str) -> RedirectResponse: 35 + @limiter.limit(settings.rate_limit.auth_limit) 36 + async def start_login(request: Request, handle: str) -> RedirectResponse: 36 37 """start OAuth flow for a given handle.""" 37 38 auth_url, _state = await start_oauth_flow(handle) 38 39 return RedirectResponse(url=auth_url) ··· 80 81 81 82 82 83 @router.post("/exchange") 84 + @limiter.limit(settings.rate_limit.auth_limit) 83 85 async def exchange_token( 84 - request: ExchangeTokenRequest, 85 - http_request: Request, 86 + request: Request, 87 + exchange_request: ExchangeTokenRequest, 86 88 response: Response, 87 89 ) -> ExchangeTokenResponse: 88 90 """exchange one-time token for session_id. ··· 93 95 for browser requests: sets HttpOnly cookie and still returns session_id in response 94 96 for SDK/CLI clients: only returns session_id in response (no cookie) 95 97 """ 96 - session_id = await consume_exchange_token(request.exchange_token) 98 + session_id = await consume_exchange_token(exchange_request.exchange_token) 97 99 98 100 if not session_id: 99 101 raise HTTPException( ··· 101 103 detail="invalid, expired, or already used exchange token", 102 104 ) 103 105 104 - user_agent = http_request.headers.get("user-agent", "").lower() 106 + user_agent = request.headers.get("user-agent", "").lower() 105 107 is_browser = any( 106 108 browser in user_agent 107 109 for browser in ["mozilla", "chrome", "safari", "firefox", "edge", "opera"]
+12 -1
src/backend/api/tracks/uploads.py
··· 9 9 from typing import Annotated 10 10 11 11 import logfire 12 - from fastapi import BackgroundTasks, Depends, File, Form, HTTPException, UploadFile 12 + from fastapi import ( 13 + BackgroundTasks, 14 + Depends, 15 + File, 16 + Form, 17 + HTTPException, 18 + Request, 19 + UploadFile, 20 + ) 13 21 from fastapi.responses import StreamingResponse 14 22 from sqlalchemy import select 15 23 from sqlalchemy.exc import IntegrityError ··· 26 34 from backend.storage import storage 27 35 from backend.utilities.database import db_session 28 36 from backend.utilities.hashing import CHUNK_SIZE 37 + from backend.utilities.rate_limit import limiter 29 38 30 39 from .router import router 31 40 from .services import get_or_create_album ··· 366 375 367 376 368 377 @router.post("/") 378 + @limiter.limit(settings.rate_limit.upload_limit) 369 379 async def upload_track( 380 + request: Request, 370 381 title: Annotated[str, Form()], 371 382 background_tasks: BackgroundTasks, 372 383 auth_session: AuthSession = Depends(require_artist_profile),
+32
src/backend/config.py
··· 365 365 ) 366 366 367 367 368 + class RateLimitSettings(RelaySettingsSection): 369 + """Rate limiting configuration.""" 370 + 371 + model_config = SettingsConfigDict( 372 + env_prefix="RATE_LIMIT_", 373 + env_file=".env", 374 + case_sensitive=False, 375 + extra="ignore", 376 + ) 377 + 378 + enabled: bool = Field( 379 + default=True, 380 + description="Enable API rate limiting", 381 + ) 382 + default_limit: str = Field( 383 + default="100/minute", 384 + description="Default global rate limit", 385 + ) 386 + auth_limit: str = Field( 387 + default="10/minute", 388 + description="Rate limit for authentication endpoints", 389 + ) 390 + upload_limit: str = Field( 391 + default="5/minute", 392 + description="Rate limit for file uploads", 393 + ) 394 + 395 + 368 396 class Settings(RelaySettingsSection): 369 397 """Relay application settings.""" 370 398 ··· 382 410 storage: StorageSettings = Field(default_factory=StorageSettings) 383 411 atproto: AtprotoSettings = Field(default_factory=AtprotoSettings) 384 412 observability: ObservabilitySettings = Field(default_factory=ObservabilitySettings) 413 + rate_limit: RateLimitSettings = Field( 414 + default_factory=RateLimitSettings, 415 + description="Rate limiting settings", 416 + ) 385 417 notify: NotificationSettings = Field( 386 418 default_factory=NotificationSettings, 387 419 description="Notification settings",
+11
src/backend/main.py
··· 7 7 8 8 from fastapi import FastAPI, Request 9 9 from fastapi.middleware.cors import CORSMiddleware 10 + from slowapi import _rate_limit_exceeded_handler 11 + from slowapi.errors import RateLimitExceeded 12 + from slowapi.middleware import SlowAPIMiddleware 10 13 from starlette.middleware.base import BaseHTTPMiddleware 11 14 12 15 # filter pydantic warning from atproto library ··· 32 35 from backend.api.migration import router as migration_router 33 36 from backend.config import settings 34 37 from backend.models import init_db 38 + from backend.utilities.rate_limit import limiter 35 39 36 40 # configure logfire if enabled 37 41 if settings.observability.enabled: ··· 119 123 lifespan=lifespan, 120 124 ) 121 125 126 + # setup rate limiter 127 + app.state.limiter = limiter 128 + app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) 129 + 122 130 # instrument fastapi with logfire 123 131 if logfire: 124 132 logfire.instrument_fastapi(app) ··· 134 142 allow_methods=["*"], 135 143 allow_headers=["*"], 136 144 ) 145 + 146 + # add rate limiting middleware 147 + app.add_middleware(SlowAPIMiddleware) 137 148 138 149 # include routers 139 150 app.include_router(auth_router)
+13
src/backend/utilities/rate_limit.py
··· 1 + """Rate limiting utility.""" 2 + 3 + from slowapi import Limiter 4 + from slowapi.util import get_remote_address 5 + 6 + from backend.config import settings 7 + 8 + limiter = Limiter( 9 + key_func=get_remote_address, 10 + enabled=settings.rate_limit.enabled, 11 + default_limits=[settings.rate_limit.default_limit], 12 + storage_uri="memory://", 13 + )
+41 -1
uv.lock
··· 1 1 version = 1 2 - revision = 2 2 + revision = 3 3 3 requires-python = ">=3.11" 4 4 5 5 [[package]] ··· 322 322 { name = "python-dotenv" }, 323 323 { name = "python-jose", extra = ["cryptography"] }, 324 324 { name = "python-multipart" }, 325 + { name = "slowapi" }, 325 326 { name = "sqlalchemy" }, 326 327 { name = "uvicorn", extra = ["standard"] }, 327 328 ] ··· 363 364 { name = "python-dotenv", specifier = ">=1.1.0" }, 364 365 { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, 365 366 { name = "python-multipart", specifier = ">=0.0.20" }, 367 + { name = "slowapi", specifier = ">=0.1.9" }, 366 368 { name = "sqlalchemy", specifier = ">=2.0.36" }, 367 369 { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, 368 370 ] ··· 807 809 ] 808 810 809 811 [[package]] 812 + name = "deprecated" 813 + version = "1.3.1" 814 + source = { registry = "https://pypi.org/simple" } 815 + dependencies = [ 816 + { name = "wrapt" }, 817 + ] 818 + sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } 819 + wheels = [ 820 + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, 821 + ] 822 + 823 + [[package]] 810 824 name = "dirty-equals" 811 825 version = "0.10.0" 812 826 source = { registry = "https://pypi.org/simple" } ··· 1276 1290 ] 1277 1291 1278 1292 [[package]] 1293 + name = "limits" 1294 + version = "5.6.0" 1295 + source = { registry = "https://pypi.org/simple" } 1296 + dependencies = [ 1297 + { name = "deprecated" }, 1298 + { name = "packaging" }, 1299 + { name = "typing-extensions" }, 1300 + ] 1301 + sdist = { url = "https://files.pythonhosted.org/packages/bb/e5/c968d43a65128cd54fb685f257aafb90cd5e4e1c67d084a58f0e4cbed557/limits-5.6.0.tar.gz", hash = "sha256:807fac75755e73912e894fdd61e2838de574c5721876a19f7ab454ae1fffb4b5", size = 182984, upload-time = "2025-09-29T17:15:22.689Z" } 1302 + wheels = [ 1303 + { url = "https://files.pythonhosted.org/packages/40/96/4fcd44aed47b8fcc457653b12915fcad192cd646510ef3f29fd216f4b0ab/limits-5.6.0-py3-none-any.whl", hash = "sha256:b585c2104274528536a5b68864ec3835602b3c4a802cd6aa0b07419798394021", size = 60604, upload-time = "2025-09-29T17:15:18.419Z" }, 1304 + ] 1305 + 1306 + [[package]] 1279 1307 name = "logfire" 1280 1308 version = "4.14.2" 1281 1309 source = { registry = "https://pypi.org/simple" } ··· 2428 2456 sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } 2429 2457 wheels = [ 2430 2458 { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, 2459 + ] 2460 + 2461 + [[package]] 2462 + name = "slowapi" 2463 + version = "0.1.9" 2464 + source = { registry = "https://pypi.org/simple" } 2465 + dependencies = [ 2466 + { name = "limits" }, 2467 + ] 2468 + sdist = { url = "https://files.pythonhosted.org/packages/a0/99/adfc7f94ca024736f061257d39118e1542bade7a52e86415a4c4ae92d8ff/slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77", size = 14028, upload-time = "2024-02-05T12:11:52.13Z" } 2469 + wheels = [ 2470 + { url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" }, 2431 2471 ] 2432 2472 2433 2473 [[package]]