From 04762f5dd1acb06363eb8119f4212aeddad84bf1 Mon Sep 17 00:00:00 2001 From: Daniel Maksymilian Syrnicki Date: Tue, 21 Apr 2026 11:39:15 +0200 Subject: [PATCH] feat(manifest): add 'path' setting type with server-side validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apps can now declare a setting with "type": "path" whose value is an absolute host filesystem path. Compose bind-mounts it via standard .env substitution (${MEDIA_PATH}:/media) — no reconciler changes needed. Unlocks media/data-heavy apps (Jellyfin, later Paperless, Nextcloud, Immich) that point at existing user data instead of copying it into a Docker volume. Install/update refuses values that aren't absolute, don't exist, aren't directories, or resolve into a system-path deny-list (/, /etc, /root, /boot, /proc, /sys, /dev, /bin, /sbin, /usr/bin, /usr/sbin, /var/lib/furtka). Path.resolve() is applied before the deny-list check so /mnt/../etc traversal is caught too. Error messages surface in the existing install/edit modal. UI: path settings render as a text input with a /mnt/… placeholder. The manifest's `description` field carries the actual hint ("Absoluter Pfad zu deinem Filme-Ordner, z.B. /mnt/media"). No new form components, no new API routes. Tests: 9 new cases for install + update path validation; 1 new case for manifest schema accepting the path type. 211 total passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 18 +++++++++ furtka/api.py | 4 +- furtka/installer.py | 75 +++++++++++++++++++++++++++++++++- furtka/manifest.py | 2 +- tests/test_installer.py | 90 +++++++++++++++++++++++++++++++++++++++++ tests/test_manifest.py | 21 ++++++++++ 6 files changed, 207 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96b03a4..9e77c4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r ## [Unreleased] +### Added + +- **New `path` setting type for app manifests.** Apps can now declare a + setting with `"type": "path"` whose value is an absolute filesystem + path on the host; docker-compose bind-mounts it via the usual `.env` + substitution (`${MEDIA_PATH}:/media`). Unlocks media/data-heavy apps + (Jellyfin, later Paperless/Nextcloud/Immich) where the user points at + an existing folder instead of copying everything into a Docker + volume. The install form renders path settings as a plain text input + with a `/mnt/…` placeholder hint. +- **Server-side path validation.** Both `install_from()` and + `update_env()` refuse values that aren't absolute, don't exist, + aren't directories, or resolve (after `Path.resolve()`) into a + system-path deny-list (`/`, `/etc`, `/root`, `/boot`, `/proc`, + `/sys`, `/dev`, `/bin`, `/sbin`, `/usr/bin`, `/usr/sbin`, + `/var/lib/furtka`). Catches `/mnt/../etc`-style traversal too. Error + messages surface in the existing install/edit modal error line. + ## [26.9-alpha] - 2026-04-21 ### Fixed diff --git a/furtka/api.py b/furtka/api.py index 5ccfb38..851cbda 100644 --- a/furtka/api.py +++ b/furtka/api.py @@ -173,7 +173,9 @@ async function openSettingsDialog(name, action) { modal.form.innerHTML = data.settings.map(s => { const id = `field-${esc(s.name)}`; const value = action === 'edit' && s.type === 'password' ? '' : esc(s.value || ''); - const placeholder = action === 'edit' && s.type === 'password' ? 'Leave blank to keep current' : ''; + const placeholder = action === 'edit' && s.type === 'password' ? 'Leave blank to keep current' + : s.type === 'path' ? '/mnt/…' + : ''; return `
diff --git a/furtka/installer.py b/furtka/installer.py index 95b1157..a269d1d 100644 --- a/furtka/installer.py +++ b/furtka/installer.py @@ -2,7 +2,7 @@ import shutil from pathlib import Path from furtka import sources -from furtka.manifest import ManifestError, load_manifest +from furtka.manifest import Manifest, ManifestError, load_manifest from furtka.paths import apps_dir # Values that an app's .env.example may use as obvious "fill me in" markers. @@ -11,6 +11,25 @@ from furtka.paths import apps_dir # default that ends up screenshotted on Hacker News. PLACEHOLDER_SECRETS: frozenset[str] = frozenset({"changeme"}) +# System paths that must never be accepted as a user-supplied `path`-type +# setting. The user is root on their own box, so this is about preventing +# accidental footguns (typing `/etc` when they meant `/mnt/etc`), not +# defending against an attacker. Matches exact paths and their subtrees +# after `Path.resolve()` — so `/mnt/../etc` also lands here. +DENIED_PATH_PREFIXES: tuple[str, ...] = ( + "/etc", + "/root", + "/boot", + "/proc", + "/sys", + "/dev", + "/bin", + "/sbin", + "/usr/bin", + "/usr/sbin", + "/var/lib/furtka", +) + class InstallError(RuntimeError): pass @@ -31,6 +50,53 @@ def _placeholder_keys(env_path: Path) -> list[str]: return bad +def _is_denied_system_path(resolved: str) -> bool: + if resolved == "/": + return True + for bad in DENIED_PATH_PREFIXES: + if resolved == bad or resolved.startswith(bad + "/"): + return True + return False + + +def _path_setting_errors(m: Manifest, env_path: Path) -> list[str]: + """Validate the filesystem paths named by `path`-type settings. + + Returns one human-readable message per offending setting. Empty values + on non-required settings are allowed — the required-field check in the + caller already refuses blanks on required fields before write. + """ + if not env_path.exists(): + return [] + values = _read_env(env_path) + errors: list[str] = [] + for s in m.settings: + if s.type != "path": + continue + value = values.get(s.name, "") + if not value: + continue + p = Path(value) + if not p.is_absolute(): + errors.append(f"{s.name}={value!r} must be an absolute path (start with /)") + continue + try: + resolved = p.resolve(strict=False) + except (OSError, RuntimeError) as e: + errors.append(f"{s.name}={value!r} cannot be resolved: {e}") + continue + if _is_denied_system_path(str(resolved)): + errors.append(f"{s.name}={value!r} resolves into a system path and is not allowed") + continue + if not resolved.exists(): + errors.append(f"{s.name}={value!r} does not exist on this box") + continue + if not resolved.is_dir(): + errors.append(f"{s.name}={value!r} is not a directory") + continue + return errors + + def _format_env_value(v: str) -> str: # Quote values that contain whitespace, quotes, or shell metacharacters so # docker-compose's env substitution reads them back intact. Simple values @@ -160,6 +226,10 @@ def install_from(src: Path, settings: dict[str, str] | None = None) -> Path: f"file and re-run `furtka app install {m.name}`." ) + path_errors = _path_setting_errors(m, env) + if path_errors: + raise InstallError(f"{m.name}: {'; '.join(path_errors)}") + return target @@ -231,6 +301,9 @@ def update_env(name: str, settings: dict[str, str]) -> Path: bad = _placeholder_keys(env) if bad: raise InstallError(f"{m.name}: {env} still has placeholder values for {', '.join(bad)}.") + path_errors = _path_setting_errors(m, env) + if path_errors: + raise InstallError(f"{m.name}: {'; '.join(path_errors)}") return target diff --git a/furtka/manifest.py b/furtka/manifest.py index f8f9f07..c70e9f0 100644 --- a/furtka/manifest.py +++ b/furtka/manifest.py @@ -13,7 +13,7 @@ REQUIRED_FIELDS = ( "icon", ) -VALID_SETTING_TYPES = frozenset({"text", "password", "number"}) +VALID_SETTING_TYPES = frozenset({"text", "password", "number", "path"}) SETTING_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$") diff --git a/tests/test_installer.py b/tests/test_installer.py index fbc0211..a0c14b7 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -267,3 +267,93 @@ def test_read_env_values_roundtrip(tmp_path, fake_dirs): write_env(p, {"A": "plain", "B": "has space", "C": 'has "quote"', "D": ""}) values = read_env_values(p) assert values == {"A": "plain", "B": "has space", "C": 'has "quote"', "D": ""} + + +# --- path-type settings ------------------------------------------------------ + +PATH_MANIFEST = dict( + VALID_MANIFEST, + name="jellyfin", + settings=[ + { + "name": "MEDIA_PATH", + "label": "Medienordner", + "type": "path", + "required": True, + } + ], +) + +OPTIONAL_PATH_MANIFEST = dict( + VALID_MANIFEST, + name="jellyfin", + settings=[ + {"name": "OPTIONAL_PATH", "label": "Optional", "type": "path", "required": False} + ], +) + + +def test_install_with_valid_path_succeeds(tmp_path, fake_dirs): + media = tmp_path / "media" + media.mkdir() + src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST) + installer.install_from(src, settings={"MEDIA_PATH": str(media)}) + target = apps_dir() / "jellyfin" + assert f"MEDIA_PATH={media}" in (target / ".env").read_text() + + +def test_install_rejects_nonexistent_path(tmp_path, fake_dirs): + src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST) + with pytest.raises(installer.InstallError, match="does not exist"): + installer.install_from(src, settings={"MEDIA_PATH": str(tmp_path / "ghost")}) + + +def test_install_rejects_path_that_is_a_file(tmp_path, fake_dirs): + f = tmp_path / "not-a-dir" + f.write_text("hi") + src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST) + with pytest.raises(installer.InstallError, match="is not a directory"): + installer.install_from(src, settings={"MEDIA_PATH": str(f)}) + + +def test_install_rejects_relative_path(tmp_path, fake_dirs): + src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST) + with pytest.raises(installer.InstallError, match="absolute path"): + installer.install_from(src, settings={"MEDIA_PATH": "media"}) + + +def test_install_rejects_system_path(tmp_path, fake_dirs): + src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST) + with pytest.raises(installer.InstallError, match="system path"): + installer.install_from(src, settings={"MEDIA_PATH": "/etc"}) + + +def test_install_rejects_root_filesystem(tmp_path, fake_dirs): + src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST) + with pytest.raises(installer.InstallError, match="system path"): + installer.install_from(src, settings={"MEDIA_PATH": "/"}) + + +def test_install_rejects_deny_list_via_traversal(tmp_path, fake_dirs): + # /mnt/../etc resolves to /etc — must be caught after Path.resolve(). + src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST) + with pytest.raises(installer.InstallError, match="system path"): + installer.install_from(src, settings={"MEDIA_PATH": "/mnt/../etc"}) + + +def test_install_accepts_empty_optional_path(tmp_path, fake_dirs): + src = _write_app_source(tmp_path, "jellyfin", OPTIONAL_PATH_MANIFEST) + installer.install_from(src, settings={"OPTIONAL_PATH": ""}) + target = apps_dir() / "jellyfin" + assert (target / ".env").exists() + + +def test_update_env_rejects_invalid_path(tmp_path, fake_dirs): + # First install with a valid path. + media = tmp_path / "media" + media.mkdir() + src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST) + installer.install_from(src, settings={"MEDIA_PATH": str(media)}) + # Then try to update to a bad path. + with pytest.raises(installer.InstallError, match="does not exist"): + installer.update_env("jellyfin", {"MEDIA_PATH": str(tmp_path / "ghost")}) diff --git a/tests/test_manifest.py b/tests/test_manifest.py index ef5edc4..f5d0279 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -155,6 +155,27 @@ def test_settings_reject_unknown_type(tmp_path): load_manifest(path) +def test_settings_accept_path_type(tmp_path): + payload = dict( + VALID_MANIFEST, + settings=[ + { + "name": "MEDIA_PATH", + "label": "Medienordner", + "description": "Absoluter Pfad zu deinen Medien", + "type": "path", + "required": True, + } + ], + ) + path = _write_app(tmp_path, "fileshare", payload) + m = load_manifest(path) + assert len(m.settings) == 1 + assert m.settings[0].name == "MEDIA_PATH" + assert m.settings[0].type == "path" + assert m.settings[0].required is True + + def test_settings_reject_duplicate_name(tmp_path): bad = dict( VALID_MANIFEST,