feat(ui): inline app icons into /api/apps JSON, render on /apps

Slice 2 of the on-box UI uplevel. The resource-manager API already
returned the icon filename in each manifest summary, but the /apps
page never rendered it — and there was no endpoint to fetch the
file either. This inlines the SVG content directly into the JSON
response (one round-trip, Doherty Threshold) and injects it into
each app card's new icon slot on the left.

_read_icon_svg defends against the obvious SVG-XSS vectors (script
tags, on* handlers, javascript: URLs) and rejects anything over
16 KB. The trust model stays what it was — bundled apps are built
into the ISO, the install API has no auth — but the filter keeps
accidents from becoming exploits if an icon gets swapped upstream.

/apps now shows a generic folder fallback for any app without a
parseable icon.svg; slice 3 ships the real fileshare artwork.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-04-16 12:23:41 +02:00
parent a6878f5d23
commit e8ed224eea
2 changed files with 139 additions and 9 deletions

View file

@ -12,6 +12,7 @@ out at the top. Auth lands when Authentik does.
""" """
import json import json
import re
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from furtka import dockerops, installer, reconciler from furtka import dockerops, installer, reconciler
@ -19,6 +20,47 @@ from furtka.manifest import ManifestError, load_manifest
from furtka.paths import apps_dir, bundled_apps_dir from furtka.paths import apps_dir, bundled_apps_dir
from furtka.scanner import scan from furtka.scanner import scan
_ICON_MAX_BYTES = 16 * 1024
_UNSAFE_SVG_PATTERNS = (
re.compile(r"<script", re.IGNORECASE),
re.compile(r"javascript:", re.IGNORECASE),
re.compile(r"\bon[a-z]+\s*=", re.IGNORECASE),
)
def _read_icon_svg(app_dir, icon_name):
"""Return an SVG string safe to inline into the /apps response, or None.
Inlined rather than served via a separate endpoint so the /apps page
renders in one round-trip (Doherty Threshold). The trust model: icons
come from bundled apps we ship in the ISO or from apps the operator
installed via the (auth-less-by-design) API so the realistic threat
is a malformed file, not an attacker. Filter the obvious script /
event-handler vectors for defense in depth and let the browser render
the rest.
"""
if not app_dir or not icon_name:
return None
path = app_dir / icon_name
try:
if not path.is_file() or path.stat().st_size > _ICON_MAX_BYTES:
return None
data = path.read_text(encoding="utf-8")
except OSError:
return None
data = data.strip()
if data.startswith("<?xml"):
end = data.find("?>")
if end == -1:
return None
data = data[end + 2 :].lstrip()
if not data.startswith("<svg"):
return None
if any(p.search(data) for p in _UNSAFE_SVG_PATTERNS):
return None
return data
_HTML = """<!DOCTYPE html> _HTML = """<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -73,6 +115,15 @@ function esc(s) {
return d.innerHTML; return d.innerHTML;
} }
// Fallback when an app doesn't ship a parseable icon.svg. Simple
// stroked folder currentColor so the tile's accent tint applies.
const FALLBACK_ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-7l-2-2H5a2 2 0 0 0-2 2z"/></svg>';
function appIcon(a) {
// `a.icon_svg` is already sanitized server-side (see _read_icon_svg).
return `<div class="app-icon">${a.icon_svg || FALLBACK_ICON}</div>`;
}
const modal = { const modal = {
backdrop: document.getElementById('modal-backdrop'), backdrop: document.getElementById('modal-backdrop'),
title: document.getElementById('modal-title'), title: document.getElementById('modal-title'),
@ -194,10 +245,13 @@ async function refresh() {
const hasSettings = a.has_settings; const hasSettings = a.has_settings;
return ` return `
<div class="app"> <div class="app">
<div class="left">
${appIcon(a)}
<div class="meta"> <div class="meta">
<span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span> <span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span>
<span class="desc">${esc(a.description || a.error || '')}</span> <span class="desc">${esc(a.description || a.error || '')}</span>
</div> </div>
</div>
<div class="buttons"> <div class="buttons">
${hasSettings ? `<button data-op="edit" data-name="${esc(a.name)}">Settings</button>` : ''} ${hasSettings ? `<button data-op="edit" data-name="${esc(a.name)}">Settings</button>` : ''}
<button class="secondary" data-op="reinstall" data-name="${esc(a.name)}">Reinstall</button> <button class="secondary" data-op="reinstall" data-name="${esc(a.name)}">Reinstall</button>
@ -209,10 +263,13 @@ async function refresh() {
document.getElementById('available').innerHTML = available.length document.getElementById('available').innerHTML = available.length
? available.map(a => ` ? available.map(a => `
<div class="app"> <div class="app">
<div class="left">
${appIcon(a)}
<div class="meta"> <div class="meta">
<span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span> <span class="name">${esc(a.display_name || a.name)} <small>${esc(a.version || '')}</small></span>
<span class="desc">${esc(a.description || '')}</span> <span class="desc">${esc(a.description || '')}</span>
</div> </div>
</div>
<button data-op="install" data-name="${esc(a.name)}">Install</button> <button data-op="install" data-name="${esc(a.name)}">Install</button>
</div>`).join('') </div>`).join('')
: '<div class="empty">No bundled apps left to install.</div>'; : '<div class="empty">No bundled apps left to install.</div>';
@ -254,7 +311,7 @@ refresh();
""" """
def _manifest_summary(m): def _manifest_summary(m, app_dir=None):
return { return {
"name": m.name, "name": m.name,
"display_name": m.display_name, "display_name": m.display_name,
@ -263,6 +320,7 @@ def _manifest_summary(m):
"description_long": m.description_long, "description_long": m.description_long,
"ports": list(m.ports), "ports": list(m.ports),
"icon": m.icon, "icon": m.icon,
"icon_svg": _read_icon_svg(app_dir, m.icon),
"has_settings": bool(m.settings), "has_settings": bool(m.settings),
} }
@ -271,7 +329,7 @@ def _list_installed():
out = [] out = []
for r in scan(apps_dir()): for r in scan(apps_dir()):
if r.ok: if r.ok:
d = _manifest_summary(r.manifest) d = _manifest_summary(r.manifest, r.path)
d["ok"] = True d["ok"] = True
out.append(d) out.append(d)
else: else:
@ -295,7 +353,7 @@ def _list_bundled():
m = load_manifest(manifest_path) m = load_manifest(manifest_path)
except ManifestError: except ManifestError:
continue continue
out.append(_manifest_summary(m)) out.append(_manifest_summary(m, entry))
return out return out

View file

@ -64,6 +64,78 @@ def test_list_bundled_shows_uninstalled(fake_dirs):
assert "display_name" in out[0] assert "display_name" in out[0]
# --- Icon inlining ----------------------------------------------------------
_SIMPLE_SVG = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0h10v10H0z"/></svg>'
)
def _write_icon(app_dir, contents, name="icon.svg"):
(app_dir / name).write_text(contents)
def test_read_icon_svg_returns_content(tmp_path):
_write_icon(tmp_path, _SIMPLE_SVG)
assert api._read_icon_svg(tmp_path, "icon.svg") == _SIMPLE_SVG
def test_read_icon_svg_strips_xml_declaration(tmp_path):
_write_icon(tmp_path, '<?xml version="1.0" encoding="UTF-8"?>\n' + _SIMPLE_SVG)
assert api._read_icon_svg(tmp_path, "icon.svg") == _SIMPLE_SVG
def test_read_icon_svg_missing_file_returns_none(tmp_path):
assert api._read_icon_svg(tmp_path, "ghost.svg") is None
def test_read_icon_svg_no_name_returns_none(tmp_path):
assert api._read_icon_svg(tmp_path, None) is None
assert api._read_icon_svg(tmp_path, "") is None
def test_read_icon_svg_rejects_non_svg(tmp_path):
_write_icon(tmp_path, "<html><body>hi</body></html>")
assert api._read_icon_svg(tmp_path, "icon.svg") is None
def test_read_icon_svg_rejects_oversized(tmp_path):
_write_icon(tmp_path, "<svg>" + ("x" * (17 * 1024)) + "</svg>")
assert api._read_icon_svg(tmp_path, "icon.svg") is None
def test_read_icon_svg_rejects_script_tag(tmp_path):
_write_icon(tmp_path, "<svg><script>alert(1)</script></svg>")
assert api._read_icon_svg(tmp_path, "icon.svg") is None
def test_read_icon_svg_rejects_event_handler(tmp_path):
_write_icon(tmp_path, '<svg onload="alert(1)"><path/></svg>')
assert api._read_icon_svg(tmp_path, "icon.svg") is None
def test_read_icon_svg_rejects_javascript_url(tmp_path):
_write_icon(tmp_path, '<svg><a href="javascript:alert(1)"/></svg>')
assert api._read_icon_svg(tmp_path, "icon.svg") is None
def test_list_bundled_inlines_icon_svg(fake_dirs):
_, bundled = fake_dirs
app = _write_bundled(bundled, "fileshare")
_write_icon(app, _SIMPLE_SVG)
[entry] = api._list_bundled()
assert entry["icon_svg"] == _SIMPLE_SVG
def test_list_installed_inlines_icon_svg(fake_dirs, no_docker):
apps, bundled = fake_dirs
app = _write_bundled(bundled, "fileshare", env_example="A=real")
_write_icon(app, _SIMPLE_SVG)
api._do_install("fileshare")
[entry] = api._list_installed()
assert entry["icon_svg"] == _SIMPLE_SVG
def test_list_bundled_hides_already_installed(fake_dirs, no_docker): def test_list_bundled_hides_already_installed(fake_dirs, no_docker):
apps, bundled = fake_dirs apps, bundled = fake_dirs
_write_bundled(bundled, "fileshare", env_example="A=real") _write_bundled(bundled, "fileshare", env_example="A=real")