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:
+19
@@ -435,6 +435,25 @@ def update_config():
|
|||||||
# Handle identity fields (cell_name, domain, ip_range, wireguard_port)
|
# Handle identity fields (cell_name, domain, ip_range, wireguard_port)
|
||||||
identity_keys = {'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}
|
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
|
# Capture old identity and service configs BEFORE saving, for change detection
|
||||||
old_identity = dict(config_manager.configs.get('_identity', {}))
|
old_identity = dict(config_manager.configs.get('_identity', {}))
|
||||||
old_svc_configs = {
|
old_svc_configs = {
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────────────
|
// ── Field components ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Field({ label, children, hint }) {
|
function Field({ label, children, hint, error }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4">
|
<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">{label}</label>
|
<label className="text-sm text-gray-600 sm:w-48 shrink-0 sm:pt-1.5">{label}</label>
|
||||||
<div className="flex-1">{children}</div>
|
<div className="flex-1">
|
||||||
{hint && <span className="text-xs text-gray-400">{hint}</span>}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -338,7 +363,12 @@ function Settings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// identity save
|
// 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 () => {
|
const saveIdentity = async () => {
|
||||||
|
if (ipRangeError) return;
|
||||||
setIdentitySaving(true);
|
setIdentitySaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await cellAPI.updateConfig(identity);
|
const res = await cellAPI.updateConfig(identity);
|
||||||
@@ -475,7 +505,7 @@ function Settings() {
|
|||||||
placeholder="cell.local"
|
placeholder="cell.local"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="IP Range" hint="Docker bridge subnet">
|
<Field label="IP Range" hint="Docker bridge subnet" error={ipRangeError}>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={identity.ip_range}
|
value={identity.ip_range}
|
||||||
onChange={(v) => { setIdentity((i) => ({ ...i, ip_range: v })); setIdentityDirty(true); }}
|
onChange={(v) => { setIdentity((i) => ({ ...i, ip_range: v })); setIdentityDirty(true); }}
|
||||||
@@ -486,7 +516,7 @@ function Settings() {
|
|||||||
<div className="flex justify-end mt-4">
|
<div className="flex justify-end mt-4">
|
||||||
<button
|
<button
|
||||||
onClick={saveIdentity}
|
onClick={saveIdentity}
|
||||||
disabled={!identityDirty || identitySaving}
|
disabled={!identityDirty || identitySaving || !!ipRangeError}
|
||||||
className="btn-primary flex items-center gap-2 text-sm disabled:opacity-50"
|
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" />}
|
{identitySaving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||||
|
|||||||
Reference in New Issue
Block a user