Files
OpenVPN-Monitoring-Simple/APP_PROFILER/services/validation.py
T
iclaoudezinandClaude Sonnet 5.5 05f44b9928 Profiler: validate server/PKI settings before they reach OpenVPN config
- Schema validators on the update models (ports, subnet/mask, routes,
  DNS, public host, loopback-only management address, MTU/MSS, script
  paths, PKI DN fields, key size and lifetimes).
- Script paths must be root-owned, non-writable files directly inside
  /etc/openvpn/scripts (services/validation.py).
- Generators refuse values with newlines, quotes, backslashes or control
  characters; router maps validation errors to HTTP 400.
- Add change record and links.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 12:25:38 +00:00

72 lines
2.4 KiB
Python

"""Input validation helpers for settings that end up in generated OpenVPN configs."""
import ipaddress
import os
import re
SCRIPTS_DIR = "/etc/openvpn/scripts"
SCRIPT_RE = re.compile(r"^/etc/openvpn/scripts/[A-Za-z0-9_.-]{1,64}$")
HOSTNAME_RE = re.compile(
r"^(?=.{1,253}$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$"
)
FORBIDDEN_CHARS = set('\n\r"\\\x00')
def assert_safe_scalar(name: str, value) -> None:
"""Reject values that could break out of a config line (newlines, quotes, backslashes, NUL)."""
if isinstance(value, (list, tuple)):
for item in value:
assert_safe_scalar(name, item)
return
if isinstance(value, str) and (FORBIDDEN_CHARS & set(value) or any(ord(c) < 32 for c in value)):
raise ValueError(f"Unsafe characters in '{name}'")
def valid_netmask(mask: str) -> bool:
try:
ipaddress.IPv4Network(f"0.0.0.0/{mask}")
return True
except ValueError:
return False
def valid_route(route: str) -> bool:
"""'a.b.c.d/nn' or 'a.b.c.d m.m.m.m'."""
parts = route.split()
try:
if len(parts) == 1:
ipaddress.IPv4Network(parts[0], strict=False)
return True
if len(parts) == 2:
ipaddress.IPv4Address(parts[0])
return valid_netmask(parts[1])
except ValueError:
pass
return False
def valid_host(value: str) -> bool:
try:
ipaddress.ip_address(value)
return True
except ValueError:
return bool(HOSTNAME_RE.match(value))
def check_script(path: str) -> None:
"""A connect/disconnect script must be a root-owned, non-writable file inside SCRIPTS_DIR."""
if not path:
return
if not SCRIPT_RE.match(path):
raise ValueError(f"Script path must match {SCRIPTS_DIR}/<name>")
real = os.path.realpath(path)
if os.path.dirname(real) != SCRIPTS_DIR:
raise ValueError("Script must reside directly in " + SCRIPTS_DIR)
if not os.path.isfile(real):
raise ValueError("Script file does not exist")
st = os.stat(real)
if st.st_uid != 0 or st.st_mode & 0o022:
raise ValueError("Script must be owned by root and not writable by group/others")
dst = os.stat(SCRIPTS_DIR)
if dst.st_uid != 0 or dst.st_mode & 0o022:
raise ValueError(SCRIPTS_DIR + " must be owned by root and not writable by group/others")