Harden remote API security boundaries

This commit is contained in:
Bohdan Triapitsyn
2026-06-12 18:24:07 +03:00
parent c281937406
commit 106b31a407
52 changed files with 1582 additions and 579 deletions
@@ -0,0 +1,40 @@
import net from 'node:net';
const stripIpv6Brackets = (value) => {
if (typeof value !== 'string') return '';
const trimmed = value.trim().toLowerCase();
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
return trimmed.slice(1, -1);
}
return trimmed;
};
const normalizeIpv4MappedAddress = (host) => {
const normalized = stripIpv6Brackets(host);
const match = normalized.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
return match ? match[1] : normalized;
};
const isLoopbackIpv4 = (host) => {
if (net.isIP(host) !== 4) return false;
const first = Number.parseInt(host.split('.')[0] || '', 10);
return first === 127;
};
export const isLoopbackBindHost = (host) => {
const normalized = normalizeIpv4MappedAddress(host);
if (!normalized) return false;
if (normalized === 'localhost') return true;
if (isLoopbackIpv4(normalized)) return true;
return net.isIP(normalized) === 6 && normalized === '::1';
};
export const isNetworkExposedBindHost = (host) => !isLoopbackBindHost(host);
export const isUnsafeUnauthenticatedLanAllowed = (env = process.env) =>
env?.OPENCHAMBER_ALLOW_UNAUTHENTICATED_LAN === 'true';
export const getUnauthenticatedLanErrorMessage = (host) =>
`OpenChamber refuses to bind to ${host || 'a network-exposed host'} without UI authentication. `
+ 'Set --ui-password or OPENCHAMBER_UI_PASSWORD before exposing it over LAN, '
+ 'or set OPENCHAMBER_ALLOW_UNAUTHENTICATED_LAN=true to accept the risk.';
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import {
isLoopbackBindHost,
isNetworkExposedBindHost,
} from './bind-host.js';
describe('bind host exposure classification', () => {
it('allows only proven loopback bind hosts without authentication', () => {
for (const host of ['localhost', '127.0.0.1', '127.25.1.2', '::1', '[::1]', '::ffff:127.0.0.1']) {
expect(isLoopbackBindHost(host), host).toBe(true);
expect(isNetworkExposedBindHost(host), host).toBe(false);
}
});
it('treats wildcard, LAN, IPv6 local, and unknown hosts as exposed', () => {
for (const host of [
'0.0.0.0',
'0',
'0x0',
'::',
'[::]',
'192.168.1.10',
'10.0.0.5',
'172.16.0.2',
'::ffff:192.168.1.10',
'fe80::1',
'fc00::1',
'openchamber.local',
'example.com',
'',
]) {
expect(isLoopbackBindHost(host), host).toBe(false);
expect(isNetworkExposedBindHost(host), host).toBe(true);
}
});
});