server-up/tests/test_boilerplates.py
Ramon 916e54a229 v0.5.00-beta - authenticatie + beveiligingsfixes
Server Up beheerde de Docker-daemon als root zonder enige vorm van
authenticatie: elke /api/*-route was gelijk aan root-toegang op de host.

- Authenticatie toegevoegd (core/auth.py): lokale accounts met scrypt-hash,
  sessiecookie (HttpOnly, SameSite=Strict) en optionele trusted-proxy-header
  SSO die alleen vanaf geconfigureerde proxy-IP's wordt vertrouwd.
- before_request-guard schermt alle API-routes af; loginscherm en
  eerste-account-setup in de UI.
- CSRF-token verplicht op elke mutatie; GET-varianten van state-wijzigende
  routes verwijderd (o.a. /api/docker/restart was via <img> te triggeren).
- Path traversal in /api/store/install gedicht; gedeelde safe_name()-validatie
  voor stack-, instantie- en repo-namen.
- Git-tokens worden niet meer teruggegeven via /api/repos en /api/settings
  (has_token-vlag); settings-PUT wist een bestaand token niet meer en kan AUTH
  niet overschrijven.
- Git-URL's beperkt tot http(s)/ssh/scp-syntax; ext::-transport (voert een
  shell-commando uit) en file:// worden geweigerd.
- Boilerplate-templates renderen in een SandboxedEnvironment (SSTI).
- Automatisch syncen van repo's bij boot standaard uit (AUTO_SYNC_ON_BOOT),
  optionele commit-pinning per repo.
- Waitress in plaats van de Flask-ontwikkelserver, MAX_CONTENT_LENGTH,
  ProxyFix, en CSP/X-Frame-Options/nosniff/Referrer-Policy headers.
- Front-end libraries (Tailwind, Alpine, htmx) lokaal meegeleverd i.p.v. CDN;
  Google Fonts verwijderd. Werkt nu ook offline.
- SSH host-key-verificatie aan (accept-new + /data/known_hosts).
- Lichte /healthz voor de healthcheck i.p.v. `docker info`.
- config.json en secret.key met 0600-rechten.
- Poort standaard op 127.0.0.1 gebonden.
- Audit-log gebruikt één gedeelde SQLite-verbinding (fd-lek per job verholpen);
  joblogs afgekapt op 2000 regels.
- VERSION-bestand is de enige bron voor het versienummer.
- pytest-suite toegevoegd (74 tests) en als stap in beide deploy-workflows.
- fix-config.sh verwijderd (hardgecodeerd intern IP, overschreef config).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
2026-07-26 14:40:05 +02:00

103 lines
3.9 KiB
Python

"""Boilerplate-rendering: de sandbox, en de YAML-opruiming die conditionele
blokken achterlaten."""
import json
import pytest
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
from core import boilerplates as bp
def _maak_boilerplate(tmp_path, compose: str, variables=None) -> Path:
d = tmp_path / "stack"
(d / "files").mkdir(parents=True)
(d / "template.json").write_text(json.dumps({
"kind": "compose",
"metadata": {"name": "Test", "description": "", "tags": []},
"variables": variables if variables is not None else [{
"title": "Algemeen",
"items": [
{"name": "service_name", "type": "str", "default": "test"},
{"name": "port", "type": "int", "default": 8080},
{"name": "admin", "type": "bool", "default": False},
],
}],
}), encoding="utf-8")
(d / "files" / "compose.yaml").write_text(compose, encoding="utf-8")
return d
def test_render_vult_variabelen_in(tmp_path):
src = _maak_boilerplate(tmp_path, "services:\n << service_name >>:\n image: nginx\n"
" ports:\n - \"<< port >>:80\"\n")
dest = tmp_path / "out"
bp.render_to_dir(src, dest, {"service_name": "web", "port": 9090})
tekst = (dest / "docker-compose.yml").read_text()
assert "web:" in tekst
assert '"9090:80"' in tekst
def test_conditioneel_blok_wordt_overgeslagen(tmp_path):
src = _maak_boilerplate(
tmp_path,
"services:\n app:\n image: nginx\n"
" environment:\n"
"<% if admin %>\n - ADMIN=1\n<% endif %>\n")
dest = tmp_path / "out"
bp.render_to_dir(src, dest, {"admin": False})
tekst = (dest / "docker-compose.yml").read_text()
assert "ADMIN" not in tekst
# `environment:` zonder inhoud is ongeldige YAML en moet weg zijn.
assert "environment:" not in tekst
def test_lege_mapping_aan_einde_verdwijnt():
assert bp._drop_empty_mappings("services:\n app:\n image: x\n volumes:\n") \
== "services:\n app:\n image: x\n"
def test_gevulde_mapping_blijft_staan():
tekst = "services:\n app:\n image: x\n"
assert bp._drop_empty_mappings(tekst) == tekst
def test_sandbox_geeft_geen_python_internals_prijs(tmp_path):
"""Templates komen uit externe git-repo's en worden al gerenderd in
/api/store/preview. De sandbox levert Undefined (leeg) voor afgeschermde
attributen, zodat er nooit een echt Python-object in het resultaat komt."""
src = _maak_boilerplate(tmp_path, "x: << service_name.__class__ >>\n")
bp.render_to_dir(src, tmp_path / "out", {"service_name": "web"})
tekst = (tmp_path / "out" / "docker-compose.yml").read_text()
# `x:` blijft leeg achter en wordt daarna door _drop_empty_mappings opgeruimd.
assert tekst.strip() == ""
assert "class" not in tekst
def test_sandbox_laat_aanroep_van_internals_falen(tmp_path):
"""De klassieke SSTI-keten faalt hard in plaats van de subclass-lijst —
en daarmee een weg naar os.system — op te leveren."""
src = _maak_boilerplate(
tmp_path, "x: << ''.__class__.__mro__[1].__subclasses__() >>\n")
with pytest.raises(bp.BoilerplateError):
bp.render_to_dir(src, tmp_path / "out", {})
def test_normale_filters_blijven_werken(tmp_path):
"""De sandbox mag de bestaande templates niet breken — vaultwarden gebruikt
bijvoorbeeld `<< signups_allowed | lower >>`."""
src = _maak_boilerplate(
tmp_path, "a: << service_name | upper >>\nb: << admin | lower >>\n")
bp.render_to_dir(src, tmp_path / "out", {"service_name": "web", "admin": True})
tekst = (tmp_path / "out" / "docker-compose.yml").read_text()
assert "a: WEB" in tekst
assert "b: true" in tekst
def test_is_boilerplate_herkent_layout(tmp_path):
src = _maak_boilerplate(tmp_path, "services: {}\n")
assert bp.is_boilerplate(src)
assert not bp.is_boilerplate(tmp_path)