fix(tunnel): give the unauthenticated tunnel its own acknowledgement

The network acknowledgement that install.sh writes for every passwordless LAN install also satisfied the tunnel guard, so one click on the welcome screen's tunnel link published full terminal control to a public URL with no login and no confirm. The tunnel now needs CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL or --allow-unauthenticated-tunnel (carried by web -d and service install), and with no login the browser asks before every enable, reading a new loginConfigured field from GET /api/tunnel/status.

The guard is now decided once per request and required again where the tunnel starts, so a save that found the tunnel running can no longer restart it unguarded after a concurrent stop.

Closes #706
This commit is contained in:
JD committed 2026-10-26 03:13:25 -04:00
1 parent 3e06f3c141
commit fc0ecc6051
26 files changed
+1029 -165

No files matched your search

+16 -3
View File
@@ -7,15 +7,28 @@ export function isExplicitlyEnabled(value: string | undefined): boolean {
}
/**
* True when unauthenticated network exposure is acceptable: either a password is
* set (auth active) or the operator explicitly acknowledged it. Used by the
* tunnel-enable guard (COD-55) to refuse publishing an unauthenticated public URL.
* True when an unauthenticated non-loopback BIND is acceptable: either a password is
* set (auth active) or the operator explicitly acknowledged it. Quiets the startup
* warning only; it does not cover the Cloudflare tunnel (#706), which has its own
* acknowledgement below.
*/
export function isUnauthenticatedNetworkAcknowledged(allowFlag = false): boolean {
if (process.env.CODEMAN_PASSWORD) return true;
return allowFlag || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
}
/**
* True when the operator opted in to an unauthenticated public Cloudflare tunnel:
* `--allow-unauthenticated-tunnel` (passed in as `allowFlag`) or
* CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL. Used by the tunnel-enable guard (COD-55) in
* place of a login. Kept apart from the network acknowledgement on purpose (#706):
* install.sh writes that one for every passwordless LAN install, and it must never
* also publish the machine to the internet. install.sh never writes this one.
*/
export function isUnauthenticatedTunnelAcknowledged(allowFlag = false): boolean {
return allowFlag || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL);
}
export function isLoopbackBindHost(host: string): boolean {
const normalized = host
.trim()
+2
View File
@@ -35,6 +35,8 @@ export interface InfraPort {
readonly scheduledRuns: Map<string, ScheduledRun>;
readonly teamWatcher: TeamWatcher;
readonly tunnelManager: TunnelManager;
/** `--allow-unauthenticated-tunnel` was passed to `codeman web` (the env var is read per request, #706). */
readonly allowUnauthenticatedTunnel: boolean;
readonly pushStore: PushSubscriptionStore;
startScheduledRun(prompt: string, workingDir: string, durationMinutes: number, owner?: string): Promise<ScheduledRun>;
stopScheduledRun(id: string): Promise<void>;
+84 -19
View File
@@ -2271,10 +2271,16 @@ Object.assign(CodemanApp.prototype, {
btn.disabled = true;
try {
const newEnabled = !isActive;
const gate = newEnabled ? await this._confirmTunnelEnable() : { proceed: true, acknowledge: false };
if (!gate.proceed) {
this._updateWelcomeTunnelBtn(false);
btn.disabled = false;
return;
}
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: newEnabled }),
body: JSON.stringify({ tunnelEnabled: newEnabled, ...(gate.acknowledge ? { acknowledgeUnauthTunnel: true } : {}) }),
});
// COD-55: server refuses an unauthenticated public tunnel (403). Surface it.
if (newEnabled && (await this._handleTunnelEnableRefusal(res))) {
@@ -2582,11 +2588,67 @@ Object.assign(CodemanApp.prototype, {
return `${Math.floor(hrs / 24)}d ago`;
},
/**
* End an App Settings save whose tunnel enable did not go through (confirm cancelled, or
* refused by the server): the WHOLE settings PUT is off, so turn the tunnel toggle back
* off in the UI and localStorage. Other settings persisted to localStorage still apply locally.
*/
_abandonTunnelEnable(settings, keepOpen) {
settings.tunnelEnabled = false;
this.saveAppSettingsToStorage(settings);
const cb = document.getElementById('appSettingsTunnelEnabled');
if (cb) cb.checked = false;
if (!keepOpen) this.closeAppSettings();
},
/** The one SECURITY WARNING prompt for an unauthenticated public tunnel. */
_askUnauthTunnel() {
return confirm(
'⚠️ SECURITY WARNING — no password set\n\n' +
'Enabling the Cloudflare tunnel will publish THIS machine to a public URL with ' +
'NO login. Anyone who gets the URL has full terminal control — effectively remote ' +
'code execution on your computer.\n\n' +
'Strongly recommended: set CODEMAN_PASSWORD instead.\n\n' +
'Enable the unauthenticated public tunnel anyway?'
);
},
/**
* #706: ask BEFORE a tunnel enable whenever the server has no login (no CODEMAN_PASSWORD,
* not multi-user), every time, whatever the server would accept. With
* CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL set the PUT is never refused, so the refusal
* handler below would never ask. The ONE gate for the welcome link, the tunnel panel and
* the App Settings save. A tunnel that is already running is not being turned on, and
* the server does not guard it either. When the status cannot be read, the PUT goes out
* without the acknowledgement and `_handleTunnelEnableRefusal` stays the fallback.
* @returns {Promise<{ proceed: boolean, acknowledge: boolean }>} proceed=false: cancelled,
* send nothing; acknowledge: send `acknowledgeUnauthTunnel: true` with the enable.
*/
async _confirmTunnelEnable() {
let status = null;
try {
const res = await fetch('/api/tunnel/status');
if (res.ok) {
const env = await res.json();
status = env?.success === true ? env.data : env;
}
} catch {
/* unknown: the 403 handler asks instead */
}
if (!status || status.running || status.loginConfigured !== false) return { proceed: true, acknowledge: false };
if (!this._askUnauthTunnel()) {
this.showToast('Tunnel not enabled', 'info');
return { proceed: false, acknowledge: false };
}
return { proceed: true, acknowledge: true };
},
/**
* COD-55: detect the server's refusal to start an unauthenticated public tunnel.
* The PUT /api/settings route returns a 4xx with { success:false, error } when no
* CODEMAN_PASSWORD is set and the unauthenticated-network opt-in is not acknowledged.
* Shows the server's (actionable) message as an error toast.
* CODEMAN_PASSWORD is set and the unauthenticated-tunnel opt-in is not acknowledged.
* Shows the server's (actionable) message as an error toast. Since #706 this is the
* fallback for when `_confirmTunnelEnable` could not read the tunnel status.
* @param {Response|null} res - the fetch Response from the settings PUT
* @returns {Promise<boolean>} true if the tunnel-enable was refused (caller should abort)
*/
@@ -2612,14 +2674,7 @@ Object.assign(CodemanApp.prototype, {
// 403 = the no-password safety refusal (COD-55). Warn loudly and let the
// operator acknowledge the risk; on confirm, retry with explicit acknowledgment.
if (res.status === 403) {
const confirmed = confirm(
'⚠️ SECURITY WARNING — no password set\n\n' +
'Enabling the Cloudflare tunnel will publish THIS machine to a public URL with ' +
'NO login. Anyone who gets the URL has full terminal control — effectively remote ' +
'code execution on your computer.\n\n' +
'Strongly recommended: set CODEMAN_PASSWORD instead.\n\n' +
'Enable the unauthenticated public tunnel anyway?'
);
const confirmed = this._askUnauthTunnel();
if (!confirmed) {
this._dismissTunnelConnecting?.();
this.showToast('Tunnel not enabled', 'info');
@@ -2664,10 +2719,15 @@ Object.assign(CodemanApp.prototype, {
return;
}
try {
const gate = enable ? await this._confirmTunnelEnable() : { proceed: true, acknowledge: false };
if (!gate.proceed) {
this.closeTunnelPanel();
return;
}
const res = await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tunnelEnabled: enable }),
body: JSON.stringify({ tunnelEnabled: enable, ...(gate.acknowledge ? { acknowledgeUnauthTunnel: true } : {}) }),
});
// COD-55: server refuses an unauthenticated public tunnel (403). Surface it.
if (enable && (await this._handleTunnelEnableRefusal(res))) {
@@ -3270,11 +3330,21 @@ Object.assign(CodemanApp.prototype, {
let webhookError = '';
let serverSaved = false;
try {
// #706: a save that turns the tunnel on asks first when the server has no login
// (one already running is not asked about, see _confirmTunnelEnable).
const tunnelGate = settings.tunnelEnabled && !settingsReadOnly
? await this._confirmTunnelEnable()
: { proceed: true, acknowledge: false };
if (!tunnelGate.proceed) {
this._abandonTunnelEnable(settings, keepOpen);
return;
}
// A non-admin's save is not sent at all (see settingsReadOnly above): the server
// would refuse it with a 403, the plan-usage collection flip included.
const res = settingsReadOnly ? null : await this._apiPut('/api/settings', {
...serverSettings,
...(_chipFlip !== undefined ? { showPlanUsageLimits: _chipFlip } : {}),
...(tunnelGate.acknowledge ? { acknowledgeUnauthTunnel: true } : {}),
notificationPreferences: notifPrefsToSave,
voiceSettings,
});
@@ -3292,14 +3362,9 @@ Object.assign(CodemanApp.prototype, {
// COD-55: the server refuses an unauthenticated public tunnel with a 403 — which
// rejects the WHOLE settings PUT. Surface the message and revert the tunnel toggle
// (in the UI + localStorage) so it doesn't look enabled. Other settings persisted
// to localStorage above still apply locally.
// so it doesn't look enabled.
if (!refusedAsNonAdmin && settings.tunnelEnabled && (await this._handleTunnelEnableRefusal(res))) {
settings.tunnelEnabled = false;
this.saveAppSettingsToStorage(settings);
const cb = document.getElementById('appSettingsTunnelEnabled');
if (cb) cb.checked = false;
if (!keepOpen) this.closeAppSettings();
this._abandonTunnelEnable(settings, keepOpen);
return;
}
+83 -28
View File
@@ -15,7 +15,7 @@ import { randomBytes } from 'node:crypto';
import { dataPath } from '../../config/instance.js';
import { readHostSensors } from '../../host-sensors.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
import { isExplicitlyEnabled, isUnauthenticatedTunnelAcknowledged } from '../network-auth-policy.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { findUser, canUsernameRunPrivilegedCommands } from '../../user-store.js';
import { getAuthUser, isAdmin, requireAdmin, canAccessOwned, revokeUserSessions } from '../route-helpers.js';
@@ -177,6 +177,43 @@ export function settingsForNonAdmin(settings: unknown): unknown {
return out;
}
/**
* True when a login protects the server: CODEMAN_PASSWORD is set, or multi-user mode is
* on (every person has their own credential). The tunnel guard and the `loginConfigured`
* field of GET /api/tunnel/status both read this, so the page's answer mirrors the guard.
*/
function isLoginConfigured(): boolean {
return !!process.env.CODEMAN_PASSWORD || isMultiUserMode();
}
/**
* The refusal of a tunnel enable with no login and no tunnel opt-in or acknowledgement: logs
* the diagnostic line and returns the message for the 403. `late` is the case where the tunnel
* was running when the request arrived and had stopped by the time it would be started; the
* request's other settings were saved by then.
*/
function unauthenticatedTunnelRefusal(late: boolean): string {
// Someone who set the network variable to get here before #706 is told why it stopped working.
const networkAcknowledged = isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
console.warn(
`[tunnel] Refused an unauthenticated tunnel ${late ? 'restart after a concurrent stop' : 'enable'} (no login, no tunnel opt-in, no acknowledgement${networkAcknowledged ? '; CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK does not cover the tunnel' : ''})`
);
return (
(late
? 'The Cloudflare tunnel stopped while these settings were being saved and was not restarted; ' +
'the other settings were saved. '
: '') +
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' +
'require login, set CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL=1, or resend with ' +
'acknowledgeUnauthTunnel:true to acknowledge an unauthenticated public tunnel.' +
(networkAcknowledged
? ' CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK is set, but it only acknowledges the network ' +
'bind and no longer covers the tunnel.'
: '')
);
}
export function registerSystemRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort & KeepWarmPort
@@ -194,7 +231,14 @@ export function registerSystemRoutes(
// ========== Tunnel ==========
app.get('/api/tunnel/status', async () => ctx.tunnelManager.getStatus());
// `loginConfigured` (#706) tells the page whether a tunnel would have a login in front
// of it, so it can ask before an enable even when the server would accept one. It is
// the guard's own login test (isLoginConfigured above), never TunnelManager's, which
// knows nothing about auth.
app.get('/api/tunnel/status', async () => ({
...ctx.tunnelManager.getStatus(),
loginConfigured: isLoginConfigured(),
}));
app.get('/api/tunnel/info', async (req) => {
const status = ctx.tunnelManager.getStatus();
@@ -1063,36 +1107,29 @@ export function registerSystemRoutes(
// tunnel binds to loopback, server.ts's non-loopback bind guard never trips, and
// with no CODEMAN_PASSWORD the auth middleware is inactive — so the tunnel URL is
// unauthenticated. Refuse to start a tunnel unless auth is configured OR exposure
// is acknowledged: either the CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var, or an
// is acknowledged: either the CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL opt-in, or an
// explicit per-request `acknowledgeUnauthTunnel:true` (the UI sends this after a
// confirm dialog). This keeps curl/API/CLI callers protected by default while
// letting an operator opt in from the browser without setting the env var.
// Guard runs BEFORE persisting so a refused tunnelEnabled:true is not saved.
if (settings.tunnelEnabled === true && !ctx.tunnelManager.isRunning()) {
// Multi-user mode makes the tunnel authenticated (every person has their own
// credential), so it satisfies the same requirement as CODEMAN_PASSWORD.
const acknowledged =
isMultiUserMode() || isUnauthenticatedNetworkAcknowledged() || settings.acknowledgeUnauthTunnel === true;
if (!acknowledged) {
const msg =
'Refusing to start the Cloudflare tunnel without authentication: it would publish ' +
'full terminal control to a public URL with no password. Set CODEMAN_PASSWORD to ' +
'require login, set CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1, or resend with ' +
'acknowledgeUnauthTunnel:true to acknowledge an unauthenticated public tunnel.';
throw Object.assign(new Error(msg), {
statusCode: 403,
body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg),
});
}
// Loud warning whenever a public tunnel is started with no password — whether
// acknowledged via env var or the per-request UI confirmation.
if (!process.env.CODEMAN_PASSWORD) {
console.warn(
'⚠️ [tunnel] Starting an UNAUTHENTICATED public Cloudflare tunnel — no CODEMAN_PASSWORD set. ' +
'Anyone with the tunnel URL gets full terminal control (effectively RCE). ' +
'Set CODEMAN_PASSWORD to require login.'
);
}
// The tunnel opt-in is CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL / --allow-unauthenticated-tunnel,
// never the network one (#706): install.sh writes CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK
// for every passwordless LAN install, and that acknowledges the LAN bind only.
// Decided ONCE per request, whether or not the tunnel is running now, and required again
// where the tunnel is really started below: the settings file awaits in between leave room
// for a concurrent stop, and a save that found the tunnel running (so nothing was refused
// here) must not then restart it unguarded.
const tunnelEnableAuthorized =
settings.tunnelEnabled === true &&
(isLoginConfigured() ||
isUnauthenticatedTunnelAcknowledged(ctx.allowUnauthenticatedTunnel) ||
settings.acknowledgeUnauthTunnel === true);
if (settings.tunnelEnabled === true && !tunnelEnableAuthorized && !ctx.tunnelManager.isRunning()) {
const msg = unauthenticatedTunnelRefusal(false);
throw Object.assign(new Error(msg), {
statusCode: 403,
body: createErrorResponse(ApiErrorCode.OPERATION_FAILED, msg),
});
}
try {
@@ -1109,6 +1146,10 @@ export function registerSystemRoutes(
// acknowledgeUnauthTunnel is an ACTION field (not a stored setting) — strip
// it before persisting so settings.json stays clean.
const { acknowledgeUnauthTunnel, ...settingsToStore } = settings;
// An enable that is not authorized only got this far because the tunnel was running.
// It must never write tunnelEnabled:true: a concurrent stop has written false by now,
// and true on disk would show a stopped tunnel as on. The stored value stays as it is.
if (settings.tunnelEnabled === true && !tunnelEnableAuthorized) delete settingsToStore.tunnelEnabled;
const merged = { ...existing, ...settingsToStore };
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
@@ -1182,6 +1223,20 @@ export function registerSystemRoutes(
if ('tunnelEnabled' in settings) {
const tunnelEnabled = settings.tunnelEnabled as boolean;
if (tunnelEnabled && !ctx.tunnelManager.isRunning()) {
// The tunnel stopped since the guard above saw it running: the same authorization applies.
if (!tunnelEnableAuthorized) {
reply.code(403);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, unauthenticatedTunnelRefusal(true));
}
// Loud warning whenever a public tunnel is started with no password, whether
// acknowledged via the tunnel opt-in or the per-request UI confirmation.
if (!process.env.CODEMAN_PASSWORD) {
console.warn(
'⚠️ [tunnel] Starting an UNAUTHENTICATED public Cloudflare tunnel — no CODEMAN_PASSWORD set. ' +
'Anyone with the tunnel URL gets full terminal control (effectively RCE). ' +
'Set CODEMAN_PASSWORD to require login.'
);
}
ctx.tunnelManager.start(ctx.port, ctx.https);
console.log('Tunnel started via settings change');
} else if (tunnelEnabled && ctx.tunnelManager.isRunning() && ctx.tunnelManager.getUrl()) {
+1 -1
View File
@@ -1554,7 +1554,7 @@ export const SettingsUpdateSchema = z
// Action field (NOT persisted): explicit per-request acknowledgment that the
// operator accepts exposing an UNAUTHENTICATED public tunnel (no CODEMAN_PASSWORD).
// Lets the UI enable a tunnel after a confirm dialog without the
// CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting.
// CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL opt-in. Stripped before persisting.
acknowledgeUnauthTunnel: z.boolean().optional(),
tabTwoRows: z.boolean().optional(),
/**
+18 -3
View File
@@ -472,6 +472,8 @@ export class WebServer extends EventEmitter {
private windowTitle: string;
private readonly indexHtmlTemplate: string;
private readonly allowUnauthenticatedNetwork: boolean;
/** `--allow-unauthenticated-tunnel`; the tunnel guard reads it through the route context (#706). */
private readonly allowUnauthenticatedTunnel: boolean;
private _pasteImageGcStop: (() => void) | null = null;
private _eventLoopMonitor: EventLoopMonitorHandle | null = null;
/** Opt-in hooks-only listener on the docker bridge gateway (CODEMAN_DOCKER_BRIDGE_HOOKS). */
@@ -489,7 +491,8 @@ export class WebServer extends EventEmitter {
host: string = '127.0.0.1',
titleHostname?: string,
allowUnauthenticatedNetwork: boolean = false,
basePath: string = ''
basePath: string = '',
allowUnauthenticatedTunnel: boolean = false
) {
super();
this.setMaxListeners(0);
@@ -501,6 +504,7 @@ export class WebServer extends EventEmitter {
this.testMode = testMode;
this.allowUnauthenticatedNetwork =
allowUnauthenticatedNetwork || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
this.allowUnauthenticatedTunnel = allowUnauthenticatedTunnel;
this.titleHostname = titleHostname || getHostname();
this.windowTitle = `codeman:${this.titleHostname}`;
this.indexHtmlTemplate = readFileSync(join(__dirname, 'public', 'index.html'), 'utf-8');
@@ -880,6 +884,7 @@ export class WebServer extends EventEmitter {
scheduledRuns: this.scheduledRuns,
teamWatcher: this.teamWatcher,
tunnelManager: this.tunnelManager,
allowUnauthenticatedTunnel: this.allowUnauthenticatedTunnel,
pushStore: this.pushStore,
startScheduledRun: this.startScheduledRun.bind(this),
stopScheduledRun: this.stopScheduledRun.bind(this),
@@ -4399,9 +4404,19 @@ export async function startWebServer(
host: string = '127.0.0.1',
titleHostname?: string,
allowUnauthenticatedNetwork: boolean = false,
basePath: string = ''
basePath: string = '',
allowUnauthenticatedTunnel: boolean = false
): Promise<WebServer> {
const server = new WebServer(port, https, testMode, host, titleHostname, allowUnauthenticatedNetwork, basePath);
const server = new WebServer(
port,
https,
testMode,
host,
titleHostname,
allowUnauthenticatedNetwork,
basePath,
allowUnauthenticatedTunnel
);
await server.start();
return server;
}