feat(manifest): add 'path' setting type with server-side validation
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) <noreply@anthropic.com>
This commit is contained in:
parent
c7e7c8b1e5
commit
04762f5dd1
6 changed files with 207 additions and 3 deletions
18
CHANGELOG.md
18
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
|
||||
|
|
|
|||
|
|
@ -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 `
|
||||
<div class="field">
|
||||
<label for="${id}">${esc(s.label)}${s.required ? '<span class="req">*</span>' : ''}</label>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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_]*$")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue