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
+25 -6
View File
@@ -11,6 +11,11 @@ import { fileURLToPath, pathToFileURL } from 'url';
import { isModuleCliExecution } from './cli-entry.js';
import { cloudflareTunnelProviderCapabilities } from '../server/lib/tunnels/providers/cloudflare.js';
import { createRemoteClientAuthRuntime } from '../server/lib/client-auth/remote-clients.js';
import {
getUnauthenticatedLanErrorMessage,
isNetworkExposedBindHost,
isUnsafeUnauthenticatedLanAllowed,
} from '../server/lib/security/bind-host.js';
import {
intro as clackIntro, outro as clackOutro, log as clackLog,
box as clackBox, confirm as clackConfirm,
@@ -147,10 +152,6 @@ function resolveConfiguredBindHost(hostOverride) {
return configured || '127.0.0.1';
}
function isWildcardBindHost(host) {
return host === '0.0.0.0' || host === '::' || host === '[::]';
}
function resolveApiHost(hostOverride) {
const configured = resolveConfiguredBindHost(hostOverride);
@@ -637,6 +638,20 @@ function hasUiPasswordConfigured(password) {
return typeof password === 'string' && password.trim().length > 0;
}
function assertAuthenticatedNetworkExposure({ host, uiPassword }) {
const bindHost = resolveConfiguredBindHost(host);
if (hasUiPasswordConfigured(uiPassword)) {
return;
}
if (!isNetworkExposedBindHost(bindHost)) {
return;
}
if (isUnsafeUnauthenticatedLanAllowed(process.env)) {
return;
}
throw new TunnelCliError(getUnauthenticatedLanErrorMessage(bindHost), EXIT_CODE.AUTH_CONFIG_ERROR);
}
const BUN_BIN = getBunBinary();
function isBunRuntime() {
@@ -3445,10 +3460,13 @@ const commands = {
const logFd = fs.openSync(initialLogPath, 'a');
const effectiveUiPassword = hasUiPasswordConfigured(options.uiPassword) ? options.uiPassword : undefined;
assertAuthenticatedNetworkExposure({
host: options.host,
uiPassword: effectiveUiPassword,
});
if (!effectiveUiPassword && !options.suppressUiPasswordWarning) {
const bindHost = resolveConfiguredBindHost(options.host);
const loopbackHosts = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
const networkExposed = isWildcardBindHost(bindHost) || !loopbackHosts.has(bindHost);
const networkExposed = isNetworkExposedBindHost(bindHost);
const warningLine = 'OPENCHAMBER_UI_PASSWORD is not set';
const warningDetail = networkExposed
? `server is bound to ${bindHost} and reachable on your network with no UI auth. `
@@ -5710,6 +5728,7 @@ if (isCliExecution) {
export {
commands,
parseArgs,
assertAuthenticatedNetworkExposure,
hasUiPasswordConfigured,
shouldDisplayTunnelQr,
isValidTunnelDoctorResponse,
+32 -1
View File
@@ -3,7 +3,7 @@ import path from 'path';
import { pathToFileURL } from 'url';
import { isModuleCliExecution, normalizeCliEntryPath } from './cli-entry.js';
import { parseArgs } from './cli.js';
import { assertAuthenticatedNetworkExposure, parseArgs } from './cli.js';
describe('cli args', () => {
it('accepts legacy daemon flags as no-ops', () => {
@@ -74,6 +74,37 @@ describe('cli args', () => {
});
});
describe('network-exposed auth validation', () => {
it('allows loopback without a UI password', () => {
expect(() => assertAuthenticatedNetworkExposure({ host: '127.0.0.1' })).not.toThrow();
expect(() => assertAuthenticatedNetworkExposure({ host: 'localhost' })).not.toThrow();
expect(() => assertAuthenticatedNetworkExposure({ host: '::1' })).not.toThrow();
});
it('requires a UI password for LAN and wildcard bind hosts', () => {
expect(() => assertAuthenticatedNetworkExposure({ host: '0.0.0.0' })).toThrow(/refuses to bind/);
expect(() => assertAuthenticatedNetworkExposure({ host: '192.168.1.10' })).toThrow(/refuses to bind/);
});
it('allows network-exposed bind hosts with a UI password', () => {
expect(() => assertAuthenticatedNetworkExposure({ host: '0.0.0.0', uiPassword: 'secret' })).not.toThrow();
});
it('allows explicit unsafe LAN override from process env only', () => {
const previous = process.env.OPENCHAMBER_ALLOW_UNAUTHENTICATED_LAN;
process.env.OPENCHAMBER_ALLOW_UNAUTHENTICATED_LAN = 'true';
try {
expect(() => assertAuthenticatedNetworkExposure({ host: '0.0.0.0' })).not.toThrow();
} finally {
if (typeof previous === 'string') {
process.env.OPENCHAMBER_ALLOW_UNAUTHENTICATED_LAN = previous;
} else {
delete process.env.OPENCHAMBER_ALLOW_UNAUTHENTICATED_LAN;
}
}
});
});
describe('cli entry detection', () => {
const modulePath = '/tmp/openchamber/bin/cli.js';
const moduleUrl = pathToFileURL(modulePath).href;