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
@@ -0,0 +1,233 @@
|
||||
"""Sovran Hub — pure security validation helpers.
|
||||
|
||||
This module contains the dependency-light security helper functions used by
|
||||
the Hub server. Keeping them here allows tests to import and exercise the
|
||||
exact production implementations rather than maintaining separate copies.
|
||||
|
||||
All functions in this module depend only on the Python standard library.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import ipaddress
|
||||
import re
|
||||
import urllib.parse
|
||||
|
||||
# ── Nix string escaping ────────────────────────────────────────────────────────
|
||||
|
||||
def _nix_escape(value: str) -> str:
|
||||
"""Escape *value* for use inside a Nix double-quoted string literal.
|
||||
|
||||
Handles backslashes, double-quotes, newlines, carriage returns, tabs, and
|
||||
Nix-specific anti-quotation sequences (``${...}``). The returned value is
|
||||
safe to embed as ``"<returned_value>"`` in generated Nix source.
|
||||
"""
|
||||
value = value.replace("\\", "\\\\")
|
||||
value = value.replace('"', '\\"')
|
||||
value = value.replace("\n", "\\n")
|
||||
value = value.replace("\r", "\\r")
|
||||
value = value.replace("\t", "\\t")
|
||||
value = value.replace("${", "\\${")
|
||||
return value
|
||||
|
||||
|
||||
# ── Nostr npub validation (NIP-19 / Bech32) ───────────────────────────────────
|
||||
|
||||
# Fast pre-filter: "npub1" followed by exactly 58 lower-case bech32 characters.
|
||||
NPUB_RE = re.compile(r"^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$")
|
||||
|
||||
_BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
_BECH32_GENERATOR = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3)
|
||||
|
||||
|
||||
def _bech32_polymod(values: list[int]) -> int:
|
||||
chk = 1
|
||||
for value in values:
|
||||
top = chk >> 25
|
||||
chk = ((chk & 0x1FFFFFF) << 5) ^ value
|
||||
for i in range(5):
|
||||
if (top >> i) & 1:
|
||||
chk ^= _BECH32_GENERATOR[i]
|
||||
return chk
|
||||
|
||||
|
||||
def _bech32_hrp_expand(hrp: str) -> list[int]:
|
||||
return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
|
||||
|
||||
|
||||
def _bech32_create_checksum(hrp: str, data: list[int]) -> list[int]:
|
||||
values = _bech32_hrp_expand(hrp) + data
|
||||
polymod = _bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1
|
||||
return [(polymod >> (5 * (5 - i))) & 31 for i in range(6)]
|
||||
|
||||
|
||||
def _bech32_convertbits_decode(data: list[int]) -> list[int] | None:
|
||||
"""Convert a 5-bit integer sequence to 8-bit bytes, stripping padding."""
|
||||
acc = 0
|
||||
bits = 0
|
||||
ret: list[int] = []
|
||||
for value in data:
|
||||
acc = (acc << 5) | value
|
||||
bits += 5
|
||||
while bits >= 8:
|
||||
bits -= 8
|
||||
ret.append((acc >> bits) & 0xFF)
|
||||
if bits >= 5 or ((acc << (8 - bits)) & 0xFF):
|
||||
return None # invalid padding
|
||||
return ret
|
||||
|
||||
|
||||
def _bech32_decode(bech: str) -> tuple[str, bytes] | None:
|
||||
"""Decode a bech32 string. Returns ``(hrp, payload_bytes)`` or ``None``.
|
||||
|
||||
Verifies:
|
||||
- Lowercase-only (mixed case rejected per BIP-173).
|
||||
- Only valid bech32 charset characters.
|
||||
- Valid checksum.
|
||||
- Exactly one separator (``1``).
|
||||
- Minimum data part length (≥ 8 chars = 6 checksum + ≥ 2 data).
|
||||
"""
|
||||
if bech != bech.lower():
|
||||
return None # mixed case
|
||||
sep = bech.rfind("1")
|
||||
if sep < 1 or sep + 7 > len(bech):
|
||||
return None
|
||||
hrp = bech[:sep]
|
||||
data_part = bech[sep + 1:]
|
||||
if any(c not in _BECH32_CHARSET for c in data_part):
|
||||
return None
|
||||
decoded = [_BECH32_CHARSET.index(c) for c in data_part]
|
||||
if _bech32_polymod(_bech32_hrp_expand(hrp) + decoded) != 1:
|
||||
return None # bad checksum
|
||||
converted = _bech32_convertbits_decode(decoded[:-6])
|
||||
if converted is None:
|
||||
return None
|
||||
return hrp, bytes(converted)
|
||||
|
||||
|
||||
def _validate_npub(value: str) -> bool:
|
||||
"""Return ``True`` iff *value* is a valid NIP-19 Nostr npub.
|
||||
|
||||
Checks:
|
||||
- Lowercase ``npub`` HRP.
|
||||
- Valid bech32 charset (no uppercase, no invalid chars).
|
||||
- Valid bech32 checksum.
|
||||
- Exactly 32 decoded payload bytes (256-bit public key).
|
||||
- Retains the original regex as a fast pre-filter.
|
||||
"""
|
||||
if not NPUB_RE.fullmatch(value):
|
||||
return False
|
||||
result = _bech32_decode(value)
|
||||
if result is None:
|
||||
return False
|
||||
hrp, payload = result
|
||||
return hrp == "npub" and len(payload) == 32
|
||||
|
||||
|
||||
# ── DDNS URL validation ────────────────────────────────────────────────────────
|
||||
|
||||
_DDNS_URL_MAX_LEN = 2048
|
||||
_DDNS_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
|
||||
|
||||
# Allowlist: only the official Njal.la provider hostnames are accepted for
|
||||
# DDNS update URLs. Any other host would allow SSRF against the Hub's
|
||||
# internal network.
|
||||
_DDNS_ALLOWED_HOSTNAMES: frozenset[str] = frozenset(["njal.la", "www.njal.la"])
|
||||
|
||||
|
||||
def _validate_ddns_url(url: str) -> str:
|
||||
"""Validate *url* as a safe DDNS update URL and return it normalised.
|
||||
|
||||
Rules:
|
||||
- Must be a valid URL parseable by urllib.parse.
|
||||
- Scheme must be ``https`` (case-insensitive).
|
||||
- No userinfo (credentials must not be embedded in the URL).
|
||||
- No fragment.
|
||||
- No control characters.
|
||||
- Must not exceed ``_DDNS_URL_MAX_LEN`` bytes.
|
||||
- Hostname must be the exact Njal.la provider hostname (njal.la or www.njal.la).
|
||||
- Port must be absent or the default HTTPS port 443.
|
||||
- No percent-encoded null bytes.
|
||||
|
||||
Raises ``ValueError`` with a safe (non-secret) message on failure.
|
||||
"""
|
||||
if not url:
|
||||
raise ValueError("DDNS URL must not be empty")
|
||||
if len(url) > _DDNS_URL_MAX_LEN:
|
||||
raise ValueError("DDNS URL exceeds maximum length")
|
||||
if _DDNS_CONTROL_RE.search(url):
|
||||
raise ValueError("DDNS URL contains control characters")
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
except Exception:
|
||||
raise ValueError("DDNS URL could not be parsed")
|
||||
if parsed.scheme.lower() != "https":
|
||||
raise ValueError("DDNS URL must use the https scheme")
|
||||
if parsed.username or parsed.password:
|
||||
raise ValueError("DDNS URL must not contain credentials")
|
||||
if parsed.fragment:
|
||||
raise ValueError("DDNS URL must not contain a fragment")
|
||||
if parsed.port is not None and parsed.port != 443:
|
||||
raise ValueError("DDNS URL must use the default HTTPS port")
|
||||
hostname = parsed.hostname or ""
|
||||
if not hostname:
|
||||
raise ValueError("DDNS URL must contain a hostname")
|
||||
# Reject raw IP addresses
|
||||
try:
|
||||
ipaddress.ip_address(hostname)
|
||||
raise ValueError("DDNS URL hostname must not be a raw IP address")
|
||||
except ValueError as exc:
|
||||
if "raw IP" in str(exc):
|
||||
raise
|
||||
# Allowlist: only Njal.la
|
||||
if hostname.lower() not in _DDNS_ALLOWED_HOSTNAMES:
|
||||
raise ValueError(
|
||||
f"DDNS URL hostname is not an allowed Njal.la host "
|
||||
f"(got {hostname!r})"
|
||||
)
|
||||
if "%00" in url.lower():
|
||||
raise ValueError("DDNS URL must not contain encoded null bytes")
|
||||
return url
|
||||
|
||||
|
||||
# ── SSH public-key validation ─────────────────────────────────────────────────
|
||||
|
||||
_SSH_PUBKEY_ALGORITHMS = frozenset([
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"sk-ssh-ed25519@openssh.com",
|
||||
])
|
||||
|
||||
|
||||
def _validate_ssh_pubkey(key: str) -> str:
|
||||
"""Validate *key* as a single OpenSSH public key and return it normalised.
|
||||
|
||||
Accepts only single-line keys with a supported algorithm, valid base64
|
||||
payload, and an optional comment. Rejects options, multiple lines,
|
||||
control characters, and unsupported algorithms.
|
||||
|
||||
Raises ``ValueError`` with a safe message on failure.
|
||||
"""
|
||||
key = key.strip()
|
||||
if not key:
|
||||
raise ValueError("SSH public key must not be empty")
|
||||
if _DDNS_CONTROL_RE.search(key):
|
||||
raise ValueError("SSH public key contains control characters")
|
||||
if "\n" in key or "\r" in key:
|
||||
raise ValueError("SSH public key must be a single line")
|
||||
parts = key.split()
|
||||
if len(parts) < 2:
|
||||
raise ValueError("SSH public key is malformed")
|
||||
algo, b64 = parts[0], parts[1]
|
||||
if algo not in _SSH_PUBKEY_ALGORITHMS:
|
||||
raise ValueError(f"Unsupported SSH key algorithm: {algo!r}")
|
||||
try:
|
||||
decoded = base64.b64decode(b64, validate=True)
|
||||
except Exception:
|
||||
raise ValueError("SSH public key payload is not valid base64")
|
||||
if len(decoded) < 20:
|
||||
raise ValueError("SSH public key payload is too short")
|
||||
return key
|
||||
+225
-113
@@ -37,6 +37,19 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from .config import load_config, load_versions
|
||||
from . import systemctl as sysctl
|
||||
from . import nwc_hub_manager as _nwc_mgr
|
||||
from .security_helpers import (
|
||||
_nix_escape,
|
||||
NPUB_RE,
|
||||
_validate_npub,
|
||||
_validate_ddns_url,
|
||||
_validate_ssh_pubkey,
|
||||
_DDNS_URL_MAX_LEN,
|
||||
_DDNS_CONTROL_RE,
|
||||
_DDNS_ALLOWED_HOSTNAMES,
|
||||
_SSH_PUBKEY_ALGORITHMS,
|
||||
_bech32_decode,
|
||||
_bech32_convertbits_decode,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -84,21 +97,8 @@ NOSTR_NPUB_FILE = "/var/lib/secrets/nostr_npub"
|
||||
NJALLA_SCRIPT = "/var/lib/njalla/njalla.sh"
|
||||
NJALLA_DDNS_URLS_FILE = "/var/lib/njalla/ddns_urls.json"
|
||||
|
||||
# Nostr npub validation: "npub1" followed by exactly 58 bech32 characters
|
||||
NPUB_RE = re.compile(r"^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$")
|
||||
|
||||
# Accepted SSH public-key algorithms for support sessions
|
||||
_SSH_PUBKEY_ALGORITHMS = frozenset([
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"sk-ssh-ed25519@openssh.com",
|
||||
])
|
||||
|
||||
# DDNS URL: HTTPS only, no credentials, no control chars, max 2048 bytes
|
||||
_DDNS_URL_MAX_LEN = 2048
|
||||
_DDNS_CONTROL_RE = re.compile(r"[\x00-\x1f\x7f]")
|
||||
# Nostr npub validation, SSH pubkey validation, DDNS URL validation, and
|
||||
# Nix escaping are imported from security_helpers (single source of truth).
|
||||
|
||||
# Systemd service that rewrites the Sovran-managed /etc/hosts loopback block
|
||||
SOVRAN_HOSTS_SERVICE = "sovran-hosts-update.service"
|
||||
@@ -159,6 +159,11 @@ SUPPORT_STATUS_FILE = "/var/lib/secrets/support-session-status"
|
||||
|
||||
SUPPORT_KEY_COMMENT = "sovransystemsos-support"
|
||||
|
||||
# Maximum duration for a support session in seconds (24 hours).
|
||||
# After this time the session is automatically expired on startup and on any
|
||||
# support status/wallet operation.
|
||||
SUPPORT_SESSION_MAX_SECONDS = 86400 # 24 hours
|
||||
|
||||
# Dedicated restricted support user (non-root) for wallet privacy
|
||||
SUPPORT_USER = "sovran-support"
|
||||
SUPPORT_USER_HOME = "/var/lib/sovran-support"
|
||||
@@ -528,97 +533,6 @@ _DICEWARE_WORDS = [
|
||||
]
|
||||
|
||||
|
||||
def _nix_escape(value: str) -> str:
|
||||
"""Escape *value* for use inside a Nix double-quoted string literal.
|
||||
|
||||
Handles backslashes, double-quotes, newlines, carriage returns, tabs, and
|
||||
Nix-specific anti-quotation sequences (``${...}``). The returned value is
|
||||
safe to embed as ``"<returned_value>"`` in generated Nix source.
|
||||
"""
|
||||
value = value.replace("\\", "\\\\")
|
||||
value = value.replace('"', '\\"')
|
||||
value = value.replace("\n", "\\n")
|
||||
value = value.replace("\r", "\\r")
|
||||
value = value.replace("\t", "\\t")
|
||||
value = value.replace("${", "\\${")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_ddns_url(url: str) -> str:
|
||||
"""Validate *url* as a safe DDNS update URL and return it normalised.
|
||||
|
||||
Rules:
|
||||
- Must be a valid URL parseable by urllib.parse.
|
||||
- Scheme must be ``https`` (case-insensitive).
|
||||
- No userinfo (credentials must not be embedded in the URL).
|
||||
- No fragment.
|
||||
- No control characters.
|
||||
- Must not exceed ``_DDNS_URL_MAX_LEN`` bytes.
|
||||
- Hostname must be present and not a raw IP address.
|
||||
|
||||
Raises ``ValueError`` with a safe (non-secret) message on failure.
|
||||
"""
|
||||
if not url:
|
||||
raise ValueError("DDNS URL must not be empty")
|
||||
if len(url) > _DDNS_URL_MAX_LEN:
|
||||
raise ValueError("DDNS URL exceeds maximum length")
|
||||
if _DDNS_CONTROL_RE.search(url):
|
||||
raise ValueError("DDNS URL contains control characters")
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
except Exception:
|
||||
raise ValueError("DDNS URL could not be parsed")
|
||||
if parsed.scheme.lower() != "https":
|
||||
raise ValueError("DDNS URL must use the https scheme")
|
||||
if parsed.username or parsed.password:
|
||||
raise ValueError("DDNS URL must not contain credentials")
|
||||
if parsed.fragment:
|
||||
raise ValueError("DDNS URL must not contain a fragment")
|
||||
hostname = parsed.hostname or ""
|
||||
if not hostname:
|
||||
raise ValueError("DDNS URL must contain a hostname")
|
||||
# Reject raw IP addresses — DDNS providers use hostnames
|
||||
try:
|
||||
ipaddress.ip_address(hostname)
|
||||
raise ValueError("DDNS URL hostname must not be a raw IP address")
|
||||
except ValueError as exc:
|
||||
if "raw IP" in str(exc):
|
||||
raise
|
||||
return url
|
||||
|
||||
|
||||
def _validate_ssh_pubkey(key: str) -> str:
|
||||
"""Validate *key* as a single OpenSSH public key and return it normalised.
|
||||
|
||||
Accepts only single-line keys with a supported algorithm, valid base64
|
||||
payload, and an optional comment. Rejects options, multiple lines,
|
||||
control characters, and unsupported algorithms.
|
||||
|
||||
Raises ``ValueError`` with a safe message on failure.
|
||||
"""
|
||||
key = key.strip()
|
||||
if not key:
|
||||
raise ValueError("SSH public key must not be empty")
|
||||
if _DDNS_CONTROL_RE.search(key):
|
||||
raise ValueError("SSH public key contains control characters")
|
||||
if "\n" in key or "\r" in key:
|
||||
raise ValueError("SSH public key must be a single line")
|
||||
parts = key.split()
|
||||
if len(parts) < 2:
|
||||
raise ValueError("SSH public key is malformed")
|
||||
algo, b64 = parts[0], parts[1]
|
||||
if algo not in _SSH_PUBKEY_ALGORITHMS:
|
||||
raise ValueError(f"Unsupported SSH key algorithm: {algo!r}")
|
||||
# Validate base64 payload
|
||||
try:
|
||||
decoded = base64.b64decode(b64, validate=True)
|
||||
except Exception:
|
||||
raise ValueError("SSH public key payload is not valid base64")
|
||||
if len(decoded) < 20:
|
||||
raise ValueError("SSH public key payload is too short")
|
||||
return key
|
||||
|
||||
|
||||
def _generate_diceware_password() -> str:
|
||||
"""Generate a human-readable diceware-style passphrase: word-word-word-N."""
|
||||
import secrets as _secrets
|
||||
@@ -1987,8 +1901,20 @@ def _write_hub_overrides(features: dict, nostr_npub: str | None, timezone: str |
|
||||
return
|
||||
content = content[:last_brace] + "\n" + hub_block + content[last_brace:]
|
||||
|
||||
with open(CUSTOM_NIX, "w") as f:
|
||||
f.write(content)
|
||||
# Atomic write: write to a temp file next to custom.nix then rename so the
|
||||
# file is never left in a partially-written state if the process is killed.
|
||||
nix_dir = os.path.dirname(CUSTOM_NIX) or "."
|
||||
fd, tmp_path = tempfile.mkstemp(dir=nix_dir, prefix=".custom_nix_tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(content)
|
||||
os.replace(tmp_path, CUSTOM_NIX)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _migrate_strip_deprecated_features() -> None:
|
||||
@@ -2055,6 +1981,7 @@ def _is_sshd_feature_enabled() -> bool:
|
||||
|
||||
def _is_support_active() -> bool:
|
||||
"""Check if a per-session support key is currently installed."""
|
||||
_expire_support_if_stale()
|
||||
try:
|
||||
with open(SUPPORT_USER_AUTH_KEYS, "r") as f:
|
||||
return bool(f.read().strip())
|
||||
@@ -2062,6 +1989,36 @@ def _is_support_active() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _expire_support_if_stale() -> bool:
|
||||
"""If an active support session has passed its expiry time, disable it.
|
||||
|
||||
Returns ``True`` if a session was expired, ``False`` otherwise.
|
||||
This is called automatically from ``_is_support_active()`` and from
|
||||
startup, so expiry is enforced even if the user never calls
|
||||
``/api/support/disable``.
|
||||
"""
|
||||
try:
|
||||
with open(SUPPORT_STATUS_FILE, "r") as f:
|
||||
info = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return False
|
||||
expires_at = info.get("expires_at")
|
||||
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:
|
||||
"""Read support session metadata."""
|
||||
try:
|
||||
@@ -2212,6 +2169,75 @@ def _get_wallet_unlock_info() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
# The exact legacy fleet-wide support key comment used in old deployments.
|
||||
# This is the only key that the upgrade migration will remove from root's
|
||||
# authorized_keys. All other keys (admin keys, etc.) are preserved.
|
||||
_LEGACY_ROOT_SUPPORT_KEY_COMMENT = "sovransystemsos-support"
|
||||
|
||||
|
||||
def _remove_legacy_root_support_key() -> bool:
|
||||
"""One-time upgrade migration: remove the old fleet-wide support key from root.
|
||||
|
||||
Reads ``/root/.ssh/authorized_keys``, removes only lines whose comment
|
||||
field exactly matches ``_LEGACY_ROOT_SUPPORT_KEY_COMMENT``, and writes the
|
||||
file back atomically. All other keys and blank/comment lines are
|
||||
preserved unchanged.
|
||||
|
||||
Returns ``True`` if the file was updated, ``False`` if unchanged or absent.
|
||||
"""
|
||||
try:
|
||||
with open(AUTHORIZED_KEYS, "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")
|
||||
# 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:
|
||||
"""Install a per-session SSH public key for the restricted support user.
|
||||
|
||||
@@ -2248,6 +2274,7 @@ def _enable_support(pubkey: str) -> bool:
|
||||
session_info = {
|
||||
"enabled_at": time.time(),
|
||||
"enabled_at_human": time.strftime("%Y-%m-%d %H:%M:%S %Z"),
|
||||
"expires_at": time.time() + SUPPORT_SESSION_MAX_SECONDS,
|
||||
"use_restricted_user": use_restricted_user,
|
||||
"wallet_protected": use_restricted_user,
|
||||
"acl_applied": acl_applied,
|
||||
@@ -4296,8 +4323,8 @@ async def api_features_toggle(req: FeatureToggleRequest):
|
||||
if req.feature == "haven":
|
||||
npub = (req.extra or {}).get("nostr_npub", "").strip()
|
||||
if npub:
|
||||
if not NPUB_RE.fullmatch(npub):
|
||||
raise HTTPException(status_code=400, detail="Invalid Nostr npub (must be npub1 followed by 58 bech32 characters)")
|
||||
if not _validate_npub(npub):
|
||||
raise HTTPException(status_code=400, detail="Invalid Nostr npub (must be npub1 followed by 58 bech32 characters with valid checksum)")
|
||||
nostr_npub = npub
|
||||
elif not nostr_npub:
|
||||
raise HTTPException(status_code=400, detail="nostr_npub is required for Haven")
|
||||
@@ -4313,8 +4340,8 @@ async def api_features_toggle(req: FeatureToggleRequest):
|
||||
# Persist any extra fields (nostr_npub)
|
||||
new_npub = (req.extra or {}).get("nostr_npub", "").strip()
|
||||
if new_npub:
|
||||
if not NPUB_RE.fullmatch(new_npub):
|
||||
raise HTTPException(status_code=400, detail="Invalid Nostr npub (must be npub1 followed by 58 bech32 characters)")
|
||||
if not _validate_npub(new_npub):
|
||||
raise HTTPException(status_code=400, detail="Invalid Nostr npub (must be npub1 followed by 58 bech32 characters with valid checksum)")
|
||||
nostr_npub = new_npub
|
||||
try:
|
||||
os.makedirs(os.path.dirname(NOSTR_NPUB_FILE), exist_ok=True)
|
||||
@@ -4437,6 +4464,79 @@ def _validate_safe_name(name: str) -> bool:
|
||||
|
||||
_NJALLA_HEADER_SENTINEL = "# SOVRAN_NJALLA_HEADER"
|
||||
|
||||
# Narrow regex matching only the exact curl DDNS pattern written by old Hub
|
||||
# versions: curl <https://njal.la/...> with optional flags but NO semicolons,
|
||||
# shell expansions, backticks, or pipe characters. Anything else is rejected.
|
||||
_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:
|
||||
"""Safely migrate legacy curl DDNS lines from ``njalla.sh`` to JSON store.
|
||||
|
||||
Reads ``njalla.sh`` without executing or sourcing it. Parses only the
|
||||
exact narrow curl-pattern lines written by old Hub versions. Any line
|
||||
that does not match the narrow pattern (including potential injected
|
||||
commands) is silently discarded — never executed or logged.
|
||||
|
||||
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
|
||||
no longer be executed by cron or any other mechanism. If the script does
|
||||
not exist or the JSON store already has entries, this is a no-op.
|
||||
"""
|
||||
try:
|
||||
with open(NJALLA_SCRIPT, "r") as f:
|
||||
content = f.read()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError:
|
||||
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.
|
||||
@@ -4553,7 +4653,7 @@ def _run_njalla_ddns() -> None:
|
||||
# Replace the placeholder with the validated IP (safe string replacement)
|
||||
url = raw_url.replace("${IP}", public_ip) if public_ip else raw_url
|
||||
subprocess.run(
|
||||
["curl", "--silent", "--max-time", "15", "--fail", url],
|
||||
["curl", "--silent", "--max-time", "15", "--fail", "--no-location", url],
|
||||
timeout=20, check=False,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
@@ -6168,6 +6268,18 @@ async def _startup_domain_reachability():
|
||||
_domain_reachability_task = asyncio.create_task(_background_domain_reachability_checker())
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _startup_security_migrations():
|
||||
"""Run one-time security upgrade migrations on every server start."""
|
||||
loop = asyncio.get_event_loop()
|
||||
# Migrate legacy njalla.sh DDNS lines to JSON store and archive the script
|
||||
await loop.run_in_executor(None, _migrate_legacy_njalla_script)
|
||||
# Remove the legacy fleet-wide support key from /root/.ssh/authorized_keys
|
||||
await loop.run_in_executor(None, _remove_legacy_root_support_key)
|
||||
# Expire any support session that has passed its deadline
|
||||
await loop.run_in_executor(None, _expire_support_if_stale)
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def _shutdown_domain_reachability():
|
||||
"""Stop the background domain reachability checker."""
|
||||
|
||||
@@ -110,12 +110,26 @@ function renderSupportInactive() {
|
||||
'</div>',
|
||||
'<div class="support-steps"><div class="support-steps-title">What happens:</div><ol>',
|
||||
'<li>A restricted <code>sovran-support</code> user is created with limited access</li>',
|
||||
'<li>Our SSH key is added only to that restricted account</li>',
|
||||
'<li>Support\'s SSH key is added only to that restricted account — not to root</li>',
|
||||
'<li>Wallet files are locked via access controls — not visible to support</li>',
|
||||
'<li>You control if and when wallet access is granted (time-limited)</li>',
|
||||
'<li>All session events are logged for your audit</li>',
|
||||
'<li>Access expires automatically after 24 hours</li>',
|
||||
'</ol></div>',
|
||||
'<div class="support-key-section">',
|
||||
'<label class="support-key-label" for="support-ssh-pubkey">',
|
||||
'<strong>Paste the support SSH public key provided by Sovran Systems:</strong>',
|
||||
'</label>',
|
||||
'<textarea id="support-ssh-pubkey" class="support-key-input" rows="3" ',
|
||||
'placeholder="ssh-ed25519 AAAA… support-session" ',
|
||||
'spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"></textarea>',
|
||||
'<p class="support-key-hint">',
|
||||
'The key must start with <code>ssh-ed25519</code> or <code>ecdsa-sha2-nistp256</code>. ',
|
||||
'Do not paste your own private key — only paste the one-time public key sent by Sovran Systems support.',
|
||||
'</p>',
|
||||
'</div>',
|
||||
'<button class="btn support-btn-enable" id="btn-support-enable">Enable Support Access</button>',
|
||||
'<p id="support-key-error" class="support-key-error" style="display:none;color:#c0392b;margin-top:8px;"></p>',
|
||||
'<p class="support-fine-print">You can revoke access at any time. When you end the session, you\'ll be able to disable SSH to return to the default secure state.</p>',
|
||||
'</div>',
|
||||
].join("");
|
||||
@@ -227,16 +241,43 @@ function renderSupportRemoved(verified) {
|
||||
|
||||
async function enableSupport() {
|
||||
var btn = document.getElementById("btn-support-enable");
|
||||
var errEl = document.getElementById("support-key-error");
|
||||
var textarea = document.getElementById("support-ssh-pubkey");
|
||||
if (errEl) { errEl.style.display = "none"; errEl.textContent = ""; }
|
||||
|
||||
var sshKey = textarea ? textarea.value.trim() : "";
|
||||
if (!sshKey) {
|
||||
if (errEl) { errEl.textContent = "Please paste the SSH public key provided by Sovran Systems support."; errEl.style.display = "block"; }
|
||||
return;
|
||||
}
|
||||
// Client-side pre-validation: key must start with a known algorithm prefix
|
||||
var validPrefixes = ["ssh-ed25519 ", "ecdsa-sha2-nistp256 ", "ecdsa-sha2-nistp384 ", "ecdsa-sha2-nistp521 ", "sk-ssh-ed25519@openssh.com "];
|
||||
var hasValidPrefix = validPrefixes.some(function(p) { return sshKey.startsWith(p); });
|
||||
if (!hasValidPrefix) {
|
||||
if (errEl) { errEl.textContent = "Invalid key format. The key must start with ssh-ed25519 or ecdsa-sha2-nistp256. Do not paste a private key."; errEl.style.display = "block"; }
|
||||
return;
|
||||
}
|
||||
if (sshKey.indexOf("\n") !== -1) {
|
||||
if (errEl) { errEl.textContent = "The key must be a single line. Please check the pasted value."; errEl.style.display = "block"; }
|
||||
return;
|
||||
}
|
||||
|
||||
if (btn) { btn.disabled = true; btn.textContent = "Enabling…"; }
|
||||
try {
|
||||
await apiFetch("/api/support/enable", { method: "POST" });
|
||||
await apiFetch("/api/support/enable", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ssh_public_key: sshKey }),
|
||||
});
|
||||
var status = await apiFetch("/api/support/status");
|
||||
_supportStatus = status;
|
||||
_supportEnabledAt = status.enabled_at;
|
||||
renderSupportActive(status);
|
||||
} catch (err) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = "Enable Support Access"; }
|
||||
alert("Failed to enable support access. Please try again.");
|
||||
var detail = (err && err.detail) ? err.detail : "Failed to enable support access. Please check the key and try again.";
|
||||
if (errEl) { errEl.textContent = detail; errEl.style.display = "block"; }
|
||||
else { alert(detail); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user