A review round read yt-dlp's source and found the comment behind the previous commit's safety-net marker did not match it. Measured in the worker with a real account cookie and `player_client=android_vr`: yt-dlp logs `Skipping client "android_vr" since it does not support cookies`, drops it, and the request left with no client fails with "Requested format is not available" — not "No player clients have been requested". That raise sits EARLIER in yt-dlp, before the cookie-capability removal, so the cookie path can never reach it; and an unknown or renamed client is skipped with a warning and replaced by yt-dlp's defaults, so the other scenario the comment named cannot reach it either. So the marker bought nothing: the symptom that does occur was already retriable here before this branch. It is dropped, the comment now states what was measured, and the test asserts the real symptom instead of a message the code cannot produce. The cookie-capability filter itself is vindicated by the same measurement — without it every signed-in download would burn its first attempt on a client yt-dlp discards. Also from the round: an enqueue test for a `source_kind="youtube"` job, the one join nothing else made (a YouTube job stores a bare 11-char ref and the URL the cookie gate keys on is rebuilt by the adapter — both ends were pinned apart, the join was not, and a break there would stop every YouTube cookie silently while signed-out downloads kept working). And copy corrections: the group's only unhedged claim is hedged, "described the video" loses the pipeline vocabulary, and four Hungarian phrasings that read as translated English are rewritten.
454 lines
19 KiB
Python
454 lines
19 KiB
Python
"""S6 — per-user, per-host download cookies: host-scope, cache privacy, encryption, write-only API.
|
|
|
|
The two load-bearing security properties are covered as behaviour, not prose:
|
|
* **cache privacy** — a cookie-authed download is a PRIVATE, salted asset; a second user without a
|
|
cookie enqueuing the same URL gets the shared (unsalted) asset and can never be cache-served the
|
|
authed one, and the worker attaches a cookie ONLY to that private identity.
|
|
* **host scope (SEC-12)** — a cookie is resolved ONLY for a URL whose netloc the credential owns; a
|
|
look-alike or unrelated host resolves to no host_key and gets no cookie.
|
|
|
|
Pure-logic parts run with no DB; the CRUD / enqueue-salting / API parts use the `db`/`as_user` lane.
|
|
"""
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from cryptography.fernet import Fernet
|
|
|
|
from app import security
|
|
from app.downloads import credentials, formats, service
|
|
from app.models import DownloadCredential, DownloadJob, MediaAsset, User
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _fernet(monkeypatch):
|
|
# A throwaway key so encrypt/decrypt work without TOKEN_ENCRYPTION_KEY in the env (mirrors
|
|
# test_security). Autouse: every credential write in this module encrypts.
|
|
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
|
|
|
|
|
|
COOKIES = "# Netscape HTTP Cookie File\n.facebook.com\tTRUE\t/\tTRUE\t0\tc_user\t123\n"
|
|
|
|
|
|
# --- host scope (pure logic) ------------------------------------------------------------------
|
|
|
|
|
|
class TestHostKeyForUrl:
|
|
def test_matches_a_supported_host(self):
|
|
assert credentials.host_key_for_url("https://www.facebook.com/watch?v=1") == "facebook"
|
|
assert credentials.host_key_for_url("https://fb.watch/abc/") == "facebook"
|
|
assert credentials.host_key_for_url("https://x.com/i/status/1") == "twitter"
|
|
# YouTube gates individual videos behind its bot check; the cookie is the only way past it.
|
|
for url in (
|
|
"https://www.youtube.com/watch?v=abc",
|
|
"https://youtu.be/abc",
|
|
"https://music.youtube.com/watch?v=abc",
|
|
"https://www.youtube-nocookie.com/embed/abc",
|
|
):
|
|
assert credentials.host_key_for_url(url) == "youtube", url
|
|
|
|
def test_look_alike_host_never_matches(self):
|
|
assert credentials.host_key_for_url("https://myfacebook.com/x") is None
|
|
assert credentials.host_key_for_url("https://facebook.com.evil.test/x") is None
|
|
assert credentials.host_key_for_url("https://notyoutube.com/watch?v=abc") is None
|
|
assert credentials.host_key_for_url("https://youtube.com.evil.test/x") is None
|
|
|
|
def test_unrelated_or_empty_url_has_no_host_key(self):
|
|
assert credentials.host_key_for_url("https://vimeo.com/123456") is None
|
|
assert credentials.host_key_for_url("") is None
|
|
assert credentials.host_key_for_url(None) is None
|
|
|
|
|
|
class TestValidateCookies:
|
|
def test_accepts_a_netscape_file(self):
|
|
assert credentials.validate_cookies(COOKIES).endswith("\n")
|
|
|
|
def test_rejects_empty(self):
|
|
with pytest.raises(credentials.InvalidCookies):
|
|
credentials.validate_cookies(" ")
|
|
|
|
def test_rejects_non_cookie_paste(self):
|
|
# Comments only, no tab-separated data line -> not a usable cookies.txt.
|
|
with pytest.raises(credentials.InvalidCookies):
|
|
credentials.validate_cookies("just some pasted text\n# a comment")
|
|
|
|
|
|
def test_authed_sig_differs_per_user_and_from_the_shared_sig():
|
|
spec = formats.normalize({})
|
|
shared = formats.format_sig(spec)
|
|
a = credentials.authed_format_sig(spec, 1, "facebook")
|
|
b = credentials.authed_format_sig(spec, 2, "facebook")
|
|
assert a != shared and b != shared and a != b
|
|
|
|
|
|
def test_an_authed_asset_queued_before_a_signature_version_bump_still_gets_its_cookie(monkeypatch):
|
|
# A job keeps the asset it was queued with. A deploy that bumps the signature version between the
|
|
# queueing and the download used to make the user's private asset stop counting as theirs, so the
|
|
# download went without its cookie and failed on the login wall.
|
|
spec = formats.normalize({"mode": "a"})
|
|
queued = MediaAsset(format_sig=credentials.authed_format_sig(spec, 1, "facebook"))
|
|
rebuild = credentials.rebuild_salt(7, credentials.cookie_salt(1, "facebook"))
|
|
rebuilt = MediaAsset(id=7, format_sig=formats.format_sig(spec, salt=rebuild))
|
|
monkeypatch.setattr(formats, "_AUDIO_SIG_VERSION", formats._AUDIO_SIG_VERSION + 1) # the deploy
|
|
|
|
for asset in (queued, rebuilt):
|
|
assert credentials.is_authed_asset(asset, spec, 1, FB_URL)
|
|
assert not credentials.is_authed_asset(asset, spec, 2, FB_URL) # still private to its user
|
|
|
|
|
|
def test_write_cookiefile_is_private_and_survives_the_retry_wipe(tmp_path):
|
|
staging = tmp_path / "staging"
|
|
staging.mkdir()
|
|
path = credentials._write_cookiefile(COOKIES, staging)
|
|
assert path.read_text() == COOKIES
|
|
# 0600 (owner-only) — POSIX perms only (the worker is Linux-only; Windows has no mode bits).
|
|
if os.name == "posix":
|
|
assert (path.stat().st_mode & 0o777) == 0o600
|
|
# It lives in a SUBDIR so the download retry loop (which unlinks files in the staging ROOT) and
|
|
# _produced_file (which scans the root) never see it.
|
|
assert path.parent == staging / ".auth"
|
|
assert not any(p.is_file() for p in staging.iterdir())
|
|
|
|
|
|
# --- CRUD + encryption at rest (DB) -----------------------------------------------------------
|
|
|
|
|
|
def _user(db, email="cred@example.com") -> User:
|
|
u = User(email=email)
|
|
db.add(u)
|
|
db.flush()
|
|
return u
|
|
|
|
|
|
class TestCrud:
|
|
def test_upsert_stores_ciphertext_not_plaintext(self, db):
|
|
u = _user(db)
|
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
|
db.commit()
|
|
row = db.get(DownloadCredential, 1)
|
|
assert row.cookies_enc != COOKIES # encrypted at rest
|
|
assert COOKIES not in row.cookies_enc
|
|
assert security.decrypt(row.cookies_enc) == COOKIES
|
|
|
|
def test_upsert_replaces_in_place(self, db):
|
|
u = _user(db)
|
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
|
db.commit()
|
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES.replace("123", "999"))
|
|
db.commit()
|
|
rows = db.query(DownloadCredential).filter_by(user_id=u.id, host_key="facebook").all()
|
|
assert len(rows) == 1
|
|
assert "999" in security.decrypt(rows[0].cookies_enc)
|
|
|
|
def test_status_list_never_leaks_the_blob(self, db):
|
|
u = _user(db)
|
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
|
db.commit()
|
|
status = credentials.status_list(db, u.id)
|
|
fb = next(h for h in status if h["host_key"] == "facebook")
|
|
assert fb["configured"] is True and fb["updated_at"] is not None
|
|
ig = next(h for h in status if h["host_key"] == "instagram")
|
|
assert ig["configured"] is False and ig["updated_at"] is None
|
|
# No field anywhere carries the cookie value.
|
|
assert all(COOKIES not in str(v) for h in status for v in h.values())
|
|
|
|
def test_delete(self, db):
|
|
u = _user(db)
|
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
|
db.commit()
|
|
assert credentials.delete_credential(db, u.id, "facebook") is True
|
|
db.commit()
|
|
assert credentials.get_credential(db, u.id, "facebook") is None
|
|
assert credentials.delete_credential(db, u.id, "facebook") is False
|
|
|
|
|
|
# --- the crux: enqueue salts privately, no cross-user cache serve (DB) -------------------------
|
|
|
|
FB_URL = "https://www.facebook.com/watch?v=100"
|
|
|
|
|
|
class TestCachePrivacy:
|
|
def test_enqueue_with_a_cookie_creates_a_private_salted_asset(self, db):
|
|
u = _user(db)
|
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
|
db.commit()
|
|
job = service.enqueue(db, u.id, "url", FB_URL, {})
|
|
asset = db.get(MediaAsset, job.asset_id)
|
|
spec = formats.normalize({})
|
|
assert asset.format_sig == credentials.authed_format_sig(spec, u.id, "facebook")
|
|
assert asset.format_sig != formats.format_sig(spec)
|
|
|
|
def test_enqueue_without_a_cookie_uses_the_shared_sig(self, db):
|
|
u = _user(db)
|
|
job = service.enqueue(db, u.id, "url", FB_URL, {})
|
|
asset = db.get(MediaAsset, job.asset_id)
|
|
assert asset.format_sig == formats.format_sig(formats.normalize({}))
|
|
|
|
def test_a_youtube_job_reaches_the_gate_through_its_rebuilt_watch_url(self, db):
|
|
# The one join nothing else makes. A YouTube job stores a bare 11-char ref, and the URL the
|
|
# cookie gate keys on is REBUILT by the adapter (service.source_url -> YouTubeAdapter). Both
|
|
# ends are pinned apart — the watch-URL shape in test_adapters, the host_key in
|
|
# TestHostKeyForUrl — so this welds them: an enqueue that ever handed the gate the raw ref
|
|
# would silently stop salting, every YouTube cookie would quietly stop being attached, and
|
|
# signed-out downloads would keep working, so nothing else here would go red.
|
|
u = _user(db)
|
|
credentials.upsert_credential(db, u.id, "youtube", COOKIES)
|
|
db.commit()
|
|
job = service.enqueue(db, u.id, "youtube", "VIDID12345_", {})
|
|
asset = db.get(MediaAsset, job.asset_id)
|
|
spec = formats.normalize({})
|
|
assert asset.format_sig == credentials.authed_format_sig(spec, u.id, "youtube")
|
|
assert asset.format_sig != formats.format_sig(spec)
|
|
|
|
def test_two_users_never_share_the_authed_asset(self, db):
|
|
a = _user(db, "a@example.com")
|
|
b = _user(db, "b@example.com")
|
|
credentials.upsert_credential(db, a.id, "facebook", COOKIES)
|
|
db.commit()
|
|
ja = service.enqueue(db, a.id, "url", FB_URL, {}) # A: authed, private
|
|
jb = service.enqueue(db, b.id, "url", FB_URL, {}) # B: no cookie, shared
|
|
assert ja.asset_id != jb.asset_id
|
|
# B's asset is the shared identity — A's private group video can never be served to B.
|
|
assert db.get(MediaAsset, jb.asset_id).format_sig == formats.format_sig(
|
|
formats.normalize({})
|
|
)
|
|
|
|
|
|
# --- the worker gate: attach only to the private identity, host-scoped (DB) --------------------
|
|
|
|
|
|
class TestResolveCookiefile:
|
|
def _asset_sig(self, sig: str) -> MediaAsset:
|
|
return MediaAsset(
|
|
source_kind="url", source_ref=FB_URL, format_sig=sig, status="downloading"
|
|
)
|
|
|
|
def test_attaches_to_the_private_salted_asset(self, db, tmp_path):
|
|
u = _user(db)
|
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
|
db.commit()
|
|
spec = formats.normalize({})
|
|
salted = credentials.authed_format_sig(spec, u.id, "facebook")
|
|
path = credentials.resolve_cookiefile(
|
|
db, u.id, FB_URL, self._asset_sig(salted), spec, tmp_path
|
|
)
|
|
assert path is not None and Path(path).read_text() == COOKIES
|
|
|
|
def test_never_attaches_to_a_shared_asset(self, db, tmp_path):
|
|
# Cookie exists, but the asset is the UNSALTED shared identity -> attaching would poison the
|
|
# global cache with a private-group video. Must return None.
|
|
u = _user(db)
|
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
|
db.commit()
|
|
spec = formats.normalize({})
|
|
shared = formats.format_sig(spec)
|
|
assert (
|
|
credentials.resolve_cookiefile(
|
|
db, u.id, FB_URL, self._asset_sig(shared), spec, tmp_path
|
|
)
|
|
is None
|
|
)
|
|
|
|
def test_non_matching_host_gets_no_cookie(self, db, tmp_path):
|
|
u = _user(db)
|
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
|
db.commit()
|
|
spec = formats.normalize({})
|
|
# A URL of ANOTHER host (supported or not) never gets this credential's cookie. YouTube is
|
|
# a supported host in its own right now, so the case needs a host that is not.
|
|
other = "https://vimeo.com/123456"
|
|
salted = credentials.authed_format_sig(spec, u.id, "facebook")
|
|
assert (
|
|
credentials.resolve_cookiefile(db, u.id, other, self._asset_sig(salted), spec, tmp_path)
|
|
is None
|
|
)
|
|
# ...and a YouTube link gets nothing either: the stored credential is Facebook's.
|
|
yt = "https://www.youtube.com/watch?v=abc"
|
|
assert (
|
|
credentials.resolve_cookiefile(db, u.id, yt, self._asset_sig(salted), spec, tmp_path)
|
|
is None
|
|
)
|
|
|
|
def test_no_credential_gets_no_cookie(self, db, tmp_path):
|
|
u = _user(db)
|
|
spec = formats.normalize({})
|
|
salted = credentials.authed_format_sig(spec, u.id, "facebook")
|
|
assert (
|
|
credentials.resolve_cookiefile(
|
|
db, u.id, FB_URL, self._asset_sig(salted), spec, tmp_path
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
# --- Retry / Re-download take a cookie added later (DB) -----------------------------------------
|
|
# Measured on dev (E11.5): a Facebook reel failed on the login wall, the user added their cookie as
|
|
# the failure copy tells them to, pressed Retry — and it failed again, because the job still held the
|
|
# shared asset the worker never attaches a cookie to. Re-download forked a salt the gate could not
|
|
# recognise, so even a job downloaded WITH a cookie rebuilt without it.
|
|
|
|
|
|
def _held_job(db, u, *, sig, asset_status="error", job_status="error"):
|
|
a = MediaAsset(
|
|
source_kind="url", source_ref=FB_URL, format_sig=sig, status=asset_status, ref_count=1
|
|
)
|
|
db.add(a)
|
|
db.flush()
|
|
j = DownloadJob(
|
|
user_id=u.id,
|
|
asset_id=a.id,
|
|
source_kind="url",
|
|
source_ref=FB_URL,
|
|
status=job_status,
|
|
queue_pos=0,
|
|
profile_snapshot={},
|
|
)
|
|
db.add(j)
|
|
db.commit()
|
|
return a, j
|
|
|
|
|
|
class TestRequeueTakesTheCookie:
|
|
spec = formats.normalize({})
|
|
|
|
@pytest.mark.parametrize(
|
|
("asset_status", "job_status"),
|
|
# A failed download; and a job paused before it ever started (its asset never downloaded).
|
|
[("error", "error"), ("pending", "paused")],
|
|
)
|
|
def test_retry_after_adding_a_cookie_moves_the_job_to_the_private_asset(
|
|
self, db, tmp_path, asset_status, job_status
|
|
):
|
|
u = _user(db)
|
|
shared, j = _held_job(
|
|
db,
|
|
u,
|
|
sig=formats.format_sig(self.spec),
|
|
asset_status=asset_status,
|
|
job_status=job_status,
|
|
)
|
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
|
db.commit()
|
|
|
|
service.resume(db, j)
|
|
|
|
db.expire_all()
|
|
job = db.get(DownloadJob, j.id)
|
|
target = db.get(MediaAsset, job.asset_id)
|
|
assert job.status == "queued" and target.id != shared.id
|
|
assert target.format_sig == credentials.authed_format_sig(self.spec, u.id, "facebook")
|
|
assert (target.status, target.ref_count) == ("pending", 1)
|
|
assert db.get(MediaAsset, shared.id).ref_count == 0 # the old hold went with the move
|
|
assert credentials.resolve_cookiefile(db, u.id, FB_URL, target, self.spec, tmp_path)
|
|
|
|
def test_retry_without_a_cookie_keeps_the_shared_asset(self, db):
|
|
u = _user(db)
|
|
shared, j = _held_job(db, u, sig=formats.format_sig(self.spec))
|
|
|
|
service.resume(db, j)
|
|
|
|
db.expire_all()
|
|
assert db.get(DownloadJob, j.id).asset_id == shared.id
|
|
assert db.get(MediaAsset, shared.id).ref_count == 1
|
|
|
|
@pytest.mark.parametrize(
|
|
("asset_status", "job_status"), [("ready", "error"), ("downloading", "paused")]
|
|
)
|
|
def test_retry_never_moves_off_a_file_or_a_download_in_flight(
|
|
self, db, asset_status, job_status
|
|
):
|
|
u = _user(db)
|
|
shared, j = _held_job(
|
|
db,
|
|
u,
|
|
sig=formats.format_sig(self.spec),
|
|
asset_status=asset_status,
|
|
job_status=job_status,
|
|
)
|
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
|
db.commit()
|
|
|
|
service.resume(db, j)
|
|
|
|
db.expire_all()
|
|
assert db.get(DownloadJob, j.id).asset_id == shared.id
|
|
|
|
def test_redownload_with_a_cookie_rebuilds_into_an_asset_the_cookie_goes_with(
|
|
self, db, tmp_path
|
|
):
|
|
u = _user(db)
|
|
other = _user(db, "other@example.com")
|
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
|
credentials.upsert_credential(db, other.id, "facebook", COOKIES)
|
|
authed = credentials.authed_format_sig(self.spec, u.id, "facebook")
|
|
first, j = _held_job(db, u, sig=authed, asset_status="ready", job_status="done")
|
|
|
|
seen = {first.id}
|
|
for _ in range(2): # a rebuild of a rebuild is still recognised
|
|
service.redownload(db, j)
|
|
db.expire_all()
|
|
job = db.get(DownloadJob, j.id)
|
|
target = db.get(MediaAsset, job.asset_id)
|
|
assert target.id not in seen and target.format_sig != authed
|
|
seen.add(target.id)
|
|
assert credentials.resolve_cookiefile(db, u.id, FB_URL, target, self.spec, tmp_path)
|
|
# Private: another user's cookie is never attached to it.
|
|
assert not credentials.is_authed_asset(target, self.spec, other.id, FB_URL)
|
|
|
|
# A Retry of a failed rebuild keeps the rebuild — it is already the cookie's identity.
|
|
target.status, job.status = "error", "error"
|
|
db.commit()
|
|
service.resume(db, job)
|
|
db.expire_all()
|
|
assert db.get(DownloadJob, j.id).asset_id == target.id
|
|
|
|
def test_a_rebuild_made_without_a_cookie_is_no_cookie_identity(self, db, tmp_path):
|
|
u = _user(db)
|
|
_, j = _held_job(db, u, sig=formats.format_sig(self.spec))
|
|
service.redownload(db, j)
|
|
credentials.upsert_credential(db, u.id, "facebook", COOKIES)
|
|
db.commit()
|
|
|
|
db.expire_all()
|
|
target = db.get(MediaAsset, db.get(DownloadJob, j.id).asset_id)
|
|
assert not credentials.is_authed_asset(target, self.spec, u.id, FB_URL)
|
|
|
|
|
|
# --- API: write-only, never echoes the blob (DB) ----------------------------------------------
|
|
|
|
|
|
class TestApi:
|
|
def test_put_get_delete_roundtrip_never_returns_the_blob(self, db, as_user):
|
|
u = _user(db)
|
|
db.commit()
|
|
client = as_user(u)
|
|
|
|
r = client.put("/api/downloads/credentials/facebook", json={"cookies": COOKIES})
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["ok"] is True
|
|
assert COOKIES not in r.text # the response must never carry the value
|
|
fb = next(h for h in body["hosts"] if h["host_key"] == "facebook")
|
|
assert fb["configured"] is True
|
|
|
|
r = client.get("/api/downloads/credentials")
|
|
assert r.status_code == 200 and COOKIES not in r.text
|
|
|
|
r = client.delete("/api/downloads/credentials/facebook")
|
|
assert r.status_code == 200
|
|
assert credentials.get_credential(db, u.id, "facebook") is None
|
|
|
|
def test_unknown_host_is_404(self, db, as_user):
|
|
u = _user(db)
|
|
db.commit()
|
|
r = as_user(u).put("/api/downloads/credentials/nope", json={"cookies": COOKIES})
|
|
assert r.status_code == 404
|
|
|
|
def test_bad_cookies_are_rejected(self, db, as_user):
|
|
u = _user(db)
|
|
db.commit()
|
|
r = as_user(u).put("/api/downloads/credentials/facebook", json={"cookies": " "})
|
|
assert r.status_code == 400
|
|
assert r.json()["detail"]["code"] == "cookies"
|