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)
def resolve_media_path( profile: jmri_core.persistence.Profile, raw: str | None) -> pathlib._local.Path | None:
 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.

def media_url_for(entry: jmri_core.beans.RosterEntry, kind: str) -> str:
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".

def media_content_type(path: pathlib._local.Path) -> str:
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.