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