Fix all 8 security hardening blockers for PR #423
Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com>
This commit is contained in:
co-authored by
naturallaw777
parent
894707a87c
commit
947c04834d
+42
-13
@@ -6,6 +6,17 @@
|
||||
"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 ─────────────────────────────────────────────
|
||||
# Reads DDNS update URLs from the JSON store written by the Hub API and
|
||||
# invokes curl directly — no shell interpolation, no script execution.
|
||||
@@ -23,6 +34,7 @@
|
||||
NoNewPrivileges = true;
|
||||
ProtectSystem = "strict";
|
||||
ReadWritePaths = [ "/var/lib/njalla" ];
|
||||
ReadOnlyPaths = [ "/etc/sovran" ];
|
||||
ProtectHome = true;
|
||||
PrivateTmp = true;
|
||||
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
|
||||
@@ -42,15 +54,31 @@
|
||||
|
||||
# Install the Python runner script at build time so the service can find it.
|
||||
# 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 = ''
|
||||
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
|
||||
"""Sovran safe DDNS update runner.
|
||||
|
||||
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"
|
||||
ALLOWED_HOSTS = frozenset(["njal.la", "www.njal.la"])
|
||||
|
||||
try:
|
||||
with open(URLS_FILE) as f:
|
||||
@@ -58,7 +86,7 @@ try:
|
||||
if not isinstance(urls, list):
|
||||
raise ValueError("not a list")
|
||||
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
|
||||
public_ip = ""
|
||||
@@ -68,26 +96,27 @@ try:
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
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
|
||||
except Exception:
|
||||
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:
|
||||
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
|
||||
# Substitute ''${IP} placeholder then validate through production validator.
|
||||
# After substitution there must be no $ left; _validate_ddns_url rejects
|
||||
# any remaining $ expression.
|
||||
url = raw_url.replace("''${IP}", public_ip)
|
||||
_validate_ddns_url(url)
|
||||
subprocess.run(
|
||||
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
|
||||
timeout=20, check=False,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception:
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
PYEOF
|
||||
chmod 0500 /var/lib/sovran/ddns-update.py
|
||||
|
||||
@@ -6,13 +6,14 @@ 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)
|
||||
--unit / -u <name> must be one of the explicitly approved service units
|
||||
--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
|
||||
|
||||
At least one ``--unit`` flag is required; whole-journal queries are rejected.
|
||||
All other flags, paths, directories, roots, namespaces, and output
|
||||
destinations are rejected with a non-zero exit code.
|
||||
"""
|
||||
@@ -23,9 +24,14 @@ import sys
|
||||
|
||||
# ── Allowlists ────────────────────────────────────────────────────────────────
|
||||
|
||||
_ALLOWED_UNITS_RE = re.compile(
|
||||
r'^[a-zA-Z0-9@._\-]+\.(service|socket|timer|target|mount|path|slice|scope)$'
|
||||
)
|
||||
# Explicit approved units. Only these four services may be queried through
|
||||
# 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([
|
||||
"0", "1", "2", "3", "4", "5", "6", "7",
|
||||
@@ -50,8 +56,11 @@ def _die(msg: str) -> None:
|
||||
|
||||
|
||||
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)")
|
||||
if val not in _APPROVED_UNITS:
|
||||
_die(
|
||||
f"rejected unit name: {val!r} "
|
||||
f"(allowed: {', '.join(sorted(_APPROVED_UNITS))})"
|
||||
)
|
||||
return val
|
||||
|
||||
|
||||
@@ -86,6 +95,7 @@ def _validate_output(val: str) -> str:
|
||||
def main() -> None:
|
||||
args = sys.argv[1:]
|
||||
cmd = ["journalctl"]
|
||||
unit_count = 0
|
||||
|
||||
i = 0
|
||||
while i < len(args):
|
||||
@@ -96,8 +106,10 @@ def main() -> None:
|
||||
if i >= len(args):
|
||||
_die("--unit requires a value")
|
||||
cmd += ["--unit", _validate_unit(args[i])]
|
||||
unit_count += 1
|
||||
elif arg.startswith("--unit="):
|
||||
cmd += ["--unit", _validate_unit(arg[len("--unit="):])]
|
||||
unit_count += 1
|
||||
|
||||
elif arg in ("--lines", "-n"):
|
||||
i += 1
|
||||
@@ -149,8 +161,11 @@ def main() -> None:
|
||||
|
||||
i += 1
|
||||
|
||||
if not cmd[1:]:
|
||||
_die("at least one flag is required (try --unit <name>)")
|
||||
if unit_count == 0:
|
||||
_die(
|
||||
"at least one --unit flag is required; "
|
||||
f"allowed units: {', '.join(sorted(_APPROVED_UNITS))}"
|
||||
)
|
||||
|
||||
result = subprocess.run(cmd)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
@@ -63,11 +63,11 @@
|
||||
{
|
||||
users = [ "sovran-support" ];
|
||||
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 bitcoind.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 bitcoind.service"; options = [ "NOPASSWD" ]; }
|
||||
{ command = "/run/current-system/sw/bin/systemctl status lnd.service"; options = [ "NOPASSWD" ]; }
|
||||
|
||||
Reference in New Issue
Block a user