fix: harden SAC security per audit (0.4.15)
Close audit findings: anti-spoof client IP for login rate limit, JWT/CORS enforce on startup, SSH host-key verification and sudo via stdin, optional WinRM HTTPS, SSE httpOnly cookie auth, ingest body limit, ILIKE escaping, and HMAC API key hashing with legacy SHA-256 fallback.
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
"""Resolve client IP behind reverse proxy (nginx $proxy_add_x_forwarded_for)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
|
||||
def client_ip_from_request(request: Request) -> str:
|
||||
"""Client IP for rate limiting.
|
||||
|
||||
nginx with ``proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for``
|
||||
appends the real peer address as the *last* hop. Spoofed values in the
|
||||
incoming header therefore cannot replace the actual client IP.
|
||||
"""
|
||||
if request.client and request.client.host:
|
||||
direct = request.client.host.strip()
|
||||
else:
|
||||
direct = "unknown"
|
||||
|
||||
forwarded = (request.headers.get("x-forwarded-for") or "").strip()
|
||||
if not forwarded:
|
||||
return direct[:64]
|
||||
|
||||
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
|
||||
if not parts:
|
||||
return direct[:64]
|
||||
return parts[-1][:64]
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Rate limit failed SAC UI logins + optional Telegram alert."""
|
||||
"""Rate limit failed SAC UI logins + optional Telegram alert."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.login_attempt import LoginAttempt
|
||||
from app.services.client_ip import client_ip_from_request
|
||||
from app.services.login_security_settings import (
|
||||
get_effective_login_security_config,
|
||||
is_ip_login_whitelisted,
|
||||
@@ -37,15 +38,6 @@ def get_login_rate_limit_config() -> LoginRateLimitConfig:
|
||||
)
|
||||
|
||||
|
||||
def client_ip_from_request(request: Request) -> str:
|
||||
forwarded = (request.headers.get("x-forwarded-for") or "").strip()
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()[:64]
|
||||
if request.client and request.client.host:
|
||||
return request.client.host[:64]
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _failure_count(db: Session, ip_address: str, *, since: datetime) -> int:
|
||||
return int(
|
||||
db.scalar(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""SSH connectivity and remote commands for Linux hosts."""
|
||||
"""SSH connectivity and remote commands for Linux hosts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,6 +6,7 @@ import re
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Host
|
||||
|
||||
SSH_MONITOR_UPDATE_SCRIPT = "/opt/scripts/update_ssh_monitor.sh"
|
||||
@@ -187,20 +188,55 @@ def _shell_single_quote(value: str) -> str:
|
||||
def _remote_shell_command(
|
||||
user: str,
|
||||
remote_cmd: str,
|
||||
password: str,
|
||||
*,
|
||||
need_root: bool = False,
|
||||
login_shell: bool = True,
|
||||
) -> str:
|
||||
"""Build remote shell command. Sudo only when need_root and user is not root."""
|
||||
) -> tuple[str, bool]:
|
||||
"""Build remote shell command. Returns (command, needs_sudo_password_on_stdin)."""
|
||||
safe_cmd = _shell_single_quote(remote_cmd)
|
||||
bash_flag = "lc" if login_shell else "c"
|
||||
if user == "root" or not need_root:
|
||||
return f"bash -{bash_flag} {safe_cmd}"
|
||||
return f"bash -{bash_flag} {safe_cmd}", False
|
||||
return f"sudo -S -p '' bash -{bash_flag} {safe_cmd}", True
|
||||
|
||||
safe_pw = _shell_single_quote(password)
|
||||
inner = f"printf '%s\\n' {safe_pw} | sudo -S -p '' bash -{bash_flag} {safe_cmd}"
|
||||
return f"bash -{bash_flag} {_shell_single_quote(inner)}"
|
||||
|
||||
def _configure_ssh_client(client, target: str) -> None:
|
||||
import paramiko
|
||||
|
||||
settings = get_settings()
|
||||
if settings.sac_ssh_auto_add_host_key:
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
return
|
||||
|
||||
client.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
client.load_system_host_keys()
|
||||
known_hosts = (settings.sac_ssh_known_hosts_file or "").strip()
|
||||
if known_hosts:
|
||||
try:
|
||||
client.load_host_keys(known_hosts)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _exec_remote_command(
|
||||
client,
|
||||
*,
|
||||
shell_cmd: str,
|
||||
password: str,
|
||||
needs_sudo_password: bool,
|
||||
command_timeout_sec: int,
|
||||
) -> tuple[int, str, str]:
|
||||
if needs_sudo_password:
|
||||
stdin, stdout, stderr = client.exec_command(shell_cmd, timeout=command_timeout_sec, get_pty=True)
|
||||
stdin.write(f"{password}\n")
|
||||
stdin.flush()
|
||||
stdin.channel.shutdown_write()
|
||||
else:
|
||||
_stdin, stdout, stderr = client.exec_command(shell_cmd, timeout=command_timeout_sec)
|
||||
exit_code = stdout.channel.recv_exit_status()
|
||||
out_text = _truncate_output(stdout.read().decode("utf-8", errors="replace"))
|
||||
err_text = _truncate_output(stderr.read().decode("utf-8", errors="replace"))
|
||||
return exit_code, out_text, err_text
|
||||
|
||||
|
||||
def run_ssh_command(
|
||||
@@ -224,10 +260,9 @@ def run_ssh_command(
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("paramiko is not installed on SAC server") from exc
|
||||
|
||||
shell_cmd = _remote_shell_command(
|
||||
shell_cmd, needs_sudo_password = _remote_shell_command(
|
||||
user,
|
||||
remote_cmd,
|
||||
password,
|
||||
need_root=need_root,
|
||||
login_shell=login_shell,
|
||||
)
|
||||
@@ -237,8 +272,8 @@ def run_ssh_command(
|
||||
|
||||
for attempt in range(1, _SSH_CONNECT_ATTEMPTS + 1):
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
_configure_ssh_client(client, target)
|
||||
_connect_ssh_client(
|
||||
client,
|
||||
target=target,
|
||||
@@ -246,10 +281,13 @@ def run_ssh_command(
|
||||
password=password,
|
||||
connect_timeout_sec=connect_timeout_sec,
|
||||
)
|
||||
_stdin, stdout, stderr = client.exec_command(shell_cmd, timeout=command_timeout_sec)
|
||||
exit_code = stdout.channel.recv_exit_status()
|
||||
out_text = _truncate_output(stdout.read().decode("utf-8", errors="replace"))
|
||||
err_text = _truncate_output(stderr.read().decode("utf-8", errors="replace"))
|
||||
exit_code, out_text, err_text = _exec_remote_command(
|
||||
client,
|
||||
shell_cmd=shell_cmd,
|
||||
password=password,
|
||||
needs_sudo_password=needs_sudo_password,
|
||||
command_timeout_sec=command_timeout_sec,
|
||||
)
|
||||
break
|
||||
except paramiko.AuthenticationException:
|
||||
return SshCommandResult(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""WinRM connectivity test for Windows hosts using domain admin credentials."""
|
||||
"""WinRM connectivity test for Windows hosts using domain admin credentials."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -92,13 +92,20 @@ def _winrm_session(
|
||||
|
||||
operation_timeout_sec = max(5, timeout_sec)
|
||||
read_timeout_sec = operation_timeout_sec + 15
|
||||
endpoint = f"http://{target}:5985/wsman"
|
||||
settings = get_settings()
|
||||
scheme = "https" if settings.sac_winrm_use_https else "http"
|
||||
port = 5986 if settings.sac_winrm_use_https else 5985
|
||||
endpoint = f"{scheme}://{target}:{port}/wsman"
|
||||
cert_validation = (settings.sac_winrm_server_cert_validation or "validate").strip().lower()
|
||||
if cert_validation not in {"validate", "ignore"}:
|
||||
cert_validation = "validate"
|
||||
session = winrm.Session(
|
||||
endpoint,
|
||||
auth=(user, password),
|
||||
transport="ntlm",
|
||||
read_timeout_sec=read_timeout_sec,
|
||||
operation_timeout_sec=operation_timeout_sec,
|
||||
server_cert_validation=cert_validation,
|
||||
)
|
||||
return session, winrm
|
||||
|
||||
@@ -540,7 +547,7 @@ def run_winrm_rdp_monitor_update(
|
||||
ok=False,
|
||||
message=(
|
||||
f"Failed to download RDP bundle on client: {download.message} "
|
||||
f"(URL: {bundle_url})"
|
||||
"(bundle URL omitted from logs)"
|
||||
),
|
||||
target=target,
|
||||
stdout="\n\n".join(part.strip() for part in [prep.stdout, download.stdout] if part.strip()),
|
||||
|
||||
Reference in New Issue
Block a user