|
59 | 59 | ) |
60 | 60 | from utils.worker import JobWorker |
61 | 61 | from utils.auth import require_token, require_admin, auth_required, optional_token |
| 62 | +from utils.rate_limit import make_rate_limiter |
62 | 63 |
|
63 | 64 | ALLOWED_METADATA_PROVIDERS = frozenset({"deezer", "spotify"}) |
64 | 65 |
|
| 66 | +# Rate-Limiter (Audit M-1, 2026-05-12). Module-level damit alle Requests |
| 67 | +# denselben State teilen — Fresh-Per-Request würde den Counter resetten. |
| 68 | +# Werte konservativ-großzügig gewählt: legitime Bulk-User stoßen nicht an, |
| 69 | +# automatisierte Bursts werden gebremst. |
| 70 | +_rl_csv_import = make_rate_limiter(20, 3600) # 20 CSV-Imports/h |
| 71 | +_rl_spotify_history = make_rate_limiter(10, 3600) # 10 Spotify-History/h |
| 72 | +_rl_url_download = make_rate_limiter(60, 3600) # 60 URL-Downloads/h |
| 73 | +_rl_track_download = make_rate_limiter(120, 3600) # 120 Track-Downloads/h |
| 74 | + |
65 | 75 | # Extra download attempts after the first failure (each failure waits before retrying). |
66 | 76 | MAX_DOWNLOAD_RETRIES_CAP = 5 |
67 | 77 |
|
@@ -876,6 +886,30 @@ async def add_root_path(request: Request, call_next): |
876 | 886 | return await call_next(request) |
877 | 887 |
|
878 | 888 |
|
| 889 | +@app.middleware("http") |
| 890 | +async def add_security_headers(request: Request, call_next): |
| 891 | + """Standard-Security-Header für jede Response (Audit M-2, 2026-05-12). |
| 892 | +
|
| 893 | + Vier No-Brainer-Header die nicht die UI brechen können: |
| 894 | + - X-Content-Type-Options: nosniff — blockt MIME-Type-Sniffing-Attacks |
| 895 | + - X-Frame-Options: DENY — blockt Clickjacking via iframe-Embedding |
| 896 | + - Referrer-Policy: strict-origin-when-cross-origin — limitiert Referer-Leak |
| 897 | + - Permissions-Policy — disabled Browser-APIs die Tonus nicht braucht |
| 898 | + CSP bleibt bewusst weg — würde SvelteKit-inline-styles brechen und braucht |
| 899 | + eigenes Tuning (eigenes Backlog-Item). HSTS macht Sinn nur HINTER TLS-Proxy |
| 900 | + (Traefik) und wird vom Proxy selbst gesetzt — hier kein zweites Set. |
| 901 | + """ |
| 902 | + response = await call_next(request) |
| 903 | + response.headers.setdefault("X-Content-Type-Options", "nosniff") |
| 904 | + response.headers.setdefault("X-Frame-Options", "DENY") |
| 905 | + response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") |
| 906 | + response.headers.setdefault( |
| 907 | + "Permissions-Policy", |
| 908 | + "geolocation=(), microphone=(), camera=(), payment=(), usb=()", |
| 909 | + ) |
| 910 | + return response |
| 911 | + |
| 912 | + |
879 | 913 | @app.get("/api/metadata/providers") |
880 | 914 | async def metadata_providers(): |
881 | 915 | """Available metadata sources and server default.""" |
@@ -1294,7 +1328,7 @@ async def move_queue_item(req: QueueMoveRequest, _: None = Depends(require_token |
1294 | 1328 |
|
1295 | 1329 |
|
1296 | 1330 | @app.post("/api/download") |
1297 | | -async def download_track(request: DownloadRequest, background_tasks: BackgroundTasks, _: None = Depends(require_token)): |
| 1331 | +async def download_track(request: DownloadRequest, background_tasks: BackgroundTasks, _: None = Depends(require_token), __: None = Depends(_rl_track_download)): |
1298 | 1332 | """Start downloading a track""" |
1299 | 1333 | if request.location not in ["local", "navidrome"]: |
1300 | 1334 | request.location = "local" |
@@ -2116,7 +2150,7 @@ def get_recommendations(request: RecommendationRequest): |
2116 | 2150 |
|
2117 | 2151 |
|
2118 | 2152 | @app.post("/api/import/csv") |
2119 | | -async def import_csv(request: CsvImportRequest, _: None = Depends(require_token)): |
| 2153 | +async def import_csv(request: CsvImportRequest, _: None = Depends(require_token), __: None = Depends(_rl_csv_import)): |
2120 | 2154 | """ |
2121 | 2155 | CSV-Import (persistent): speichert Job in SQLite, Worker holt ihn ab. |
2122 | 2156 | Gibt sofort eine job_id zurück — Status unter /api/import/jobs/{job_id}/status pollbar. |
@@ -2316,7 +2350,7 @@ class SpotifyHistoryImportRequest(BaseModel): |
2316 | 2350 |
|
2317 | 2351 |
|
2318 | 2352 | @app.post("/api/import/spotify-history") |
2319 | | -async def import_spotify_history(req: SpotifyHistoryImportRequest, _: None = Depends(require_token)): |
| 2353 | +async def import_spotify_history(req: SpotifyHistoryImportRequest, _: None = Depends(require_token), __: None = Depends(_rl_spotify_history)): |
2320 | 2354 | """Importiert eine oder mehrere Spotify-Extended-Streaming-History-JSONs. |
2321 | 2355 |
|
2322 | 2356 | Pipeline-Integration: |
@@ -2879,7 +2913,7 @@ def url_download_and_process( |
2879 | 2913 |
|
2880 | 2914 |
|
2881 | 2915 | @app.post("/api/url/download") |
2882 | | -async def url_download(req: URLDownloadRequest, background_tasks: BackgroundTasks, _: None = Depends(require_token)): |
| 2916 | +async def url_download(req: URLDownloadRequest, background_tasks: BackgroundTasks, _: None = Depends(require_token), __: None = Depends(_rl_url_download)): |
2883 | 2917 | """Phase 1: Direkter Download via URL ohne Spotify/Deezer-Match. |
2884 | 2918 |
|
2885 | 2919 | Funktioniert für YouTube, SoundCloud, Bandcamp, Vimeo … alles was yt-dlp kennt. |
|
0 commit comments