From 55bec04603f8f96c0c1fbfb37256db3a690fe8fd Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Fri, 24 Apr 2026 00:48:20 -0400 Subject: [PATCH] Add port and IP validation across all service config forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI: validateServiceConfig() checks all port fields (1–65535) and WireGuard address (IP/CIDR) on every keystroke; Save button is disabled and saveService() guards against any field errors. API: update_config() rejects out-of-range port values and invalid WireGuard address before persisting, returning 400 with a clear field path (e.g. email.smtp_port). Co-Authored-By: Claude Sonnet 4.6 --- api/app.py | 32 ++++++++++++ webui/src/pages/Settings.jsx | 95 +++++++++++++++++++++++++++++------- 2 files changed, 109 insertions(+), 18 deletions(-) diff --git a/api/app.py b/api/app.py index 62bcbe5..3cf4405 100644 --- a/api/app.py +++ b/api/app.py @@ -454,6 +454,38 @@ def update_config(): except ValueError as _e: return jsonify({'error': f'Invalid ip_range: {_e}'}), 400 + # Validate service config port and IP fields + _port_fields = { + 'network': ['dns_port'], + 'wireguard': ['port'], + 'email': ['smtp_port', 'submission_port', 'imap_port', 'webmail_port'], + 'calendar': ['port'], + 'files': ['port', 'manager_port'], + } + for _svc, _fields in _port_fields.items(): + if _svc not in data: + continue + _svc_data = data[_svc] + if not isinstance(_svc_data, dict): + continue + for _f in _fields: + if _f in _svc_data and _svc_data[_f] is not None and _svc_data[_f] != '': + try: + _p = int(_svc_data[_f]) + if not (1 <= _p <= 65535): + raise ValueError() + except (ValueError, TypeError): + return jsonify({'error': f'{_svc}.{_f} must be an integer between 1 and 65535'}), 400 + # Validate WireGuard address (must be valid IP/CIDR) + if 'wireguard' in data and isinstance(data['wireguard'], dict): + _addr = data['wireguard'].get('address') + if _addr: + import ipaddress as _ipa2 + try: + _ipa2.ip_interface(_addr) + except ValueError as _e: + return jsonify({'error': f'wireguard.address is not a valid IP/CIDR: {_e}'}), 400 + # Capture old identity and service configs BEFORE saving, for change detection old_identity = dict(config_manager.configs.get('_identity', {})) old_svc_configs = { diff --git a/webui/src/pages/Settings.jsx b/webui/src/pages/Settings.jsx index acef2e3..a84a9f4 100644 --- a/webui/src/pages/Settings.jsx +++ b/webui/src/pages/Settings.jsx @@ -69,6 +69,60 @@ function Section({ icon: Icon, title, children, collapsible = false, defaultOpen ); } +// ── Validation utilities ────────────────────────────────────────────────────── + +function isValidPort(v) { + const n = Number(v); + return Number.isInteger(n) && n >= 1 && n <= 65535; +} + +function isValidIp(v) { + if (!v || !v.trim()) return false; + const m = v.trim().match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); + if (!m) return false; + return m.slice(1, 5).map(Number).every(n => n >= 0 && n <= 255); +} + +function isValidIpCidr(v) { + if (!v || !v.trim()) return false; + const m = v.trim().match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)\/(\d+)$/); + if (!m) return false; + const [, a, b, c, d, p] = m.map(Number); + return [a, b, c, d].every(n => n >= 0 && n <= 255) && p >= 0 && p <= 32; +} + +const E_PORT = 'Must be 1–65535'; +const E_IP = 'Must be a valid IP address'; +const E_CIDR = 'Must be a valid IP/CIDR (e.g. 10.0.0.1/24)'; + +function validateServiceConfig(key, data) { + const errors = {}; + const port = (field) => { + if (data[field] !== undefined && data[field] !== '' && !isValidPort(data[field])) + errors[field] = E_PORT; + }; + if (key === 'network') { + port('dns_port'); + if (data.dhcp_range) { + const parts = data.dhcp_range.split(','); + if (parts[0]?.trim() && !isValidIp(parts[0].trim())) + errors.dhcp_range = `Start IP is invalid`; + else if (parts[1]?.trim() && !isValidIp(parts[1].trim())) + errors.dhcp_range = `End IP is invalid`; + } + } + if (key === 'wireguard') { + port('port'); + if (data.address && !isValidIpCidr(data.address)) errors.address = E_CIDR; + } + if (key === 'email') { + port('smtp_port'); port('submission_port'); port('imap_port'); port('webmail_port'); + } + if (key === 'calendar') port('port'); + if (key === 'files') { port('port'); port('manager_port'); } + return errors; +} + // ── RFC-1918 validation ─────────────────────────────────────────────────────── function isRFC1918Cidr(cidr) { @@ -182,13 +236,13 @@ function TagList({ value = [], onChange, placeholder }) { // ── Service config forms ────────────────────────────────────────────────────── -function NetworkForm({ data, onChange }) { +function NetworkForm({ data, onChange, errors = {} }) { return (
- + onChange({ ...data, dns_port: v })} min={1} max={65535} /> - + onChange({ ...data, dhcp_range: v })} placeholder="10.0.0.100,10.0.0.200,12h" /> @@ -198,13 +252,13 @@ function NetworkForm({ data, onChange }) { ); } -function WireguardForm({ data, onChange }) { +function WireguardForm({ data, onChange, errors = {} }) { return (
- + onChange({ ...data, port: v })} min={1} max={65535} /> - + onChange({ ...data, address: v })} placeholder="10.0.0.1/24" /> @@ -214,32 +268,32 @@ function WireguardForm({ data, onChange }) { ); } -function EmailForm({ data, onChange }) { +function EmailForm({ data, onChange, errors = {} }) { return (
onChange({ ...data, domain: v })} placeholder="mail.example.com" /> - + onChange({ ...data, smtp_port: v })} min={1} max={65535} /> - + onChange({ ...data, submission_port: v })} min={1} max={65535} /> - + onChange({ ...data, imap_port: v })} min={1} max={65535} /> - + onChange({ ...data, webmail_port: v })} min={1} max={65535} />
); } -function CalendarForm({ data, onChange }) { +function CalendarForm({ data, onChange, errors = {} }) { return (
- + onChange({ ...data, port: v })} min={1} max={65535} /> @@ -249,13 +303,13 @@ function CalendarForm({ data, onChange }) { ); } -function FilesForm({ data, onChange }) { +function FilesForm({ data, onChange, errors = {} }) { return (
- + onChange({ ...data, port: v })} min={1} max={65535} /> - + onChange({ ...data, manager_port: v })} min={1} max={65535} /> @@ -384,6 +438,9 @@ function Settings() { // service config save const saveService = async (key) => { + const { defaults } = SERVICE_DEFS.find((d) => d.key === key) || {}; + const data = { ...(defaults || {}), ...(serviceConfigs[key] || {}) }; + if (Object.keys(validateServiceConfig(key, data)).length > 0) return; setServiceSaving((s) => ({ ...s, [key]: true })); try { const res = await cellAPI.updateConfig({ [key]: serviceConfigs[key] }); @@ -535,16 +592,18 @@ function Settings() {
{SERVICE_DEFS.map(({ key, label, icon: Icon, Form, defaults }) => { const data = { ...defaults, ...(serviceConfigs[key] || {}) }; + const errors = validateServiceConfig(key, data); + const hasErrors = Object.keys(errors).length > 0; const dirty = serviceDirty[key]; const saving = serviceSaving[key]; return (
-
updateServiceConfig(key, d)} /> + updateServiceConfig(key, d)} errors={errors} />
Port/directory changes take effect after container restart.