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)
|
||||
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 = {
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
Reference in New Issue
Block a user