jmri_server.roster_media
Roster media resolver.
~jmri_core.beans.roster_entry.RosterEntry carries two path
fields — image_file_path (large photo) and icon_file_path
(thumbnail). Both may be absolute or relative to a base directory
under the active profile. This module resolves them to an on-disk
pathlib.Path, rejecting anything that would escape the
profile root (a scripted CV file pointing at ../../etc/passwd
should not become a media URL).
The actual HTTP serving lives in jmri_server.routes — this
module only does the path resolution + safety check. Kept separate so
the resolver has tests independent of the FastAPI test client.
1"""Roster media resolver. 2 3:class:`~jmri_core.beans.roster_entry.RosterEntry` carries two path 4fields — ``image_file_path`` (large photo) and ``icon_file_path`` 5(thumbnail). Both may be absolute or relative to a base directory 6under the active profile. This module resolves them to an on-disk 7:class:`pathlib.Path`, rejecting anything that would escape the 8profile root (a scripted CV file pointing at ``../../etc/passwd`` 9should not become a media URL). 10 11The actual HTTP serving lives in :mod:`jmri_server.routes` — this 12module only does the path resolution + safety check. Kept separate so 13the resolver has tests independent of the FastAPI test client. 14""" 15from __future__ import annotations 16 17import mimetypes 18import pathlib 19 20from jmri_core.beans.roster_entry import RosterEntry 21from jmri_core.persistence.profile import Profile 22 23 24def _guess_type(path: pathlib.Path) -> str: 25 """Best-effort content-type for the served file. Falls back to 26 ``application/octet-stream`` for unrecognized extensions — safer 27 than lying about the mime and letting the browser sniff.""" 28 guess, _ = mimetypes.guess_type(str(path)) 29 return guess or "application/octet-stream" 30 31 32def _candidate_bases(profile: Profile) -> list[pathlib.Path]: 33 """Directories a relative roster path may be resolved against. 34 35 JMRI's Java writer stores paths relative to the profile's 36 ``resources/`` dir (photos) or the profile root itself (older 37 layouts). We check both so pre-existing rosters imported from a 38 real JMRI install work without editing paths. 39 """ 40 return [ 41 profile.profile_dir / "resources", 42 profile.profile_dir, 43 profile.root, 44 ] 45 46 47def _under(root: pathlib.Path, candidate: pathlib.Path) -> bool: 48 """True if *candidate* is contained under *root*. Both must be 49 resolved to real absolute paths first — otherwise a symlink or 50 ``..`` component could sneak past the string prefix check.""" 51 try: 52 candidate.relative_to(root) 53 return True 54 except ValueError: 55 return False 56 57 58def resolve_media_path( 59 profile: Profile, 60 raw: str | None, 61) -> pathlib.Path | None: 62 """Turn a stored ``image_file_path`` / ``icon_file_path`` into a 63 real on-disk file, or ``None`` if the string is empty, points 64 somewhere that doesn't exist, or would escape the profile. 65 66 Absolute paths pass through untouched (after existence and 67 containment checks). Relative paths try each candidate base in 68 order; the first match wins. 69 70 The containment check is the security-relevant bit: any resolved 71 path that isn't under the profile root — or, for absolute paths, 72 isn't a normal file — is rejected as ``None``. That prevents a 73 hostile roster XML from pointing ``imageFilePath`` at 74 ``/etc/passwd`` and having the server serve it. 75 """ 76 if not raw: 77 return None 78 raw_str = str(raw).strip() 79 if not raw_str: 80 return None 81 path = pathlib.Path(raw_str).expanduser() 82 83 if path.is_absolute(): 84 try: 85 resolved = path.resolve(strict=False) 86 except OSError: 87 return None 88 if not resolved.is_file(): 89 return None 90 try: 91 profile_root = profile.root.resolve() 92 except OSError: 93 return None 94 if not _under(profile_root, resolved): 95 # Absolute paths that escape the profile are refused — 96 # rosters exported from a foreign JMRI install may carry 97 # arbitrary absolute paths; we won't serve them. 98 return None 99 return resolved 100 101 for base in _candidate_bases(profile): 102 try: 103 base_resolved = base.resolve() 104 except OSError: 105 continue 106 try: 107 candidate = (base_resolved / path).resolve(strict=False) 108 except OSError: 109 continue 110 if not _under(base_resolved, candidate): 111 # ``../../foo`` climbed past the base — skip; try the 112 # next base rather than trusting this resolution. 113 continue 114 if candidate.is_file(): 115 return candidate 116 return None 117 118 119def media_url_for(entry: RosterEntry, kind: str) -> str: 120 """Build the HTTP path a client fetches to get this piece of media. 121 122 Callers should have already confirmed the file exists via 123 :func:`resolve_media_path` — this helper just constructs the 124 URL. ``kind`` must be ``"image"`` or ``"icon"``. 125 """ 126 if kind not in ("image", "icon"): 127 raise ValueError(f"unknown roster media kind: {kind!r}") 128 return f"/roster/{entry.id}/{kind}" 129 130 131def media_content_type(path: pathlib.Path) -> str: 132 """Publicly exposed content-type guesser for the media route.""" 133 return _guess_type(path)
59def resolve_media_path( 60 profile: Profile, 61 raw: str | None, 62) -> pathlib.Path | None: 63 """Turn a stored ``image_file_path`` / ``icon_file_path`` into a 64 real on-disk file, or ``None`` if the string is empty, points 65 somewhere that doesn't exist, or would escape the profile. 66 67 Absolute paths pass through untouched (after existence and 68 containment checks). Relative paths try each candidate base in 69 order; the first match wins. 70 71 The containment check is the security-relevant bit: any resolved 72 path that isn't under the profile root — or, for absolute paths, 73 isn't a normal file — is rejected as ``None``. That prevents a 74 hostile roster XML from pointing ``imageFilePath`` at 75 ``/etc/passwd`` and having the server serve it. 76 """ 77 if not raw: 78 return None 79 raw_str = str(raw).strip() 80 if not raw_str: 81 return None 82 path = pathlib.Path(raw_str).expanduser() 83 84 if path.is_absolute(): 85 try: 86 resolved = path.resolve(strict=False) 87 except OSError: 88 return None 89 if not resolved.is_file(): 90 return None 91 try: 92 profile_root = profile.root.resolve() 93 except OSError: 94 return None 95 if not _under(profile_root, resolved): 96 # Absolute paths that escape the profile are refused — 97 # rosters exported from a foreign JMRI install may carry 98 # arbitrary absolute paths; we won't serve them. 99 return None 100 return resolved 101 102 for base in _candidate_bases(profile): 103 try: 104 base_resolved = base.resolve() 105 except OSError: 106 continue 107 try: 108 candidate = (base_resolved / path).resolve(strict=False) 109 except OSError: 110 continue 111 if not _under(base_resolved, candidate): 112 # ``../../foo`` climbed past the base — skip; try the 113 # next base rather than trusting this resolution. 114 continue 115 if candidate.is_file(): 116 return candidate 117 return None
Turn a stored image_file_path / icon_file_path into a
real on-disk file, or None if the string is empty, points
somewhere that doesn't exist, or would escape the profile.
Absolute paths pass through untouched (after existence and containment checks). Relative paths try each candidate base in order; the first match wins.
The containment check is the security-relevant bit: any resolved
path that isn't under the profile root — or, for absolute paths,
isn't a normal file — is rejected as None. That prevents a
hostile roster XML from pointing imageFilePath at
/etc/passwd and having the server serve it.
120def media_url_for(entry: RosterEntry, kind: str) -> str: 121 """Build the HTTP path a client fetches to get this piece of media. 122 123 Callers should have already confirmed the file exists via 124 :func:`resolve_media_path` — this helper just constructs the 125 URL. ``kind`` must be ``"image"`` or ``"icon"``. 126 """ 127 if kind not in ("image", "icon"): 128 raise ValueError(f"unknown roster media kind: {kind!r}") 129 return f"/roster/{entry.id}/{kind}"
Build the HTTP path a client fetches to get this piece of media.
Callers should have already confirmed the file exists via
resolve_media_path() — this helper just constructs the
URL. kind must be "image" or "icon".
132def media_content_type(path: pathlib.Path) -> str: 133 """Publicly exposed content-type guesser for the media route.""" 134 return _guess_type(path)
Publicly exposed content-type guesser for the media route.