Harden remote API security boundaries
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user