Skip to content

Commit 830902c

Browse files
authored
Merge pull request #3438 from Spinnich/fix/screenscraper-std-media-credentials
fix(screenscraper): inject user credentials for all standard media downloads
2 parents 6bc3d58 + f5b1d44 commit 830902c

7 files changed

Lines changed: 98 additions & 23 deletions

File tree

backend/endpoints/roms/__init__.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1282,7 +1282,7 @@ async def update_rom(
12821282
path_screenshots = await fs_resource_handler.get_rom_screenshots(
12831283
rom=rom,
12841284
overwrite=bool(screenshots_changed),
1285-
url_screenshots=cleaned_data.get("url_screenshots", []),
1285+
url_screenshots=[add_ss_auth_to_url(u) for u in url_screenshots],
12861286
)
12871287
cleaned_data.update(
12881288
{"path_screenshots": path_screenshots, "url_screenshots": []}
@@ -1353,7 +1353,7 @@ async def update_rom(
13531353
path_cover_s, path_cover_l = await fs_resource_handler.get_cover(
13541354
entity=rom,
13551355
overwrite=url_cover != rom.url_cover,
1356-
url_cover=str(url_cover),
1356+
url_cover=add_ss_auth_to_url(url_cover),
13571357
)
13581358
cleaned_data.update(
13591359
{
@@ -1373,7 +1373,7 @@ async def update_rom(
13731373
path_manual = await fs_resource_handler.get_manual(
13741374
rom=rom,
13751375
overwrite=url_manual != rom.url_manual,
1376-
url_manual=str(url_manual) if url_manual else None,
1376+
url_manual=add_ss_auth_to_url(url_manual),
13771377
)
13781378
cleaned_data.update(
13791379
{
@@ -1413,12 +1413,16 @@ async def update_rom(
14131413
media_type,
14141414
)
14151415

1416-
if cleaned_data.get("ss_metadata", {}).get(f"{media_type.value}_path"):
1416+
media_path = cleaned_data.get("ss_metadata", {}).get(
1417+
f"{media_type.value}_path"
1418+
)
1419+
media_url = cleaned_data.get("ss_metadata", {}).get(
1420+
f"{media_type.value}_url"
1421+
)
1422+
if media_path and media_url:
14171423
await fs_resource_handler.store_media_file(
1418-
add_ss_auth_to_url(
1419-
cleaned_data["ss_metadata"][f"{media_type.value}_url"]
1420-
),
1421-
cleaned_data["ss_metadata"][f"{media_type.value}_path"],
1424+
add_ss_auth_to_url(media_url),
1425+
media_path,
14221426
)
14231427

14241428
log.debug(

backend/endpoints/sockets/scan.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -396,22 +396,23 @@ async def _identify_rom(
396396
path_cover_s, path_cover_l = await fs_resource_handler.get_cover(
397397
entity=_added_rom,
398398
overwrite=_added_rom.url_cover != rom.url_cover,
399-
url_cover=_added_rom.url_cover,
399+
url_cover=add_ss_auth_to_url(_added_rom.url_cover),
400400
)
401401

402402
path_manual = await fs_resource_handler.get_manual(
403403
rom=_added_rom,
404404
overwrite=_added_rom.url_manual != rom.url_manual,
405-
url_manual=_added_rom.url_manual,
405+
url_manual=add_ss_auth_to_url(_added_rom.url_manual),
406406
)
407407

408408
screenshots_changed = pydash.xor(
409409
_added_rom.url_screenshots or [], rom.url_screenshots or []
410410
)
411+
url_screenshots = _added_rom.url_screenshots or []
411412
path_screenshots = await fs_resource_handler.get_rom_screenshots(
412413
rom=_added_rom,
413414
overwrite=bool(screenshots_changed),
414-
url_screenshots=_added_rom.url_screenshots,
415+
url_screenshots=[add_ss_auth_to_url(u) for u in url_screenshots],
415416
)
416417

417418
_added_rom.path_cover_s = path_cover_s
@@ -434,12 +435,12 @@ async def _identify_rom(
434435
if _added_rom.ss_metadata and MetadataSource.SS in metadata_sources:
435436
preferred_media_types = get_preferred_media_types()
436437
for media_type in preferred_media_types:
437-
if _added_rom.ss_metadata.get(f"{media_type.value}_path"):
438+
media_path = _added_rom.ss_metadata.get(f"{media_type.value}_path")
439+
media_url = _added_rom.ss_metadata.get(f"{media_type.value}_url")
440+
if media_path and media_url:
438441
await fs_resource_handler.store_media_file(
439-
add_ss_auth_to_url(
440-
_added_rom.ss_metadata[f"{media_type.value}_url"]
441-
),
442-
_added_rom.ss_metadata[f"{media_type.value}_path"],
442+
add_ss_auth_to_url(media_url),
443+
media_path,
443444
)
444445

445446
# Handle special media files from ES-DE gamelist.xml

backend/handler/metadata/ss_handler.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import re
33
from datetime import datetime
44
from typing import Final, NotRequired, TypedDict
5-
from urllib.parse import quote
5+
from urllib.parse import quote, urlparse
66

77
import pydash
88
from unidecode import unidecode as uc
@@ -34,8 +34,34 @@
3434
SENSITIVE_KEYS = {"ssid", "sspassword"}
3535

3636

37-
def add_ss_auth_to_url(url: str) -> str:
38-
"""Re-add SS user credentials to a media URL at download time (never stored)."""
37+
def _is_screenscraper_host(url: str) -> bool:
38+
"""True only if the URL's hostname is screenscraper.fr or a subdomain.
39+
40+
Substring matching would let an attacker-controlled host like
41+
screenscraper.fr.evil.example receive the user's credentials.
42+
"""
43+
try:
44+
host = urlparse(url).hostname
45+
except ValueError:
46+
return False
47+
48+
if not host:
49+
return False
50+
51+
return host.lower() == "screenscraper.fr" or host.lower().endswith(
52+
".screenscraper.fr"
53+
)
54+
55+
56+
def add_ss_auth_to_url(url: str | None) -> str:
57+
"""Re-add SS user credentials to a media URL at download time (never stored).
58+
59+
Only injects credentials for screenscraper.fr URLs; returns other URLs
60+
unchanged to avoid leaking credentials to third-party sources.
61+
"""
62+
if not url or not _is_screenscraper_host(url):
63+
return url or ""
64+
3965
if not SCREENSCRAPER_USER or not SCREENSCRAPER_PASSWORD:
4066
return url
4167

backend/handler/scan_handler.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1045,7 +1045,7 @@ async def fetch_sgdb_details(playmatch_rom: PlaymatchRomMatch) -> SGDBRom:
10451045
extra=LOGGER_MODULE_NAME,
10461046
)
10471047

1048-
if rom.has_nested_single_file or rom.has_multiple_files:
1048+
if fs_rom["nested"]:
10491049
for file in fs_rom["files"]:
10501050
log.info(
10511051
f"\t · {hl(file.file_name, color=LIGHTYELLOW)}",

backend/tests/handler/metadata/test_ss_handler.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,50 @@ def test_handles_stripped_url_from_extract_media(self):
397397
assert query.get("devpassword") == ["devpw"]
398398
assert query.get("other") == ["keep"]
399399

400+
def test_rejects_lookalike_and_attacker_hosts(self):
401+
"""Credentials must only be injected when the hostname is exactly
402+
screenscraper.fr or a subdomain. A substring match would leak creds
403+
to attacker-controlled domains."""
404+
hostile_urls = [
405+
# Suffix attack: hostname ends with attacker-controlled domain
406+
"https://screenscraper.fr.evil.example/img.png",
407+
# Substring in path/query of unrelated host
408+
"https://evil.example/?u=screenscraper.fr",
409+
"https://evil.example/screenscraper.fr/img.png",
410+
# Credentials in userinfo pointing at attacker host
411+
"https://screenscraper.fr@evil.example/img.png",
412+
# Prefix attack
413+
"https://notscreenscraper.fr/img.png",
414+
]
415+
with (
416+
patch("handler.metadata.ss_handler.SCREENSCRAPER_USER", "user1"),
417+
patch("handler.metadata.ss_handler.SCREENSCRAPER_PASSWORD", "pw1"),
418+
):
419+
for url in hostile_urls:
420+
result = add_ss_auth_to_url(url)
421+
assert result == url, f"Credentials leaked to {url!r}"
422+
assert "ssid" not in parse_qs(urlparse(result).query)
423+
assert "sspassword" not in parse_qs(urlparse(result).query)
424+
425+
def test_accepts_screenscraper_subdomains(self):
426+
"""Subdomains of screenscraper.fr (e.g. api.screenscraper.fr) are
427+
treated as the same trust boundary and receive credentials."""
428+
urls = [
429+
"https://screenscraper.fr/img.png",
430+
"https://api.screenscraper.fr/api2/foo",
431+
"https://www.screenscraper.fr/img.png",
432+
"https://SCREENSCRAPER.FR/img.png", # case-insensitive host
433+
]
434+
with (
435+
patch("handler.metadata.ss_handler.SCREENSCRAPER_USER", "user1"),
436+
patch("handler.metadata.ss_handler.SCREENSCRAPER_PASSWORD", "pw1"),
437+
):
438+
for url in urls:
439+
result = add_ss_auth_to_url(url)
440+
query = parse_qs(urlparse(result).query)
441+
assert query.get("ssid") == ["user1"], f"Creds missing on {url!r}"
442+
assert query.get("sspassword") == ["pw1"], f"Creds missing on {url!r}"
443+
400444
def test_strip_then_reauth_roundtrip(self):
401445
"""End-to-end: storing media strips user creds; download-time auth
402446
restores them without leaking creds into intermediate state."""

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ DEP002 = [ # DEP002 rule: Project should not contain unused dependencies
136136
[tool.uv]
137137
package = false
138138
exclude-newer = "7 days"
139+
exclude-newer-package = { starlette = "2026-05-22" }
139140

140141
[tool.ty.environment]
141142
root = ["./backend"]

uv.lock

Lines changed: 2 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)