diff --git a/data/unidentified_dismissed.json b/data/unidentified_dismissed.json index a4a56f6..e527a45 100644 --- a/data/unidentified_dismissed.json +++ b/data/unidentified_dismissed.json @@ -109,5 +109,6 @@ "IMG_4556.jpeg|Top row, green textured spine with cartoon dinosaur, left of 'Ravensbu...' spine||Green speckled/scaly texture spine with small cartoon dinosaur illustration", "IMG_4556.jpeg|Top row, second shelf, spine reading 'Ravensbu...' between an unidentified dark box and green dinosaur-patterned spine|Ravensbu...|White spine with blue text, likely Ravensburger logo/publisher rather than title, top cut off", "IMG_4566.jpeg|Top shelf, background, partially obscured behind and above the two Alice Is Missing boxes|TOWN...FUKU (possibly Japanese text)|Colorful box with cartoon character illustrations, appears to be a small/medium sized game box, mostly obscured by foreground items", - "IMG_4573.jpeg|Bottom right of cover art, near 'Blackboard' box||Small dark box with logo icon, title illegible due to size and angle" + "IMG_4573.jpeg|Bottom right of cover art, near 'Blackboard' box||Small dark box with logo icon, title illegible due to size and angle", + "IMG_4573.jpeg|Upper right area of the book cover art, stacked above 'ACTION CASTLE I' box|AC... (partially obscured by hand/fingers)|Red/orange box spine, appears to be part of a series with other 'Action Castle' related titles" ] diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py index 5a2a877..d91f336 100644 --- a/src/bggpipe/webreview.py +++ b/src/bggpipe/webreview.py @@ -330,11 +330,24 @@ def create_app( origin_host = urlsplit(origin).hostname if origin else None client_ip = request.client.host if request.client else "" loopback = client_ip.startswith("127.") or client_ip == "::1" + # /static/* is the app's own css/js/artwork — no user data. Safari's + # preload scanner fetches them before the document's Set-Cookie is + # committed, and browsers probe favicons cookie-less; gating them + # would break the first paint for nothing. + public = request.url.path.startswith("/static/") or request.url.path in ( + "/favicon.ico", + "/apple-touch-icon.png", + "/apple-touch-icon-precomposed.png", + ) # Same-machine browsing stays keyless under --lan: a network peer # cannot arrive with a loopback CLIENT address, and these requests # fall through to the localhost guard below, which still blocks # rebinding and cross-origin pages by their foreign Host/Origin. - if lan_token is not None and not (loopback and host in ALLOWED_HOSTS): + if ( + lan_token is not None + and not (loopback and host in ALLOWED_HOSTS) + and not public + ): # --lan has no login, so EVERY request — reads included: shelf # photos and pipeline state are private — needs the per-run # key from the printed URL; a cookie carries it afterwards. @@ -355,20 +368,26 @@ def create_app( }, status_code=403, ) + if request.method in ("GET", "HEAD") and request.query_params.get("k"): + # the key arrived in the typed URL: commit it to a cookie + # via redirect BEFORE the document loads (the preload + # scanner races Set-Cookie on a streaming document), and + # scrub the key from the address bar and history + response = Response( + status_code=303, + headers={"Location": request.url.path or "/"}, + ) + response.set_cookie( + LAN_COOKIE, lan_token, httponly=True, samesite="lax" + ) + return response if request.method not in ("GET", "HEAD", "OPTIONS") and ( origin_host is not None and origin_host != host ): return JSONResponse( {"detail": "cross-origin request refused"}, status_code=403 ) - response = await call_next(request) - if request.query_params.get("k"): - # the key came in the typed URL: hand it to the browser so - # navigation and fetches keep working without it - response.set_cookie( - LAN_COOKIE, lan_token, httponly=True, samesite="lax" - ) - return response + return await call_next(request) if request.method not in ("GET", "HEAD", "OPTIONS") and ( host not in ALLOWED_HOSTS or (origin_host is not None and origin_host not in ALLOWED_HOSTS) diff --git a/tests/test_webreview.py b/tests/test_webreview.py index c221ebf..ad621b2 100644 --- a/tests/test_webreview.py +++ b/tests/test_webreview.py @@ -919,9 +919,14 @@ def test_lan_token_gates_every_request(tmp_path): # reads are gated too: shelf photos and state are private assert phone.get("/api/state").status_code == 403 assert phone.get("/api/state?k=wrong").status_code == 403 - first = phone.get("/titles?k=sekret") - assert first.status_code == 200 - assert "bggpipe_key" in first.cookies # the URL key becomes a cookie + # the app's own assets are public: the preload scanner fetches them + # before the document's cookie commits + assert phone.get("/static/app.css").status_code == 200 + first = phone.get("/titles?k=sekret", follow_redirects=False) + assert first.status_code == 303 # cookie commits BEFORE the document + assert first.headers["location"] == "/titles" # key scrubbed from URL + assert "bggpipe_key" in first.cookies + assert phone.get("/titles").status_code == 200 # cookie carries it now # cookie carries the session: mutations work from ANY host the phone # used (no allowlist dependence — DHCP/multi-interface safe), with a # same-origin Origin header and a port, like a real phone browser