fix: bug scan — API hardening, safer disk picker, updater rollback, friendlier installer #19

Merged
daniel merged 7 commits from fix/bug-scan-hw-compat into main 2026-09-28 14:06:09 +02:00
7 changed files with 293 additions and 44 deletions
Showing only changes of commit 20f6398abf - Show all commits

View file

@ -68,6 +68,15 @@
</main> </main>
<script> <script>
// Sessions live in API memory, so every restart or self-update logs
// everyone out. Send the browser to the login page instead of rendering
// the 401 body as if the box had no apps.
const _fetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const r = await _fetch(...args);
if (r.status === 401) { location.href = '/login'; }
return r;
};
// Revoke the cookie server-side and bounce to /login. Shared // Revoke the cookie server-side and bounce to /login. Shared
// shape with the _HTML in furtka/api.py so the two logout // shape with the _HTML in furtka/api.py so the two logout
// links behave identically. // links behave identically.

View file

@ -122,6 +122,15 @@
</main> </main>
<script> <script>
// Sessions live in API memory, so every restart or self-update logs
// everyone out. Send the browser to the login page instead of rendering
// the 401 body as if the box had no apps.
const _fetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const r = await _fetch(...args);
if (r.status === 401) { location.href = '/login'; }
return r;
};
// Logout button in the nav — same shape as /apps and / pages. // Logout button in the nav — same shape as /apps and / pages.
async function doLogout(ev) { async function doLogout(ev) {
ev.preventDefault(); ev.preventDefault();

View file

@ -17,12 +17,14 @@ stopgap.
import json import json
import re import re
import threading
import time import time
import traceback
from http.cookies import SimpleCookie from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from furtka import auth, deps, dockerops, install_runner, installer, reconciler, sources from furtka import auth, deps, dockerops, install_runner, installer, reconciler, sources
from furtka.manifest import ManifestError, load_manifest from furtka.manifest import APP_NAME_RE, ManifestError, load_manifest
from furtka.paths import apps_dir, static_www_dir from furtka.paths import apps_dir, static_www_dir
from furtka.scanner import scan from furtka.scanner import scan
@ -120,6 +122,15 @@ _HTML = """<!DOCTYPE html>
</div> </div>
<script> <script>
// Sessions live in API memory, so every restart or self-update logs
// everyone out. Send the browser to the login page instead of rendering
// the 401 body as if the box had no apps.
const _fetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const r = await _fetch(...args);
if (r.status === 401) { location.href = '/login'; }
return r;
};
function esc(s) { function esc(s) {
const d = document.createElement('div'); const d = document.createElement('div');
d.textContent = s == null ? '' : String(s); d.textContent = s == null ? '' : String(s);
@ -1238,7 +1249,25 @@ def _parse_settings_body(payload):
return out return out
# Largest request body we read. Every legitimate body is a small JSON
# object; without a cap a LAN client could make us buffer gigabytes.
MAX_BODY_BYTES = 1024 * 1024
# The server is threaded so status polls and page loads keep answering while
# a slow request (image pull, reconcile) runs. Requests that change state
# still run one at a time, as they did on the single-threaded server.
_MUTATION_LOCK = threading.Lock()
def _valid_app_name(name) -> bool:
return isinstance(name, str) and APP_NAME_RE.fullmatch(name) is not None
class _Handler(BaseHTTPRequestHandler): class _Handler(BaseHTTPRequestHandler):
# Socket read timeout: a client that stops sending mid-request gets
# dropped instead of holding a worker forever.
timeout = 30
def _json(self, status, payload, extra_headers=None): def _json(self, status, payload, extra_headers=None):
body = json.dumps(payload).encode() body = json.dumps(payload).encode()
self.send_response(status) self.send_response(status)
@ -1393,7 +1422,28 @@ class _Handler(BaseHTTPRequestHandler):
auth.SESSIONS.revoke(morsel.value) auth.SESSIONS.revoke(morsel.value)
return self._json(200, {"ok": True}, extra_headers=[self._clear_cookie_header()]) return self._json(200, {"ok": True}, extra_headers=[self._clear_cookie_header()])
def _internal_error(self):
# Without this, an unexpected exception makes socketserver drop the
# connection and the UI shows "Failed to fetch" with nothing to go on.
traceback.print_exc()
try:
self._json(500, {"error": "internal error — see `journalctl -u furtka-api`"})
except OSError:
pass
def do_GET(self): # noqa: N802 — http.server convention def do_GET(self): # noqa: N802 — http.server convention
try:
self._route_get()
except Exception:
self._internal_error()
def do_POST(self): # noqa: N802
try:
self._route_post()
except Exception:
self._internal_error()
def _route_get(self):
# --- Public routes: login page + its assets ------------------ # --- Public routes: login page + its assets ------------------
if self.path in ("/login", "/login/"): if self.path in ("/login", "/login/"):
# Already authed? Skip straight to the app list. # Already authed? Skip straight to the app list.
@ -1447,14 +1497,26 @@ class _Handler(BaseHTTPRequestHandler):
# /api/apps/<name>/settings # /api/apps/<name>/settings
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"): if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
name = self.path[len("/api/apps/") : -len("/settings")] name = self.path[len("/api/apps/") : -len("/settings")]
if "/" in name or not name: if not _valid_app_name(name):
return self._json(400, {"error": "invalid app name"}) return self._json(400, {"error": "invalid app name"})
status, body = _do_get_settings(name) status, body = _do_get_settings(name)
return self._json(status, body) return self._json(status, body)
self._json(404, {"error": "not found"}) self._json(404, {"error": "not found"})
def do_POST(self): # noqa: N802 def _route_post(self):
length = int(self.headers.get("Content-Length", "0")) # Browsers tag every request with where it came from. Only accept
# state changes from our own pages — an app on another port of the
# same host counts as "same-site" and would pass SameSite=Strict.
# Old browsers that don't send the header fall back to the cookie.
fetch_site = self.headers.get("Sec-Fetch-Site")
if fetch_site is not None and fetch_site not in ("same-origin", "none"):
return self._json(403, {"error": "cross-site request refused"})
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
return self._json(400, {"error": "invalid Content-Length"})
if length < 0 or length > MAX_BODY_BYTES:
return self._json(413, {"error": "request body too large"})
raw = self.rfile.read(length) if length else b"" raw = self.rfile.read(length) if length else b""
try: try:
payload = json.loads(raw.decode()) if raw else {} payload = json.loads(raw.decode()) if raw else {}
@ -1473,10 +1535,15 @@ class _Handler(BaseHTTPRequestHandler):
if self._current_session() is None: if self._current_session() is None:
return self._json(401, {"error": "not authenticated"}) return self._json(401, {"error": "not authenticated"})
with _MUTATION_LOCK:
return self._route_authed_post(payload)
def _route_authed_post(self, payload):
# Per-app settings update: /api/apps/<name>/settings # Per-app settings update: /api/apps/<name>/settings
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"): if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
name = self.path[len("/api/apps/") : -len("/settings")] name = self.path[len("/api/apps/") : -len("/settings")]
if "/" in name or not name: if not _valid_app_name(name):
return self._json(400, {"error": "invalid app name"}) return self._json(400, {"error": "invalid app name"})
settings = _parse_settings_body(payload) settings = _parse_settings_body(payload)
if settings is False or settings is None: if settings is False or settings is None:
@ -1487,7 +1554,7 @@ class _Handler(BaseHTTPRequestHandler):
# Per-app image update: /api/apps/<name>/update # Per-app image update: /api/apps/<name>/update
if self.path.startswith("/api/apps/") and self.path.endswith("/update"): if self.path.startswith("/api/apps/") and self.path.endswith("/update"):
name = self.path[len("/api/apps/") : -len("/update")] name = self.path[len("/api/apps/") : -len("/update")]
if "/" in name or not name: if not _valid_app_name(name):
return self._json(400, {"error": "invalid app name"}) return self._json(400, {"error": "invalid app name"})
status, body = _do_update(name) status, body = _do_update(name)
return self._json(status, body) return self._json(status, body)
@ -1519,6 +1586,8 @@ class _Handler(BaseHTTPRequestHandler):
name = payload.get("name") name = payload.get("name")
if not isinstance(name, str) or not name: if not isinstance(name, str) or not name:
return self._json(400, {"error": "missing or empty 'name' field"}) return self._json(400, {"error": "missing or empty 'name' field"})
if not _valid_app_name(name):
return self._json(400, {"error": "invalid app name"})
if self.path == "/api/apps/install/plan": if self.path == "/api/apps/install/plan":
status, body = _do_install_plan(name) status, body = _do_install_plan(name)
@ -1541,7 +1610,8 @@ class _Handler(BaseHTTPRequestHandler):
def serve(host: str = "127.0.0.1", port: int = 7000) -> None: def serve(host: str = "127.0.0.1", port: int = 7000) -> None:
"""Run the API server. Blocks forever; exits on SIGINT/SIGTERM.""" """Run the API server. Blocks forever; exits on SIGINT/SIGTERM."""
server = HTTPServer((host, port), _Handler) server = ThreadingHTTPServer((host, port), _Handler)
server.daemon_threads = True
print(f"Furtka API listening on http://{host}:{port}/") print(f"Furtka API listening on http://{host}:{port}/")
try: try:
server.serve_forever() server.serve_forever()

View file

@ -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 Manifest, ManifestError, load_manifest from furtka.manifest import APP_NAME_RE, 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.
@ -35,19 +35,26 @@ class InstallError(RuntimeError):
pass pass
def app_dir(name: str) -> Path:
"""/var/lib/furtka/apps/<name>, refusing anything that isn't a plain app name.
Names reach us from API bodies and URL paths; without this check "..",
"." or "/" would point remove()'s rmtree at /var/lib/furtka, every app,
or the root filesystem.
"""
if not isinstance(name, str) or not APP_NAME_RE.fullmatch(name):
raise InstallError(f"{name!r} is not a valid app name")
return apps_dir() / name
def _placeholder_values(values: dict[str, str]) -> list[str]:
return [k for k, v in values.items() if v.strip() in PLACEHOLDER_SECRETS]
def _placeholder_keys(env_path: Path) -> list[str]: def _placeholder_keys(env_path: Path) -> list[str]:
if not env_path.exists(): if not env_path.exists():
return [] return []
bad: list[str] = [] return _placeholder_values(_read_env(env_path))
for raw in env_path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
value = value.strip().strip('"').strip("'")
if value in PLACEHOLDER_SECRETS:
bad.append(key.strip())
return bad
def _is_denied_system_path(resolved: str) -> bool: def _is_denied_system_path(resolved: str) -> bool:
@ -59,16 +66,13 @@ def _is_denied_system_path(resolved: str) -> bool:
return False return False
def _path_setting_errors(m: Manifest, env_path: Path) -> list[str]: def _path_setting_errors(m: Manifest, values: dict[str, str]) -> list[str]:
"""Validate the filesystem paths named by `path`-type settings. """Validate the filesystem paths named by `path`-type settings.
Returns one human-readable message per offending setting. Empty values Returns one human-readable message per offending setting. Empty values
on non-required settings are allowed — the required-field check in the on non-required settings are allowed — the required-field check in the
caller already refuses blanks on required fields before write. caller already refuses blanks on required fields before write.
""" """
if not env_path.exists():
return []
values = _read_env(env_path)
errors: list[str] = [] errors: list[str] = []
for s in m.settings: for s in m.settings:
if s.type != "path": if s.type != "path":
@ -97,10 +101,35 @@ def _path_setting_errors(m: Manifest, env_path: Path) -> list[str]:
return errors return errors
def _validate_settings(m: Manifest, values: dict[str, str]) -> None:
"""Refuse a merged settings view before it is written to .env.
Runs in memory so a rejected value (placeholder, system path, line
break) never reaches the live .env where the next reconcile would use it.
"""
for k, v in values.items():
if any(c in v for c in "\r\n\0"):
raise InstallError(f"{m.name}: setting {k!r} must not contain line breaks")
bad = _placeholder_values({s.name: values.get(s.name, "") for s in m.settings})
if bad:
raise InstallError(
f"{m.name}: please replace the example value for {', '.join(bad)} with your own"
)
path_errors = _path_setting_errors(m, values)
if path_errors:
raise InstallError(f"{m.name}: {'; '.join(path_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
# stay unquoted to keep the file readable when a user SSHes in. # stay unquoted to keep the file readable when a user SSHes in.
if any(c in v for c in "\r\n\0"):
raise InstallError("setting values must not contain line breaks")
if "$" in v and "'" not in v:
# Compose expands ${VAR} inside double quotes, so "pa$word" would
# silently turn into "pa". Single-quoted values are taken literally.
return f"'{v}'"
if v == "" or any(c in v for c in " \t\"'$`\\#"): if v == "" or any(c in v for c in " \t\"'$`\\#"):
escaped = v.replace("\\", "\\\\").replace('"', '\\"') escaped = v.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"' return f'"{escaped}"'
@ -167,7 +196,7 @@ def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
if key not in declared: if key not in declared:
raise InstallError(f"{m.name}: unknown setting {key!r}") raise InstallError(f"{m.name}: unknown setting {key!r}")
target = apps_dir() / m.name target = app_dir(m.name)
target.mkdir(parents=True, exist_ok=True) target.mkdir(parents=True, exist_ok=True)
for item in src.iterdir(): for item in src.iterdir():
@ -216,6 +245,7 @@ def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
for s in m.settings: for s in m.settings:
if s.required and not merged.get(s.name): if s.required and not merged.get(s.name):
raise InstallError(f"{m.name}: setting {s.name!r} is required") raise InstallError(f"{m.name}: setting {s.name!r} is required")
_validate_settings(m, merged)
write_env(env, merged) write_env(env, merged)
elif not env.exists() and env_example.exists(): elif not env.exists() and env_example.exists():
# First install with no settings and no .env shipped: bootstrap from # First install with no settings and no .env shipped: bootstrap from
@ -235,7 +265,7 @@ 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) path_errors = _path_setting_errors(m, read_env_values(env))
if path_errors: if path_errors:
raise InstallError(f"{m.name}: {'; '.join(path_errors)}") raise InstallError(f"{m.name}: {'; '.join(path_errors)}")
@ -256,10 +286,11 @@ def parse_env_text(text: str) -> dict[str, str]:
continue continue
key, _, value = line.partition("=") key, _, value = line.partition("=")
value = value.strip() value = value.strip()
if (value.startswith('"') and value.endswith('"')) or ( if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
value.startswith("'") and value.endswith("'")
):
value = value[1:-1].replace('\\"', '"').replace("\\\\", "\\") value = value[1:-1].replace('\\"', '"').replace("\\\\", "\\")
elif len(value) >= 2 and value.startswith("'") and value.endswith("'"):
# Single quotes are literal in compose's dotenv — no escapes.
value = value[1:-1]
out[key.strip()] = value out[key.strip()] = value
return out return out
@ -284,7 +315,7 @@ def update_env(name: str, settings: dict[str, str]) -> Path:
apps only. Returns the target folder; caller is expected to run apps only. Returns the target folder; caller is expected to run
reconcile to restart the containers. reconcile to restart the containers.
""" """
target = apps_dir() / name target = app_dir(name)
manifest_path = target / "manifest.json" manifest_path = target / "manifest.json"
if not manifest_path.exists(): if not manifest_path.exists():
raise InstallError(f"{name!r} is not installed") raise InstallError(f"{name!r} is not installed")
@ -316,13 +347,8 @@ def update_env(name: str, settings: dict[str, str]) -> Path:
for s in m.settings: for s in m.settings:
if s.required and not merged.get(s.name): if s.required and not merged.get(s.name):
raise InstallError(f"{m.name}: setting {s.name!r} is required") raise InstallError(f"{m.name}: setting {s.name!r} is required")
_validate_settings(m, merged)
write_env(env, merged) write_env(env, merged)
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 return target
@ -353,7 +379,7 @@ def remove(name: str) -> Path:
Caller is responsible for stopping the compose project first. Caller is responsible for stopping the compose project first.
""" """
target = apps_dir() / name target = app_dir(name)
if not target.exists(): if not target.exists():
raise InstallError(f"{name!r} is not installed") raise InstallError(f"{name!r} is not installed")
shutil.rmtree(target) shutil.rmtree(target)

View file

@ -175,8 +175,10 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
raise ManifestError(f"{path}: missing required fields: {', '.join(missing)}") raise ManifestError(f"{path}: missing required fields: {', '.join(missing)}")
name = raw["name"] name = raw["name"]
if not isinstance(name, str) or not name: if not isinstance(name, str) or not APP_NAME_RE.fullmatch(name):
raise ManifestError(f"{path}: name must be a non-empty string") raise ManifestError(
f"{path}: name must be lowercase letters, digits, '-' or '_' (got {name!r})"
)
if expected_name is not None and name != expected_name: if expected_name is not None and name != expected_name:
raise ManifestError(f"{path}: name {name!r} must equal {expected_name!r}") raise ManifestError(f"{path}: name {name!r} must equal {expected_name!r}")

View file

@ -407,7 +407,7 @@ def _request(port, path, cookie=None, method="GET", body=None):
def test_http_get_apps_route(fake_dirs, no_docker, admin_session): def test_http_get_apps_route(fake_dirs, no_docker, admin_session):
"""Smoke test the actual HTTP server with a real socket, urllib client.""" """Smoke test the actual HTTP server with a real socket, urllib client."""
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) # port 0 → ephemeral server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler) # port 0 → ephemeral
port = server.server_address[1] port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True) t = threading.Thread(target=server.serve_forever, daemon=True)
t.start() t.start()
@ -431,7 +431,7 @@ def test_http_get_apps_route(fake_dirs, no_docker, admin_session):
def test_http_post_install_unknown_app(fake_dirs, admin_session): def test_http_post_install_unknown_app(fake_dirs, admin_session):
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler)
port = server.server_address[1] port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True) t = threading.Thread(target=server.serve_forever, daemon=True)
t.start() t.start()
@ -459,7 +459,7 @@ def test_http_post_install_unknown_app(fake_dirs, admin_session):
def _start_server(): def _start_server():
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler)
port = server.server_address[1] port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True) t = threading.Thread(target=server.serve_forever, daemon=True)
t.start() t.start()
@ -980,7 +980,7 @@ def test_update_settings_unknown_app(fake_dirs):
def test_http_get_settings_route(fake_dirs, no_docker, admin_session): def test_http_get_settings_route(fake_dirs, no_docker, admin_session):
_, bundled = fake_dirs _, bundled = fake_dirs
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST) _write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler)
port = server.server_address[1] port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True) t = threading.Thread(target=server.serve_forever, daemon=True)
t.start() t.start()
@ -1211,7 +1211,7 @@ def test_http_post_update_route(
api._do_install("fileshare") api._do_install("fileshare")
update_docker_stubs["up_called"] = 0 # reset counter after install update_docker_stubs["up_called"] = 0 # reset counter after install
update_docker_stubs["local"] = {"samba": "sha256:NEW"} update_docker_stubs["local"] = {"samba": "sha256:NEW"}
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler)
port = server.server_address[1] port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True) t = threading.Thread(target=server.serve_forever, daemon=True)
t.start() t.start()
@ -1236,7 +1236,7 @@ def test_http_post_update_route(
def test_http_post_install_with_settings(fake_dirs, no_docker, no_systemd_run, admin_session): def test_http_post_install_with_settings(fake_dirs, no_docker, no_systemd_run, admin_session):
_, bundled = fake_dirs _, bundled = fake_dirs
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST) _write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler)
port = server.server_address[1] port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True) t = threading.Thread(target=server.serve_forever, daemon=True)
t.start() t.start()
@ -1424,3 +1424,81 @@ def test_power_surfaces_systemd_run_missing(fake_dirs, monkeypatch):
status, body = api._do_power({"action": "reboot"}) status, body = api._do_power({"action": "reboot"})
assert status == 502 assert status == 502
assert "systemd-run" in body["error"] assert "systemd-run" in body["error"]
# --- hardening: names, body size, cross-site POSTs, crash handling ----------
@pytest.mark.parametrize("bad", ["/", ".", "..", "../catalog"])
def test_http_remove_rejects_path_names(fake_dirs, no_docker, admin_session, bad):
apps, _ = fake_dirs
(apps / "fileshare").mkdir()
server, port = _start_server()
try:
req = _request(
port, "/api/apps/remove", cookie=admin_session, method="POST", body={"name": bad}
)
with pytest.raises(urllib.error.HTTPError) as e:
urllib.request.urlopen(req)
assert e.value.code == 400
assert (apps / "fileshare").exists()
finally:
server.shutdown()
server.server_close()
def test_http_post_rejects_oversized_body(fake_dirs, admin_session):
server, port = _start_server()
try:
import http.client
# Announce a huge body but never send it: the server must answer
# from the header alone instead of waiting to buffer it.
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
conn.putrequest("POST", "/api/apps/remove")
conn.putheader("Cookie", admin_session)
conn.putheader("Content-Length", str(api.MAX_BODY_BYTES + 1))
conn.endheaders()
assert conn.getresponse().status == 413
conn.close()
finally:
server.shutdown()
server.server_close()
def test_http_post_rejects_same_site_other_origin(fake_dirs, no_docker, admin_session):
apps, _ = fake_dirs
(apps / "fileshare").mkdir()
server, port = _start_server()
try:
req = _request(
port,
"/api/apps/remove",
cookie=admin_session,
method="POST",
body={"name": "fileshare"},
)
req.add_header("Sec-Fetch-Site", "same-site")
with pytest.raises(urllib.error.HTTPError) as e:
urllib.request.urlopen(req)
assert e.value.code == 403
assert (apps / "fileshare").exists()
finally:
server.shutdown()
server.server_close()
def test_http_unexpected_exception_returns_json_500(fake_dirs, admin_session, monkeypatch):
def boom():
raise FileNotFoundError("docker")
monkeypatch.setattr(api, "_list_installed", boom)
server, port = _start_server()
try:
with pytest.raises(urllib.error.HTTPError) as e:
urllib.request.urlopen(_request(port, "/api/apps", cookie=admin_session))
assert e.value.code == 500
assert "internal error" in json.loads(e.value.read())["error"]
finally:
server.shutdown()
server.server_close()

View file

@ -462,3 +462,58 @@ def test_install_plan_skips_already_installed(tmp_path, fake_dirs, monkeypatch):
) )
installer.install_plan(plan) installer.install_plan(plan)
assert calls == ["a"] assert calls == ["a"]
# --- hardening: app names, validate-before-write, .env quoting --------------
@pytest.mark.parametrize("bad", ["/", ".", "..", "../catalog", "a/b", "", "UPPER"])
def test_remove_refuses_non_app_names(fake_dirs, bad):
apps, _ = fake_dirs
(apps / "fileshare").mkdir()
with pytest.raises(installer.InstallError, match="not a valid app name"):
installer.remove(bad)
assert (apps / "fileshare").exists()
assert apps.exists()
def test_update_env_refuses_non_app_names(fake_dirs):
with pytest.raises(installer.InstallError, match="not a valid app name"):
installer.update_env("..", {"X": "1"})
def test_rejected_path_setting_never_reaches_env(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)})
env = apps_dir() / "jellyfin" / ".env"
with pytest.raises(installer.InstallError, match="system path"):
installer.update_env("jellyfin", {"MEDIA_PATH": "/etc"})
assert "/etc" not in env.read_text()
assert f"MEDIA_PATH={media}" in env.read_text()
def test_newline_in_setting_is_rejected(tmp_path, fake_dirs):
m = dict(VALID_MANIFEST, settings=[{"name": "PW", "label": "PW", "type": "password"}])
src = _write_app_source(tmp_path, "fileshare", m)
with pytest.raises(installer.InstallError, match="line breaks"):
installer.install_from(src, settings={"PW": "abc\nCOMPOSE_PROJECT_NAME=x"})
env = apps_dir() / "fileshare" / ".env"
assert not env.exists() or "COMPOSE_PROJECT_NAME" not in env.read_text()
def test_dollar_values_are_single_quoted_and_round_trip(tmp_path):
env = tmp_path / ".env"
values = {"PW": "pa$word", "B": "has space", "C": "it's $5", "D": "plain"}
installer.write_env(env, values)
text = env.read_text()
assert "PW='pa$word'" in text
assert "D=plain" in text
assert installer.read_env_values(env) == values
def test_manifest_name_must_be_app_name(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "evil", dict(VALID_MANIFEST, name="../evil"))
with pytest.raises(installer.InstallError, match="lowercase"):
installer.install_from(src)