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 "cachetools>=6.2.1", 25 "pytest-asyncio>=0.25.3", 26 "aioboto3>=15.5.0", 27 ] 28 29 requires-python = ">=3.11"
··· 24 "cachetools>=6.2.1", 25 "pytest-asyncio>=0.25.3", 26 "aioboto3>=15.5.0", 27 + "slowapi>=0.1.9", 28 ] 29 30 requires-python = ">=3.11"
+9 -7
src/backend/api/auth.py
··· 2 3 from typing import Annotated 4 5 - from fastapi import APIRouter, Depends, HTTPException, Query 6 from fastapi.responses import JSONResponse, RedirectResponse 7 from pydantic import BaseModel 8 - from starlette.requests import Request 9 from starlette.responses import Response 10 11 from backend._internal import ( ··· 20 start_oauth_flow, 21 ) 22 from backend.config import settings 23 24 router = APIRouter(prefix="/auth", tags=["auth"]) 25 ··· 32 33 34 @router.get("/start") 35 - async def start_login(handle: str) -> RedirectResponse: 36 """start OAuth flow for a given handle.""" 37 auth_url, _state = await start_oauth_flow(handle) 38 return RedirectResponse(url=auth_url) ··· 80 81 82 @router.post("/exchange") 83 async def exchange_token( 84 - request: ExchangeTokenRequest, 85 - http_request: Request, 86 response: Response, 87 ) -> ExchangeTokenResponse: 88 """exchange one-time token for session_id. ··· 93 for browser requests: sets HttpOnly cookie and still returns session_id in response 94 for SDK/CLI clients: only returns session_id in response (no cookie) 95 """ 96 - session_id = await consume_exchange_token(request.exchange_token) 97 98 if not session_id: 99 raise HTTPException( ··· 101 detail="invalid, expired, or already used exchange token", 102 ) 103 104 - user_agent = http_request.headers.get("user-agent", "").lower() 105 is_browser = any( 106 browser in user_agent 107 for browser in ["mozilla", "chrome", "safari", "firefox", "edge", "opera"]
··· 2 3 from typing import Annotated 4 5 + from fastapi import APIRouter, Depends, HTTPException, Query, Request 6 from fastapi.responses import JSONResponse, RedirectResponse 7 from pydantic import BaseModel 8 from starlette.responses import Response 9 10 from backend._internal import ( ··· 19 start_oauth_flow, 20 ) 21 from backend.config import settings 22 + from backend.utilities.rate_limit import limiter 23 24 router = APIRouter(prefix="/auth", tags=["auth"]) 25 ··· 32 33 34 @router.get("/start") 35 + @limiter.limit(settings.rate_limit.auth_limit) 36 + async def start_login(request: Request, handle: str) -> RedirectResponse: 37 """start OAuth flow for a given handle.""" 38 auth_url, _state = await start_oauth_flow(handle) 39 return RedirectResponse(url=auth_url) ··· 81 82 83 @router.post("/exchange") 84 + @limiter.limit(settings.rate_limit.auth_limit) 85 async def exchange_token( 86 + request: Request, 87 + exchange_request: ExchangeTokenRequest, 88 response: Response, 89 ) -> ExchangeTokenResponse: 90 """exchange one-time token for session_id. ··· 95 for browser requests: sets HttpOnly cookie and still returns session_id in response 96 for SDK/CLI clients: only returns session_id in response (no cookie) 97 """ 98 + session_id = await consume_exchange_token(exchange_request.exchange_token) 99 100 if not session_id: 101 raise HTTPException( ··· 103 detail="invalid, expired, or already used exchange token", 104 ) 105 106 + user_agent = request.headers.get("user-agent", "").lower() 107 is_browser = any( 108 browser in user_agent 109 for browser in ["mozilla", "chrome", "safari", "firefox", "edge", "opera"]
+12 -1
src/backend/api/tracks/uploads.py
··· 9 from typing import Annotated 10 11 import logfire 12 - from fastapi import BackgroundTasks, Depends, File, Form, HTTPException, UploadFile 13 from fastapi.responses import StreamingResponse 14 from sqlalchemy import select 15 from sqlalchemy.exc import IntegrityError ··· 26 from backend.storage import storage 27 from backend.utilities.database import db_session 28 from backend.utilities.hashing import CHUNK_SIZE 29 30 from .router import router 31 from .services import get_or_create_album ··· 366 367 368 @router.post("/") 369 async def upload_track( 370 title: Annotated[str, Form()], 371 background_tasks: BackgroundTasks, 372 auth_session: AuthSession = Depends(require_artist_profile),
··· 9 from typing import Annotated 10 11 import logfire 12 + from fastapi import ( 13 + BackgroundTasks, 14 + Depends, 15 + File, 16 + Form, 17 + HTTPException, 18 + Request, 19 + UploadFile, 20 + ) 21 from fastapi.responses import StreamingResponse 22 from sqlalchemy import select 23 from sqlalchemy.exc import IntegrityError ··· 34 from backend.storage import storage 35 from backend.utilities.database import db_session 36 from backend.utilities.hashing import CHUNK_SIZE 37 + from backend.utilities.rate_limit import limiter 38 39 from .router import router 40 from .services import get_or_create_album ··· 375 376 377 @router.post("/") 378 + @limiter.limit(settings.rate_limit.upload_limit) 379 async def upload_track( 380 + request: Request, 381 title: Annotated[str, Form()], 382 background_tasks: BackgroundTasks, 383 auth_session: AuthSession = Depends(require_artist_profile),
+32
src/backend/config.py
··· 365 ) 366 367 368 class Settings(RelaySettingsSection): 369 """Relay application settings.""" 370 ··· 382 storage: StorageSettings = Field(default_factory=StorageSettings) 383 atproto: AtprotoSettings = Field(default_factory=AtprotoSettings) 384 observability: ObservabilitySettings = Field(default_factory=ObservabilitySettings) 385 notify: NotificationSettings = Field( 386 default_factory=NotificationSettings, 387 description="Notification settings",
··· 365 ) 366 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 + 396 class Settings(RelaySettingsSection): 397 """Relay application settings.""" 398 ··· 410 storage: StorageSettings = Field(default_factory=StorageSettings) 411 atproto: AtprotoSettings = Field(default_factory=AtprotoSettings) 412 observability: ObservabilitySettings = Field(default_factory=ObservabilitySettings) 413 + rate_limit: RateLimitSettings = Field( 414 + default_factory=RateLimitSettings, 415 + description="Rate limiting settings", 416 + ) 417 notify: NotificationSettings = Field( 418 default_factory=NotificationSettings, 419 description="Notification settings",
+11
src/backend/main.py
··· 7 8 from fastapi import FastAPI, Request 9 from fastapi.middleware.cors import CORSMiddleware 10 from starlette.middleware.base import BaseHTTPMiddleware 11 12 # filter pydantic warning from atproto library ··· 32 from backend.api.migration import router as migration_router 33 from backend.config import settings 34 from backend.models import init_db 35 36 # configure logfire if enabled 37 if settings.observability.enabled: ··· 119 lifespan=lifespan, 120 ) 121 122 # instrument fastapi with logfire 123 if logfire: 124 logfire.instrument_fastapi(app) ··· 134 allow_methods=["*"], 135 allow_headers=["*"], 136 ) 137 138 # include routers 139 app.include_router(auth_router)
··· 7 8 from fastapi import FastAPI, Request 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 13 from starlette.middleware.base import BaseHTTPMiddleware 14 15 # filter pydantic warning from atproto library ··· 35 from backend.api.migration import router as migration_router 36 from backend.config import settings 37 from backend.models import init_db 38 + from backend.utilities.rate_limit import limiter 39 40 # configure logfire if enabled 41 if settings.observability.enabled: ··· 123 lifespan=lifespan, 124 ) 125 126 + # setup rate limiter 127 + app.state.limiter = limiter 128 + app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) 129 + 130 # instrument fastapi with logfire 131 if logfire: 132 logfire.instrument_fastapi(app) ··· 142 allow_methods=["*"], 143 allow_headers=["*"], 144 ) 145 + 146 + # add rate limiting middleware 147 + app.add_middleware(SlowAPIMiddleware) 148 149 # include routers 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 version = 1 2 - revision = 2 3 requires-python = ">=3.11" 4 5 [[package]] ··· 322 { name = "python-dotenv" }, 323 { name = "python-jose", extra = ["cryptography"] }, 324 { name = "python-multipart" }, 325 { name = "sqlalchemy" }, 326 { name = "uvicorn", extra = ["standard"] }, 327 ] ··· 363 { name = "python-dotenv", specifier = ">=1.1.0" }, 364 { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, 365 { name = "python-multipart", specifier = ">=0.0.20" }, 366 { name = "sqlalchemy", specifier = ">=2.0.36" }, 367 { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, 368 ] ··· 807 ] 808 809 [[package]] 810 name = "dirty-equals" 811 version = "0.10.0" 812 source = { registry = "https://pypi.org/simple" } ··· 1276 ] 1277 1278 [[package]] 1279 name = "logfire" 1280 version = "4.14.2" 1281 source = { registry = "https://pypi.org/simple" } ··· 2428 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 wheels = [ 2430 { 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" }, 2431 ] 2432 2433 [[package]]
··· 1 version = 1 2 + revision = 3 3 requires-python = ">=3.11" 4 5 [[package]] ··· 322 { name = "python-dotenv" }, 323 { name = "python-jose", extra = ["cryptography"] }, 324 { name = "python-multipart" }, 325 + { name = "slowapi" }, 326 { name = "sqlalchemy" }, 327 { name = "uvicorn", extra = ["standard"] }, 328 ] ··· 364 { name = "python-dotenv", specifier = ">=1.1.0" }, 365 { name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" }, 366 { name = "python-multipart", specifier = ">=0.0.20" }, 367 + { name = "slowapi", specifier = ">=0.1.9" }, 368 { name = "sqlalchemy", specifier = ">=2.0.36" }, 369 { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, 370 ] ··· 809 ] 810 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]] 824 name = "dirty-equals" 825 version = "0.10.0" 826 source = { registry = "https://pypi.org/simple" } ··· 1290 ] 1291 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]] 1307 name = "logfire" 1308 version = "4.14.2" 1309 source = { registry = "https://pypi.org/simple" } ··· 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" } 2457 wheels = [ 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" }, 2471 ] 2472 2473 [[package]]