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>
This commit is contained in:
1 parent
5de0501cbc
commit
05f44b9928
7 files changed
+279
-6
No files matched your search
@@ -4,6 +4,7 @@ from jinja2 import Environment, FileSystemLoader
|
||||
from sqlalchemy.orm import Session
|
||||
from .config import get_system_settings, get_pki_settings
|
||||
from .pki import PKI_DIR
|
||||
from . import validation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -26,7 +27,7 @@ def generate_server_config(db: Session, output_path: str = "server.conf"):
|
||||
file_crl_path = os.path.join(PKI_DIR, "crl.pem")
|
||||
|
||||
# Render template
|
||||
config_content = template.render(
|
||||
ctx = dict(
|
||||
protocol=settings.protocol,
|
||||
port=settings.port,
|
||||
ca_path=file_ca_path,
|
||||
@@ -53,7 +54,13 @@ def generate_server_config(db: Session, output_path: str = "server.conf"):
|
||||
tun_mtu=settings.tun_mtu,
|
||||
mssfix=settings.mssfix
|
||||
)
|
||||
|
||||
for _name, _val in ctx.items():
|
||||
validation.assert_safe_scalar(_name, _val)
|
||||
if settings.user_defined_cdscripts:
|
||||
validation.check_script(settings.connect_script)
|
||||
validation.check_script(settings.disconnect_script)
|
||||
config_content = template.render(**ctx)
|
||||
|
||||
# Write to file
|
||||
with open(output_path, "w") as f:
|
||||
f.write(config_content)
|
||||
@@ -97,7 +104,9 @@ def generate_client_config(db: Session, username: str, output_path: str):
|
||||
remote_ip = get_public_ip()
|
||||
|
||||
template = env.get_template("client.ovpn.j2")
|
||||
|
||||
|
||||
validation.assert_safe_scalar("remote_ip", remote_ip)
|
||||
validation.assert_safe_scalar("protocol", settings.protocol)
|
||||
config_content = template.render(
|
||||
protocol=settings.protocol,
|
||||
remote_ip=remote_ip,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""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")
|
||||
Reference in new issue
Block a user