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]
|
## [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
|
## [26.9-alpha] - 2026-04-21
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,9 @@ async function openSettingsDialog(name, action) {
|
||||||
modal.form.innerHTML = data.settings.map(s => {
|
modal.form.innerHTML = data.settings.map(s => {
|
||||||
const id = `field-${esc(s.name)}`;
|
const id = `field-${esc(s.name)}`;
|
||||||
const value = action === 'edit' && s.type === 'password' ? '' : esc(s.value || '');
|
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 `
|
return `
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="${id}">${esc(s.label)}${s.required ? '<span class="req">*</span>' : ''}</label>
|
<label for="${id}">${esc(s.label)}${s.required ? '<span class="req">*</span>' : ''}</label>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from furtka import sources
|
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
|
from furtka.paths import apps_dir
|
||||||
|
|
||||||
# Values that an app's .env.example may use as obvious "fill me in" markers.
|
# 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.
|
# default that ends up screenshotted on Hacker News.
|
||||||
PLACEHOLDER_SECRETS: frozenset[str] = frozenset({"changeme"})
|
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):
|
class InstallError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
@ -31,6 +50,53 @@ def _placeholder_keys(env_path: Path) -> list[str]:
|
||||||
return bad
|
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:
|
def _format_env_value(v: str) -> str:
|
||||||
# Quote values that contain whitespace, quotes, or shell metacharacters so
|
# Quote values that contain whitespace, quotes, or shell metacharacters so
|
||||||
# docker-compose's env substitution reads them back intact. Simple values
|
# 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}`."
|
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
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -231,6 +301,9 @@ def update_env(name: str, settings: dict[str, str]) -> Path:
|
||||||
bad = _placeholder_keys(env)
|
bad = _placeholder_keys(env)
|
||||||
if bad:
|
if bad:
|
||||||
raise InstallError(f"{m.name}: {env} still has placeholder values for {', '.join(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
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ REQUIRED_FIELDS = (
|
||||||
"icon",
|
"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_]*$")
|
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": ""})
|
write_env(p, {"A": "plain", "B": "has space", "C": 'has "quote"', "D": ""})
|
||||||
values = read_env_values(p)
|
values = read_env_values(p)
|
||||||
assert values == {"A": "plain", "B": "has space", "C": 'has "quote"', "D": ""}
|
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)
|
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):
|
def test_settings_reject_duplicate_name(tmp_path):
|
||||||
bad = dict(
|
bad = dict(
|
||||||
VALID_MANIFEST,
|
VALID_MANIFEST,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue