fix(downloads,sync): bound what one user can make the server fetch, download and spend

SEC-12: the download box accepts an arbitrary http(s) URL and yt-dlp fetches
it from the WORKER, so any approved user could aim the server at the compose
network, the LAN or a cloud metadata endpoint — and read the result off
job.error, where "connection refused" vs "HTTP Error 401" vs "timed out" is a
port scanner. downloads/urlguard.py refuses a URL whose host resolves to a
loopback/private/link-local/reserved address, at enqueue AND again in the
worker (DNS can move in between, and the worker is where the socket opens).
A network-class failure on a `url` job now reports one fixed sentence instead
of the raw transport text; the error CODE is still classified from the
original, so the localized explanation is unchanged. DNS rebinding is
explicitly NOT closed — that needs the connection gated inside yt-dlp — and
the module says so rather than implying more than it does.

SEC-13: `noplaylist` only demotes a video-inside-a-playlist URL to that video,
so a URL that is purely a playlist or channel still expanded into every
entry — one enqueue, unbounded downloads. `playlist_items` pins it to one.
Size was unbounded too: `check_enqueue` compares the CURRENT footprint against
the cap, and a download's size is unknown until it lands, so one job could
overshoot by any amount; the owner's remaining budget is now handed to yt-dlp
as `max_filesize`, applied AFTER the adapter merge so no source adapter can
raise a limit the quota layer set. A join's cut-list is capped at 100: an
accurate join emits two filters per segment into one ffmpeg filter_complex.

SEC-14: the YouTube budget is instance-wide, so one user holding down a
manual sync spent the day for everyone. Per-user (not per-IP — the cost
belongs to the account) 3-per-10-minutes limiters on the two bulk pulls,
subscriptions and playlists, with separate budgets so one does not close the
other. The review also listed /rss, /backfill and /enrich; E3 deleted them,
and search stays unlimited on purpose — it is interactive, not a bulk pull.

Gate: pytest 896 -> 924 (none skipped), vitest 671.
This commit is contained in:
2026-09-05 01:03:24 +02:00
parent 688efb9ed6
commit 0cc450d1c9
15 changed files with 429 additions and 5 deletions
@@ -229,6 +229,13 @@
"tooLarge": "That file is too large (max {{size}}).",
"empty": "That file is empty."
},
"urlBlocked": {
"scheme": "Only http:// and https:// links can be downloaded.",
"no_host": "That link doesn't contain a website address.",
"unresolvable": "That address couldn't be looked up. Check the link and try again.",
"private": "That address is on the server's own network, so it can't be downloaded from.",
"generic": "That address can't be downloaded from."
},
"fail": {
"title": "Download failed",
"detailsLink": "details",
+1
View File
@@ -3,6 +3,7 @@
"generic": "The server couldn't carry out that action. Please try again.",
"validation": "Some of the values you entered weren't valid. Please check them and try again.",
"server": "Server error",
"syncCooldown": "You've synced a few times just now — give it a few minutes. The YouTube budget is shared with everyone on this instance, and background syncing keeps running either way.",
"ok": "OK",
"offline": {
"title": "Connection lost",
@@ -229,6 +229,13 @@
"tooLarge": "Ez a fájl túl nagy (legfeljebb {{size}}).",
"empty": "Ez a fájl üres."
},
"urlBlocked": {
"scheme": "Csak http:// és https:// hivatkozás tölthető le.",
"no_host": "Ez a hivatkozás nem tartalmaz webcímet.",
"unresolvable": "Ezt a címet nem sikerült feloldani. Ellenőrizd a hivatkozást, és próbáld újra.",
"private": "Ez a cím a szerver saját hálózatán van, ezért nem tölthető le róla.",
"generic": "Erről a címről nem lehet letölteni."
},
"fail": {
"title": "A letöltés nem sikerült",
"detailsLink": "részletek",
+1
View File
@@ -3,6 +3,7 @@
"generic": "A szerver nem tudta végrehajtani a műveletet. Próbáld újra.",
"validation": "Néhány megadott érték érvénytelen volt. Ellenőrizd, majd próbáld újra.",
"server": "Szerverhiba",
"syncCooldown": "Az imént többször is szinkronizáltál — várj pár percet. A YouTube-keret közös az instance összes felhasználójával, a háttérszinkron pedig így is fut tovább.",
"ok": "OK",
"offline": {
"title": "Megszakadt a kapcsolat",
+8
View File
@@ -72,6 +72,14 @@ function localizeDetail(d: unknown): string {
if (o?.code === "upload_too_large")
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit) });
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
// SEC-12: a download URL the server refuses to fetch. The reasons are deliberately distinct —
// "we could not look that host up" is a different thing to tell someone than "we will not fetch
// from that address" — so an unknown reason falls back to the generic refusal rather than a key.
if (o?.code === "url_blocked")
return t(`downloads.errors.urlBlocked.${o.reason}`, t("downloads.errors.urlBlocked.generic"));
// SEC-14: a manual sync trigger on cooldown. Not an error the user caused so much as a pace
// limit, and the copy says so.
if (o?.code === "sync_cooldown") return t("errors.syncCooldown");
return t("errors.generic");
}