Files
siftlode/backend/tests/test_public_unlock_limit.py
T
peter f5018bdcbc fix(uploads): read the body after authenticating, not before it
`POST /api/downloads/upload` declared `file: UploadFile = File(...)`, and
FastAPI parses and spools a declared body before the first dependency runs --
`require_human` included. An unauthenticated caller could therefore make the
process write the whole per-file allowance (500 MiB by default) to temp, once
per request. SEC-20's ceiling bounded that number but could not stop the
writing, which is why E27 parked it rather than closing it.

The route now takes no body parameter. `spool_upload` names `require_human`
first and only then streams `request.stream()` to the staging temp file, so a
stranger is refused before a byte moves -- the shape the screenshot route has
used since E16. Filename and display name travel as percent-encoded headers
(latin-1 on the wire, so a Hungarian filename needs the encoding) instead of
multipart parts.

`capped_stream_to` joins `capped_body` in routes/_common.py: the same counting
rule, written to a file descriptor rather than accumulated, because holding
500 MiB would trade a disk problem for a memory one. Its refusal is the
caller's, so the upload route keeps answering 422 `upload_too_large` with the
admin knob's own number.

With no envelope to allow for, the edge is now exactly that knob, and
MULTIPART_ENVELOPE_ALLOWANCE is gone.

The invariant stops being prose: test_body_limit.py asks FastAPI itself
(`route.dependant.body_params`) and fails the gate if any route with a raised
ceiling declares a body. A comment could not have prevented the defect it
described; that test would have.

Both new tests were seen red for the right reason -- before the fix the
anonymous probe got its 401 only after the server had read 4236 bytes of a
multipart body, and re-declaring the File parameter turns the invariant test
red naming the route and the parameter.
2026-09-22 01:59:03 +02:00

81 lines
3.5 KiB
Python

"""The password unlock of a shared /watch link (routes/public.py), DB-lane.
Guesses are slowed per viewer (the link AND the client — an IPv6 client by its /64), under a per-link
ceiling on wrong passwords, so a client guessing wrong shuts out only itself until it spreads its guesses
over as many networks as the ceiling allows.
"""
import pytest
from fastapi.testclient import TestClient
from app.config import settings
from app.downloads import probe as probemod
from app.main import app
from app.ratelimit import RateLimiter
from app.routes import public
_PW = "open sesame"
@pytest.fixture
def unlock_url(as_user, make_user, tmp_path, monkeypatch, upload_headers):
monkeypatch.setattr(settings, "download_root", str(tmp_path))
monkeypatch.setattr(probemod, "probe_media", lambda _p: ("h264", "aac", 320, 240, 5))
owner = as_user(make_user())
job = owner.post(
"/api/downloads/upload", content=b"fake-video", headers=upload_headers("clip.mp4")
)
assert job.status_code == 200, job.text
link = owner.post(f"/api/downloads/{job.json()['id']}/links", json={"password": _PW})
assert link.status_code == 200, link.text
return f"/api/public/watch/{link.json()['token']}/unlock"
def _from(ip: str) -> TestClient:
# Its own socket peer: with no trusted proxy configured, that is the client's IP.
return TestClient(app, client=(ip, 50000))
def test_a_link_holder_without_the_password_cannot_lock_out_its_viewers(unlock_url):
# The limit used to be keyed on the link alone: ten wrong guesses every five minutes kept every
# viewer out, the ones with the password included.
guesser = _from("203.0.113.5")
for _ in range(10):
assert guesser.post(unlock_url, json={"password": "guess"}).status_code == 403
assert guesser.post(unlock_url, json={"password": "guess"}).status_code == 429
assert _from("198.51.100.7").post(unlock_url, json={"password": _PW}).status_code == 200
def test_an_ipv6_client_is_one_viewer_across_its_64(unlock_url):
# One subscriber gets a whole /64: keyed on the full address, it could rotate through endless viewers.
for _ in range(10):
wrong = _from("2001:db8:1:2::5").post(unlock_url, json={"password": "guess"})
assert wrong.status_code == 403
assert _from("2001:db8:1:2::6").post(unlock_url, json={"password": _PW}).status_code == 429
assert _from("2001:db8:1:3::5").post(unlock_url, json={"password": _PW}).status_code == 200
def test_a_token_naming_no_link_leaves_nothing_in_the_limiter(unlock_url):
# The limiter used to record the attempt before looking the link up: posting made-up tokens grew it by
# an entry each, kept for the whole window.
keys = len(public._unlock_limit._hits)
for n in range(5):
missing = _from("203.0.113.5").post(unlock_url.replace("/watch/", f"/watch/x{n}"), json={})
assert missing.status_code == 404
assert len(public._unlock_limit._hits) == keys
def test_wrong_passwords_from_many_clients_still_reach_the_links_ceiling(unlock_url, monkeypatch):
monkeypatch.setattr(public, "_unlock_failures", RateLimiter(max_events=3, window_seconds=300))
viewer = _from("198.51.100.7")
for _ in range(5): # unlocking with the password never adds to the ceiling
assert viewer.post(unlock_url, json={"password": _PW}).status_code == 200
for n in range(3):
wrong = _from(f"203.0.113.{n}").post(unlock_url, json={"password": "guess"})
assert wrong.status_code == 403
late = _from("203.0.113.9").post(unlock_url, json={"password": "guess"})
assert late.status_code == 429