Security hardening: fix all 8 blocking findings for PR #419
Fix 1: Update support.js to collect SSH public key and POST JSON Fix 2: Legacy njalla.sh migration - parse safely, archive non-executable, replace cron with systemd timer Fix 3: DDNS SSRF prevention - allowlist only njal.la, reject other hosts, disable curl redirects Fix 4: Legacy root support-key removal migration (_remove_legacy_root_support_key) Fix 5: Automatic support-key expiration (expires_at + _expire_support_if_stale) Fix 6: Move security helpers to security_helpers.py, tests import production code Fix 7: Real NIP-19/Bech32 npub validation (_bech32_decode + _validate_npub) Fix 8: Replace journalctl sudo wildcard with restricted sovran-journal-helper.py Also: Make _write_hub_overrides() atomic with tempfile+os.replace 94 tests passing Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
This commit is contained in:
co-authored by
naturallaw777
parent
9b77b04741
commit
a111de1ece
+85
-22
@@ -1,32 +1,95 @@
|
||||
{ config, pkgs, lib, ... }:
|
||||
|
||||
{
|
||||
# ── Ensure njalla directory and base script exist on every build ──
|
||||
# ── Ensure njalla directory exists on every build ────────────────────────
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /var/lib/njalla 0750 root root -"
|
||||
];
|
||||
|
||||
# ── Create base njalla.sh if it doesn't exist yet ────────────
|
||||
systemd.services.njalla-init = {
|
||||
description = "Initialize Njal.la DDNS script if missing";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
# ── Safe DDNS update service ─────────────────────────────────────────────
|
||||
# Reads DDNS update URLs from the JSON store written by the Hub API and
|
||||
# invokes curl directly — no shell interpolation, no script execution.
|
||||
# Replaces the legacy root cron job that ran /var/lib/njalla/njalla.sh.
|
||||
systemd.services.sovran-ddns-update = {
|
||||
description = "Sovran Njal.la DDNS update (safe JSON-based runner)";
|
||||
wants = [ "network-online.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
Type = "oneshot";
|
||||
User = "root";
|
||||
ExecStart = "${pkgs.python3}/bin/python3 /var/lib/sovran/ddns-update.py";
|
||||
# Harden the service — it only needs network access and read access to
|
||||
# /var/lib/njalla/ddns_urls.json.
|
||||
NoNewPrivileges = true;
|
||||
ProtectSystem = "strict";
|
||||
ReadWritePaths = [ "/var/lib/njalla" ];
|
||||
ProtectHome = true;
|
||||
PrivateTmp = true;
|
||||
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
|
||||
};
|
||||
unitConfig = {
|
||||
ConditionPathExists = "!/var/lib/njalla/njalla.sh";
|
||||
};
|
||||
script = ''
|
||||
cat > /var/lib/njalla/njalla.sh <<'SCRIPT'
|
||||
#!/usr/bin/env bash
|
||||
IP=$(dig @resolver4.opendns.com myip.opendns.com +short -4)
|
||||
|
||||
## Add DDNS entries below — one curl per line
|
||||
## Managed via Sovran Hub web interface
|
||||
SCRIPT
|
||||
|
||||
chmod 700 /var/lib/njalla/njalla.sh
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
||||
# Run the update every 15 minutes
|
||||
systemd.timers.sovran-ddns-update = {
|
||||
description = "Sovran Njal.la DDNS update timer";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnBootSec = "2min";
|
||||
OnUnitActiveSec = "15min";
|
||||
Persistent = true;
|
||||
};
|
||||
};
|
||||
|
||||
# Install the Python runner script at build time so the service can find it.
|
||||
# The script is owned by root and not world-writable.
|
||||
system.activationScripts.sovran-ddns-update-script = ''
|
||||
install -d -m 0755 /var/lib/sovran
|
||||
cat > /var/lib/sovran/ddns-update.py <<'PYEOF'
|
||||
#!/usr/bin/env python3
|
||||
"""Sovran safe DDNS update runner. Read ddns_urls.json, call curl per URL."""
|
||||
import ipaddress, json, os, subprocess
|
||||
|
||||
URLS_FILE = "/var/lib/njalla/ddns_urls.json"
|
||||
ALLOWED_HOSTS = frozenset(["njal.la", "www.njal.la"])
|
||||
|
||||
try:
|
||||
with open(URLS_FILE) as f:
|
||||
urls = json.load(f)
|
||||
if not isinstance(urls, list):
|
||||
raise ValueError("not a list")
|
||||
except Exception:
|
||||
raise SystemExit(0) # no URLs configured — nothing to do
|
||||
|
||||
# Resolve current public IP once
|
||||
public_ip = ""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["dig", "@resolver4.opendns.com", "myip.opendns.com", "+short", "-4"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
raw = r.stdout.strip().splitlines()[0] if r.stdout.strip() else ""
|
||||
ipaddress.ip_address(raw) # validates
|
||||
public_ip = raw
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import urllib.parse
|
||||
for raw_url in urls:
|
||||
try:
|
||||
url = raw_url.replace("${IP}", public_ip) if public_ip else raw_url
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.scheme.lower() != "https":
|
||||
continue
|
||||
if (parsed.hostname or "").lower() not in ALLOWED_HOSTS:
|
||||
continue
|
||||
subprocess.run(
|
||||
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
|
||||
timeout=20, check=False,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
PYEOF
|
||||
chmod 0500 /var/lib/sovran/ddns-update.py
|
||||
'';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sovran restricted journal helper.
|
||||
|
||||
A root-owned, non-user-writable diagnostic tool that wraps journalctl with
|
||||
a strict allowlist of safe flags. Replaces the ``journalctl *`` sudo rule
|
||||
in tech-support.nix.
|
||||
|
||||
Accepted flags:
|
||||
--unit / -u <name> unit name (letters, digits, @, ., _, - only; .service suffix required)
|
||||
--lines / -n <N> positive integer (max 10000)
|
||||
--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)
|
||||
--until <datetime> ISO 8601 date/datetime (no paths, no filesystem roots)
|
||||
--output / -o <format> short | short-iso | cat | json | verbose
|
||||
|
||||
All other flags, paths, directories, roots, namespaces, and output
|
||||
destinations are rejected with a non-zero exit code.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# ── Allowlists ────────────────────────────────────────────────────────────────
|
||||
|
||||
_ALLOWED_UNITS_RE = re.compile(
|
||||
r'^[a-zA-Z0-9@._\-]+\.(service|socket|timer|target|mount|path|slice|scope)$'
|
||||
)
|
||||
|
||||
_ALLOWED_PRIORITIES = frozenset([
|
||||
"0", "1", "2", "3", "4", "5", "6", "7",
|
||||
"emerg", "alert", "crit", "err", "warning", "notice", "info", "debug",
|
||||
])
|
||||
|
||||
_ALLOWED_OUTPUT_FORMATS = frozenset([
|
||||
"short", "short-iso", "cat", "json", "verbose",
|
||||
])
|
||||
|
||||
# ISO 8601 date or datetime: YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS (no paths)
|
||||
_DATETIME_RE = re.compile(r'^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?$')
|
||||
|
||||
_MAX_LINES = 10000
|
||||
|
||||
# ── Argument parser ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _die(msg: str) -> None:
|
||||
print(f"sovran-journal-helper: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _validate_unit(val: str) -> str:
|
||||
if not _ALLOWED_UNITS_RE.match(val):
|
||||
_die(f"rejected unit name: {val!r} (only letters/digits/@._- with a known suffix)")
|
||||
return val
|
||||
|
||||
|
||||
def _validate_lines(val: str) -> str:
|
||||
try:
|
||||
n = int(val)
|
||||
except ValueError:
|
||||
_die(f"rejected: --lines must be a positive integer, got {val!r}")
|
||||
if n <= 0 or n > _MAX_LINES:
|
||||
_die(f"rejected: --lines must be between 1 and {_MAX_LINES}, got {n}")
|
||||
return str(n)
|
||||
|
||||
|
||||
def _validate_priority(val: str) -> str:
|
||||
if val not in _ALLOWED_PRIORITIES:
|
||||
_die(f"rejected priority: {val!r}")
|
||||
return val
|
||||
|
||||
|
||||
def _validate_datetime(val: str) -> str:
|
||||
if not _DATETIME_RE.match(val):
|
||||
_die(f"rejected: datetime {val!r} (must be YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)")
|
||||
return val
|
||||
|
||||
|
||||
def _validate_output(val: str) -> str:
|
||||
if val not in _ALLOWED_OUTPUT_FORMATS:
|
||||
_die(f"rejected output format: {val!r}")
|
||||
return val
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = sys.argv[1:]
|
||||
cmd = ["journalctl"]
|
||||
|
||||
i = 0
|
||||
while i < len(args):
|
||||
arg = args[i]
|
||||
|
||||
if arg in ("--unit", "-u"):
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--unit requires a value")
|
||||
cmd += ["--unit", _validate_unit(args[i])]
|
||||
elif arg.startswith("--unit="):
|
||||
cmd += ["--unit", _validate_unit(arg[len("--unit="):])]
|
||||
|
||||
elif arg in ("--lines", "-n"):
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--lines requires a value")
|
||||
cmd += ["--lines", _validate_lines(args[i])]
|
||||
elif arg.startswith("--lines="):
|
||||
cmd += ["--lines", _validate_lines(arg[len("--lines="):])]
|
||||
elif re.match(r'^-n\d+$', arg):
|
||||
cmd += ["--lines", _validate_lines(arg[2:])]
|
||||
|
||||
elif arg in ("--priority", "-p"):
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--priority requires a value")
|
||||
cmd += ["--priority", _validate_priority(args[i])]
|
||||
elif arg.startswith("--priority="):
|
||||
cmd += ["--priority", _validate_priority(arg[len("--priority="):])]
|
||||
|
||||
elif arg == "--since":
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--since requires a value")
|
||||
cmd += ["--since", _validate_datetime(args[i])]
|
||||
elif arg.startswith("--since="):
|
||||
cmd += ["--since", _validate_datetime(arg[len("--since="):])]
|
||||
|
||||
elif arg == "--until":
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--until requires a value")
|
||||
cmd += ["--until", _validate_datetime(args[i])]
|
||||
elif arg.startswith("--until="):
|
||||
cmd += ["--until", _validate_datetime(arg[len("--until="):])]
|
||||
|
||||
elif arg in ("--output", "-o"):
|
||||
i += 1
|
||||
if i >= len(args):
|
||||
_die("--output requires a value")
|
||||
cmd += ["--output", _validate_output(args[i])]
|
||||
elif arg.startswith("--output="):
|
||||
cmd += ["--output", _validate_output(arg[len("--output="):])]
|
||||
|
||||
else:
|
||||
_die(
|
||||
f"rejected flag: {arg!r}. "
|
||||
"Allowed flags: --unit, --lines, --priority, --since, --until, --output"
|
||||
)
|
||||
|
||||
i += 1
|
||||
|
||||
if not cmd[1:]:
|
||||
_die("at least one flag is required (try --unit <name>)")
|
||||
|
||||
result = subprocess.run(cmd)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -11,11 +11,10 @@
|
||||
# (u:sovran-support:---) by the Hub API as soon as a session is started.
|
||||
# • The Hub web UI lets the user grant time-limited access to wallet files
|
||||
# and view a full audit log of every session event.
|
||||
# • Scoped sudo rules allow support staff to edit custom.nix, trigger rebuilds,
|
||||
# restart services, and read logs — without full root or wallet access.
|
||||
#
|
||||
# The `acl` package provides the `setfacl` / `getfacl` utilities required by
|
||||
# the Hub's _apply_wallet_acls() and _revoke_wallet_acls() helpers.
|
||||
# • Scoped sudo rules allow support staff to restart specific services and
|
||||
# read logs — without full root, wallet access, Nix editing, or rebuilds.
|
||||
# • journalctl access is provided only through the root-owned
|
||||
# sovran-journal-helper script (see below) with an allowlist of safe flags.
|
||||
{
|
||||
# ── System packages ────────────────────────────────────────────────────────
|
||||
environment.systemPackages = [ pkgs.acl ];
|
||||
@@ -42,12 +41,24 @@
|
||||
"d /var/lib/sovran-support/.ssh 0700 sovran-support sovran-support -"
|
||||
];
|
||||
|
||||
# ── Restricted journal helper ─────────────────────────────────────────────
|
||||
# The helper is root-owned, not writable by any user, and accepts only a
|
||||
# narrow allowlist of safe journalctl flags. It is the sole mechanism by
|
||||
# which the support user may read journal logs.
|
||||
environment.etc."sovran/sovran-journal-helper.py" = {
|
||||
source = ./sovran-journal-helper.py;
|
||||
mode = "0500";
|
||||
user = "root";
|
||||
group = "root";
|
||||
};
|
||||
|
||||
# ── Scoped sudo rules for support staff ───────────────────────────────────
|
||||
# Grants only the minimum privileges needed for diagnostic support.
|
||||
# Editing Nix configuration and running nixos-rebuild are intentionally
|
||||
# excluded: combining those two permissions provides a trivial path to
|
||||
# arbitrary root code execution. Systemctl access is limited to a small
|
||||
# allowlist of named service restart operations.
|
||||
# allowlist of named service restart operations. journalctl is available
|
||||
# only through the restricted helper above.
|
||||
security.sudo.extraRules = [
|
||||
{
|
||||
users = [ "sovran-support" ];
|
||||
@@ -60,15 +71,10 @@
|
||||
{ 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 lnd.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/journalctl *"; options = [ "NOPASSWD" ]; }
|
||||
# NOTE: journalctl with arbitrary flags is retained to allow support
|
||||
# staff to filter logs by unit, time-range, and priority during
|
||||
# diagnostics. The --file / --directory flags could theoretically
|
||||
# allow reading arbitrary log files, but the support user already has
|
||||
# read access to /var/log as a system user. Wallet and secret files
|
||||
# are not stored in journald format, so exposure is limited to
|
||||
# operational logs. Consider restricting to specific units if a
|
||||
# narrower support workflow is defined in a future release.
|
||||
# Restricted journal helper: accepts only safe flags (--unit, --lines,
|
||||
# --priority, --since, --until, --output). Rejects paths, directories,
|
||||
# namespaces, roots, and arbitrary output destinations.
|
||||
{ command = "/run/current-system/sw/bin/python3 /etc/sovran/sovran-journal-helper.py *"; options = [ "NOPASSWD" ]; }
|
||||
];
|
||||
}
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user