feat: validate ip_range must be within RFC-1918 on save

API: rejects ip_range outside 10.0.0.0/8, 172.16.0.0/12, or 192.168.0.0/16
with a 400 error before saving to config.

UI: isRFC1918Cidr() validates on every keystroke; error message shown inline
below the field; Save Identity button disabled while the value is invalid.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 00:33:30 -04:00
parent 60cf223293
commit 323729e1ab
2 changed files with 56 additions and 7 deletions
+19
View File
@@ -435,6 +435,25 @@ def update_config():
# Handle identity fields (cell_name, domain, ip_range, wireguard_port)
identity_keys = {'cell_name', 'domain', 'ip_range', 'wireguard_port'}
identity_updates = {k: v for k, v in data.items() if k in identity_keys}
# Validate ip_range — must be a valid CIDR within an RFC-1918 range
if 'ip_range' in identity_updates:
import ipaddress as _ipa
_rfc1918 = [
_ipa.ip_network('10.0.0.0/8'),
_ipa.ip_network('172.16.0.0/12'),
_ipa.ip_network('192.168.0.0/16'),
]
try:
_net = _ipa.ip_network(identity_updates['ip_range'], strict=False)
if not any(_net.subnet_of(r) for r in _rfc1918):
return jsonify({'error': (
'ip_range must be within an RFC-1918 private range '
'(10.0.0.0/8, 172.16.0.0/12, or 192.168.0.0/16)'
)}), 400
except ValueError as _e:
return jsonify({'error': f'Invalid ip_range: {_e}'}), 400
# Capture old identity and service configs BEFORE saving, for change detection
old_identity = dict(config_manager.configs.get('_identity', {}))
old_svc_configs = {
+37 -7
View File
@@ -69,14 +69,39 @@ function Section({ icon: Icon, title, children, collapsible = false, defaultOpen
);
}
// RFC-1918 validation
function isRFC1918Cidr(cidr) {
if (!cidr || !cidr.trim()) return false;
const m = cidr.trim().match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)\/(\d+)$/);
if (!m) return false;
const [, a, b, c, d, p] = m.map(Number);
if ([a, b, c, d].some(n => n < 0 || n > 255) || p < 0 || p > 32) return false;
const ip = ((a << 24) | (b << 16) | (c << 8) | d) >>> 0;
const ranges = [
{ net: 0x0a000000, prefix: 8 }, // 10.0.0.0/8
{ net: 0xac100000, prefix: 12 }, // 172.16.0.0/12
{ net: 0xc0a80000, prefix: 16 }, // 192.168.0.0/16
];
for (const { net, prefix } of ranges) {
if (p < prefix) continue;
const mask = (0xffffffff << (32 - prefix)) >>> 0;
if ((ip & mask) >>> 0 === (net & mask) >>> 0) return true;
}
return false;
}
// Field components
function Field({ label, children, hint }) {
function Field({ label, children, hint, error }) {
return (
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4">
<label className="text-sm text-gray-600 sm:w-48 shrink-0">{label}</label>
<div className="flex-1">{children}</div>
{hint && <span className="text-xs text-gray-400">{hint}</span>}
<div className="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-4">
<label className="text-sm text-gray-600 sm:w-48 shrink-0 sm:pt-1.5">{label}</label>
<div className="flex-1">
{children}
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
</div>
{hint && !error && <span className="text-xs text-gray-400 sm:pt-1.5">{hint}</span>}
</div>
);
}
@@ -338,7 +363,12 @@ function Settings() {
};
// identity save
const ipRangeError = identity.ip_range && !isRFC1918Cidr(identity.ip_range)
? 'Must be within an RFC-1918 range: 10.0.0.0/8, 172.16.0.0/12, or 192.168.0.0/16'
: null;
const saveIdentity = async () => {
if (ipRangeError) return;
setIdentitySaving(true);
try {
const res = await cellAPI.updateConfig(identity);
@@ -475,7 +505,7 @@ function Settings() {
placeholder="cell.local"
/>
</Field>
<Field label="IP Range" hint="Docker bridge subnet">
<Field label="IP Range" hint="Docker bridge subnet" error={ipRangeError}>
<TextInput
value={identity.ip_range}
onChange={(v) => { setIdentity((i) => ({ ...i, ip_range: v })); setIdentityDirty(true); }}
@@ -486,7 +516,7 @@ function Settings() {
<div className="flex justify-end mt-4">
<button
onClick={saveIdentity}
disabled={!identityDirty || identitySaving}
disabled={!identityDirty || identitySaving || !!ipRangeError}
className="btn-primary flex items-center gap-2 text-sm disabled:opacity-50"
>
{identitySaving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}