fix: auth-guard / and /settings, add Logout link to static navs
All checks were successful
Build ISO / build-iso (push) Successful in 17m14s
CI / lint (push) Successful in 26s
CI / test (push) Successful in 1m2s
CI / validate-json (push) Successful in 24s
CI / markdown-links (push) Successful in 15s
Release / release (push) Successful in 11m26s
All checks were successful
Build ISO / build-iso (push) Successful in 17m14s
CI / lint (push) Successful in 26s
CI / test (push) Successful in 1m2s
CI / validate-json (push) Successful in 24s
CI / markdown-links (push) Successful in 15s
Release / release (push) Successful in 11m26s
Since 26.11 shipped login, two of the three nav pages were secretly
unauthenticated. The Caddyfile only reverse-proxied /api/*, /apps*,
/login*, /logout* to the Python auth-gated handler. Everything else —
including / (landing page) and /settings/ — fell through to Caddy's
catch-all file_server straight out of assets/www/, skipping the
session check entirely.
LAN visitor effect: they could read the box's hostname, IP, Furtka
version, uptime, and see all the Update-now / Reboot / HTTPS-toggle
buttons on /settings/. The API calls those buttons fired were
themselves 401-gated so nothing actually happened — but the info leak
plus "looks open" UX was real. Caught in the 26.13 SSH test session
when the user noticed Logout only appeared in the nav on /apps, and
not on / or /settings/.
Fix:
- Caddyfile: new `handle /settings*` and `handle /` blocks in the
shared `(furtka_routes)` snippet reverse-proxy to localhost:7000,
so both hit the Python auth-guard before the HTML goes out.
- api.py: new `_serve_static_www(relative_path)` helper reads
assets/www/{index.html, settings/index.html} with a path-traversal
clamp (resolved path must stay under static_www_dir). `do_GET`
routes `/` and `/settings[/]` to it. Removed the `/` branch from
the old combined-with-/apps line — those are different pages now.
- paths.py: new `static_www_dir()` helper with `FURTKA_STATIC_WWW`
env override for tests.
- assets/www/*.html: both nav bars get the Logout link + a shared
`doLogout()` inline script matching the _HTML pattern. Users never
see the link unauthed (the Python handler 302s them before the
page renders), but authed users get consistent navigation across
all three pages.
Tests: 5 new cases in test_api.py — unauth / redirects, unauth
/settings redirects (both trailing-slash and not), authed / serves
index.html, authed /settings serves settings/index.html,
regression guard that / and /apps serve different content.
Existing test updated (the one that used / as a proxy for /apps).
Static /style.css, /rootCA.crt, /status.json, /furtka.json,
/update-state.json stay served by Caddy's catch-all — those are
public by design (login page needs style.css, fresh users need the
CA to trust HTTPS, runtime JSON is metadata not creds).
272 tests pass, ruff check + format clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8c1fd1da2b
commit
26f0424ae3
8 changed files with 193 additions and 5 deletions
31
CHANGELOG.md
31
CHANGELOG.md
|
|
@ -7,6 +7,34 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [26.14-alpha] - 2026-04-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Landing page and `/settings/` were silently bypassing the auth
|
||||
guard.** Since 26.11 shipped login, the Caddyfile only
|
||||
reverse-proxied `/api/*`, `/apps*`, `/login*`, and `/logout*` to
|
||||
Python. Everything else — including `/` and `/settings/` — fell
|
||||
through to Caddy's catch-all `file_server` and was served straight
|
||||
from `assets/www/` without ever hitting the session check. The
|
||||
effect: a LAN visitor saw the box's hostname, IP, Furtka version,
|
||||
and the buttons for Update-now / Reboot / HTTPS-toggle. The API
|
||||
calls those buttons fired were all 401-auth-gated so actions didn't
|
||||
land, but the information leak and the "looks open" UX was a real
|
||||
bug. Caught in the 26.13 SSH test session when the user noticed
|
||||
Logout only showed up on `/apps`. Now Caddy routes `/` and
|
||||
`/settings*` through Python; a new `_serve_static_www` handler
|
||||
checks the session cookie, redirects to `/login` if unauthed, and
|
||||
reads the HTML from `assets/www/` otherwise. Catch-all still
|
||||
serves `/style.css`, `/rootCA.crt`, and the runtime JSON files
|
||||
publicly — those don't need auth.
|
||||
- **Logout link now shows on every authed page, not just `/apps`.**
|
||||
The static HTML for `/` and `/settings/` maintained their own nav
|
||||
separate from `_HTML` in `api.py`, so they never got the Logout
|
||||
entry when it was added in 26.11. Both nav bars now include it
|
||||
plus an inline `doLogout()` that POSTs `/logout` and bounces to
|
||||
`/login`, matching the pattern in `_HTML`.
|
||||
|
||||
## [26.13-alpha] - 2026-04-21
|
||||
|
||||
### Fixed
|
||||
|
|
@ -279,7 +307,8 @@ First tagged snapshot. Pre-alpha — the installer does not yet boot, but the de
|
|||
- **Containers:** Docker + Compose
|
||||
- **License:** AGPL-3.0
|
||||
|
||||
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.13-alpha...HEAD
|
||||
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.14-alpha...HEAD
|
||||
[26.14-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.14-alpha
|
||||
[26.13-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.13-alpha
|
||||
[26.12-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.12-alpha
|
||||
[26.11-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.11-alpha
|
||||
|
|
|
|||
|
|
@ -41,6 +41,20 @@
|
|||
handle /logout* {
|
||||
reverse_proxy localhost:7000
|
||||
}
|
||||
# /settings and / — these previously served as static HTML straight
|
||||
# from the catch-all file_server, which meant the auth-guard was
|
||||
# bypassed: a LAN visitor could see the box's version, IP, and
|
||||
# reach the Update-now / Reboot buttons (the API calls behind them
|
||||
# are auth-gated, but the page itself rendered without a redirect
|
||||
# to /login). Route them through the Python handler which checks
|
||||
# the session cookie and either serves the static HTML from
|
||||
# assets/www/ or redirects to /login.
|
||||
handle /settings* {
|
||||
reverse_proxy localhost:7000
|
||||
}
|
||||
handle / {
|
||||
reverse_proxy localhost:7000
|
||||
}
|
||||
# Runtime JSON lives under /var/lib/furtka/ so it survives self-updates
|
||||
# (which only swap /opt/furtka/current).
|
||||
handle /status.json {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
<a href="/" aria-current="page">Home</a>
|
||||
<a href="/apps">Apps</a>
|
||||
<a href="/settings/">Settings</a>
|
||||
<a href="#" id="logout-link" onclick="return doLogout(event)">Logout</a>
|
||||
</div>
|
||||
</nav>
|
||||
<header>
|
||||
|
|
@ -67,6 +68,17 @@
|
|||
</main>
|
||||
|
||||
<script>
|
||||
// Revoke the cookie server-side and bounce to /login. Shared
|
||||
// shape with the _HTML in furtka/api.py so the two logout
|
||||
// links behave identically.
|
||||
async function doLogout(ev) {
|
||||
ev.preventDefault();
|
||||
try { await fetch('/logout', { method: 'POST', credentials: 'same-origin' }); }
|
||||
catch (e) { /* server may already be down */ }
|
||||
window.location.href = '/login';
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hostname + install metadata — written once at install time to
|
||||
// /var/lib/furtka/furtka.json (see _furtka_json_cmd in the installer).
|
||||
// Separate from status.json because these facts don't change between
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
<a href="/">Home</a>
|
||||
<a href="/apps">Apps</a>
|
||||
<a href="/settings/" aria-current="page">Settings</a>
|
||||
<a href="#" id="logout-link" onclick="return doLogout(event)">Logout</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
|
@ -121,6 +122,15 @@
|
|||
</main>
|
||||
|
||||
<script>
|
||||
// Logout button in the nav — same shape as /apps and / pages.
|
||||
async function doLogout(ev) {
|
||||
ev.preventDefault();
|
||||
try { await fetch('/logout', { method: 'POST', credentials: 'same-origin' }); }
|
||||
catch (e) { /* server may already be down */ }
|
||||
window.location.href = '/login';
|
||||
return false;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const r = await fetch('/status.json', { cache: 'no-store' });
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
|
|||
|
||||
from furtka import auth, dockerops, install_runner, installer, reconciler, sources
|
||||
from furtka.manifest import ManifestError, load_manifest
|
||||
from furtka.paths import apps_dir
|
||||
from furtka.paths import apps_dir, static_www_dir
|
||||
from furtka.scanner import scan
|
||||
|
||||
_ICON_MAX_BYTES = 16 * 1024
|
||||
|
|
@ -1108,6 +1108,26 @@ class _Handler(BaseHTTPRequestHandler):
|
|||
self.end_headers()
|
||||
self.wfile.write(b)
|
||||
|
||||
def _serve_static_www(self, relative_path: str):
|
||||
"""Read an HTML asset from assets/www/ and serve it as 200.
|
||||
|
||||
Only reached after the do_GET auth-guard — so the caller is
|
||||
already authed. Relative_path is hard-coded at the call site
|
||||
(``index.html`` or ``settings/index.html``), not user-supplied,
|
||||
so there's no path-traversal surface here — but we still clamp
|
||||
the resolved path to static_www_dir() as a defensive check in
|
||||
case a future refactor wires a dynamic path through.
|
||||
"""
|
||||
root = static_www_dir().resolve()
|
||||
target = (root / relative_path).resolve()
|
||||
if root not in target.parents and target != root:
|
||||
return self._html(500, "<h1>internal error</h1>")
|
||||
try:
|
||||
body = target.read_text(encoding="utf-8")
|
||||
except (FileNotFoundError, OSError):
|
||||
return self._html(404, "<h1>not found</h1>")
|
||||
return self._html(200, body)
|
||||
|
||||
def _redirect(self, location, extra_headers=None):
|
||||
self.send_response(302)
|
||||
self.send_header("Location", location)
|
||||
|
|
@ -1216,8 +1236,21 @@ class _Handler(BaseHTTPRequestHandler):
|
|||
return self._json(401, {"error": "not authenticated"})
|
||||
return self._redirect("/login")
|
||||
|
||||
if self.path in ("/", "/apps", "/apps/"):
|
||||
if self.path in ("/apps", "/apps/"):
|
||||
return self._html(200, _HTML)
|
||||
# Landing page + settings page used to be served directly by
|
||||
# Caddy as static HTML, which silently bypassed this auth
|
||||
# guard (26.11-era regression that shipped and nobody noticed
|
||||
# until the 26.13 SSH test session — LAN visitors could read
|
||||
# the box version, IP and fire pre-authed clicks at the
|
||||
# update/reboot/https-toggle buttons even though the API calls
|
||||
# themselves would 401). Python reads the static HTML from
|
||||
# assets/www/ and serves it behind the session check; Caddy
|
||||
# now proxies / and /settings* here (see Caddyfile).
|
||||
if self.path == "/":
|
||||
return self._serve_static_www("index.html")
|
||||
if self.path in ("/settings", "/settings/"):
|
||||
return self._serve_static_www("settings/index.html")
|
||||
if self.path == "/api/apps":
|
||||
return self._json(200, _list_installed())
|
||||
# /api/bundled is the pre-26.6 name for this list; kept as an alias
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ DEFAULT_CATALOG_DIR = Path("/var/lib/furtka/catalog")
|
|||
# enforced by furtka.auth.save_users (same atomic-write pattern as the app
|
||||
# .env files).
|
||||
DEFAULT_USERS_FILE = Path("/var/lib/furtka/users.json")
|
||||
# Static-web asset dir served by the Python handler for / and
|
||||
# /settings* so those pages pick up the auth-guard. Caddy also serves
|
||||
# /style.css and other assets directly from here for the login page.
|
||||
DEFAULT_STATIC_WWW = Path("/opt/furtka/current/assets/www")
|
||||
|
||||
|
||||
def apps_dir() -> Path:
|
||||
|
|
@ -36,3 +40,7 @@ def catalog_apps_dir() -> Path:
|
|||
|
||||
def users_file() -> Path:
|
||||
return Path(os.environ.get("FURTKA_USERS_FILE", DEFAULT_USERS_FILE))
|
||||
|
||||
|
||||
def static_www_dir() -> Path:
|
||||
return Path(os.environ.get("FURTKA_STATIC_WWW", DEFAULT_STATIC_WWW))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "furtka"
|
||||
version = "26.13-alpha"
|
||||
version = "26.14-alpha"
|
||||
description = "Open-source home server OS — simple enough for everyone."
|
||||
requires-python = ">=3.11"
|
||||
readme = "README.md"
|
||||
|
|
|
|||
|
|
@ -24,12 +24,18 @@ def fake_dirs(tmp_path, monkeypatch):
|
|||
bundled = tmp_path / "bundled"
|
||||
catalog = tmp_path / "catalog"
|
||||
users_file = tmp_path / "users.json"
|
||||
static_www = tmp_path / "www"
|
||||
apps.mkdir()
|
||||
bundled.mkdir()
|
||||
static_www.mkdir()
|
||||
(static_www / "index.html").write_text("<html>landing page</html>")
|
||||
(static_www / "settings").mkdir()
|
||||
(static_www / "settings" / "index.html").write_text("<html>settings page</html>")
|
||||
monkeypatch.setenv("FURTKA_APPS_DIR", str(apps))
|
||||
monkeypatch.setenv("FURTKA_BUNDLED_APPS_DIR", str(bundled))
|
||||
monkeypatch.setenv("FURTKA_CATALOG_DIR", str(catalog))
|
||||
monkeypatch.setenv("FURTKA_USERS_FILE", str(users_file))
|
||||
monkeypatch.setenv("FURTKA_STATIC_WWW", str(static_www))
|
||||
# install_runner writes to /var/lib/furtka/install-state.json and
|
||||
# /run/furtka/install.lock by default — redirect into tmp_path so
|
||||
# test code doesn't need root.
|
||||
|
|
@ -278,7 +284,7 @@ def test_http_get_apps_route(fake_dirs, no_docker, admin_session):
|
|||
assert r.status == 200
|
||||
data = json.loads(r.read())
|
||||
assert data == []
|
||||
with urllib.request.urlopen(_request(port, "/", cookie=admin_session)) as r:
|
||||
with urllib.request.urlopen(_request(port, "/apps", cookie=admin_session)) as r:
|
||||
assert r.status == 200
|
||||
assert b"Furtka Apps" in r.read()
|
||||
# Unknown route → 404 JSON.
|
||||
|
|
@ -365,6 +371,82 @@ class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|||
return None
|
||||
|
||||
|
||||
def test_unauth_root_redirects_to_login(fake_dirs):
|
||||
"""/ was previously Caddy-direct static HTML, bypassing auth. Now
|
||||
Python serves it and the auth-guard applies — unauth visitor gets
|
||||
bounced to /login just like /apps does."""
|
||||
server, port = _start_server()
|
||||
try:
|
||||
opener = urllib.request.build_opener(_NoRedirectHandler())
|
||||
try:
|
||||
opener.open(_request(port, "/"))
|
||||
raise AssertionError("expected 302")
|
||||
except urllib.error.HTTPError as e:
|
||||
assert e.code == 302
|
||||
assert e.headers["Location"] == "/login"
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def test_unauth_settings_redirects_to_login(fake_dirs):
|
||||
server, port = _start_server()
|
||||
try:
|
||||
opener = urllib.request.build_opener(_NoRedirectHandler())
|
||||
for path in ("/settings", "/settings/"):
|
||||
try:
|
||||
opener.open(_request(port, path))
|
||||
raise AssertionError(f"expected 302 for {path}")
|
||||
except urllib.error.HTTPError as e:
|
||||
assert e.code == 302
|
||||
assert e.headers["Location"] == "/login"
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def test_authed_root_serves_static_index(fake_dirs, admin_session):
|
||||
server, port = _start_server()
|
||||
try:
|
||||
with urllib.request.urlopen(_request(port, "/", cookie=admin_session)) as r:
|
||||
assert r.status == 200
|
||||
assert r.read() == b"<html>landing page</html>"
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def test_authed_settings_serves_static(fake_dirs, admin_session):
|
||||
server, port = _start_server()
|
||||
try:
|
||||
for path in ("/settings", "/settings/"):
|
||||
with urllib.request.urlopen(_request(port, path, cookie=admin_session)) as r:
|
||||
assert r.status == 200
|
||||
assert r.read() == b"<html>settings page</html>"
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def test_authed_root_does_not_serve_apps_html(fake_dirs, admin_session):
|
||||
"""Regression guard: the pre-26.14 do_GET had `if self.path in ("/",
|
||||
"/apps", ...)` which served _HTML (the apps page) for / too, since
|
||||
Caddy wasn't proxying / so nobody noticed. Now that Caddy does
|
||||
proxy /, the two paths must serve different content."""
|
||||
server, port = _start_server()
|
||||
try:
|
||||
with urllib.request.urlopen(_request(port, "/", cookie=admin_session)) as r:
|
||||
root_body = r.read()
|
||||
with urllib.request.urlopen(_request(port, "/apps", cookie=admin_session)) as r:
|
||||
apps_body = r.read()
|
||||
assert root_body != apps_body
|
||||
assert b"Furtka Apps" in apps_body
|
||||
assert b"landing page" in root_body
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def test_get_login_renders_login_form_when_admin_exists(fake_dirs):
|
||||
auth.create_admin("daniel", "hunter2-pw")
|
||||
server, port = _start_server()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue