Add port and IP validation across all service config forms

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 00:48:20 -04:00
parent 323729e1ab
commit 55bec04603
2 changed files with 109 additions and 18 deletions
+32
View File
@@ -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 = {
+77 -18
View File
@@ -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 165535';
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 (
<div className="space-y-3">
<Field label="DNS Port">
<Field label="DNS Port" error={errors.dns_port}>
<NumberInput value={data.dns_port} onChange={(v) => onChange({ ...data, dns_port: v })} min={1} max={65535} />
</Field>
<Field label="DHCP Range" hint="e.g. 10.0.0.100,10.0.0.200,12h">
<Field label="DHCP Range" hint="e.g. 10.0.0.100,10.0.0.200,12h" error={errors.dhcp_range}>
<TextInput value={data.dhcp_range} onChange={(v) => onChange({ ...data, dhcp_range: v })} placeholder="10.0.0.100,10.0.0.200,12h" />
</Field>
<Field label="NTP Servers">
@@ -198,13 +252,13 @@ function NetworkForm({ data, onChange }) {
);
}
function WireguardForm({ data, onChange }) {
function WireguardForm({ data, onChange, errors = {} }) {
return (
<div className="space-y-3">
<Field label="Listen Port">
<Field label="Listen Port" error={errors.port}>
<NumberInput value={data.port} onChange={(v) => onChange({ ...data, port: v })} min={1} max={65535} />
</Field>
<Field label="Server Address" hint="CIDR, e.g. 10.0.0.1/24">
<Field label="Server Address" hint="CIDR, e.g. 10.0.0.1/24" error={errors.address}>
<TextInput value={data.address} onChange={(v) => onChange({ ...data, address: v })} placeholder="10.0.0.1/24" />
</Field>
<Field label="Private Key">
@@ -214,32 +268,32 @@ function WireguardForm({ data, onChange }) {
);
}
function EmailForm({ data, onChange }) {
function EmailForm({ data, onChange, errors = {} }) {
return (
<div className="space-y-3">
<Field label="Mail Domain">
<TextInput value={data.domain} onChange={(v) => onChange({ ...data, domain: v })} placeholder="mail.example.com" />
</Field>
<Field label="SMTP Port" hint="MTA-to-MTA (default 25)">
<Field label="SMTP Port" hint="MTA-to-MTA (default 25)" error={errors.smtp_port}>
<NumberInput value={data.smtp_port ?? 25} onChange={(v) => onChange({ ...data, smtp_port: v })} min={1} max={65535} />
</Field>
<Field label="Submission Port" hint="Client mail send (default 587)">
<Field label="Submission Port" hint="Client mail send (default 587)" error={errors.submission_port}>
<NumberInput value={data.submission_port ?? 587} onChange={(v) => onChange({ ...data, submission_port: v })} min={1} max={65535} />
</Field>
<Field label="IMAP Port" hint="Client mail fetch (default 993)">
<Field label="IMAP Port" hint="Client mail fetch (default 993)" error={errors.imap_port}>
<NumberInput value={data.imap_port ?? 993} onChange={(v) => onChange({ ...data, imap_port: v })} min={1} max={65535} />
</Field>
<Field label="Webmail Port" hint="Rainloop webmail UI (default 8888)">
<Field label="Webmail Port" hint="Rainloop webmail UI (default 8888)" error={errors.webmail_port}>
<NumberInput value={data.webmail_port ?? 8888} onChange={(v) => onChange({ ...data, webmail_port: v })} min={1} max={65535} />
</Field>
</div>
);
}
function CalendarForm({ data, onChange }) {
function CalendarForm({ data, onChange, errors = {} }) {
return (
<div className="space-y-3">
<Field label="Radicale Port" hint="Internal port; clients use port 80 via Caddy">
<Field label="Radicale Port" hint="Internal port; clients use port 80 via Caddy" error={errors.port}>
<NumberInput value={data.port} onChange={(v) => onChange({ ...data, port: v })} min={1} max={65535} />
</Field>
<Field label="Data Directory">
@@ -249,13 +303,13 @@ function CalendarForm({ data, onChange }) {
);
}
function FilesForm({ data, onChange }) {
function FilesForm({ data, onChange, errors = {} }) {
return (
<div className="space-y-3">
<Field label="WebDAV Port" hint="Host port for WebDAV (default 8080)">
<Field label="WebDAV Port" hint="Host port for WebDAV (default 8080)" error={errors.port}>
<NumberInput value={data.port ?? 8080} onChange={(v) => onChange({ ...data, port: v })} min={1} max={65535} />
</Field>
<Field label="File Manager Port" hint="Filegator host port (default 8082)">
<Field label="File Manager Port" hint="Filegator host port (default 8082)" error={errors.manager_port}>
<NumberInput value={data.manager_port ?? 8082} onChange={(v) => onChange({ ...data, manager_port: v })} min={1} max={65535} />
</Field>
<Field label="Data Directory">
@@ -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() {
</div>
{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 (
<Section key={key} icon={Icon} title={label} collapsible defaultOpen={false}>
<Form data={data} onChange={(d) => updateServiceConfig(key, d)} />
<Form data={data} onChange={(d) => updateServiceConfig(key, d)} errors={errors} />
<div className="flex items-center justify-between mt-4">
<span className="text-xs text-gray-400">Port/directory changes take effect after container restart.</span>
<button
onClick={() => saveService(key)}
disabled={!dirty || saving}
disabled={!dirty || saving || hasErrors}
className="btn-primary flex items-center gap-2 text-sm disabled:opacity-50"
>
{saving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}