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
+14 -12
View File
@@ -51,18 +51,6 @@ export const createBootstrapRuntime = (dependencies) => {
setAutoAcceptSession,
} = options;
registerServerStatusRoutes(app, {
express,
process,
openchamberVersion,
runtimeName,
serverStartedAt,
gracefulShutdown,
getHealthSnapshot,
});
registerCommonRequestMiddleware(app, { express, verboseRequestLogs });
const uiAuthController = createUiAuth({
password: uiPassword,
readSettingsFromDiskMigrated,
@@ -72,6 +60,20 @@ export const createBootstrapRuntime = (dependencies) => {
console.log('UI password protection enabled for browser sessions');
}
registerServerStatusRoutes(app, {
express,
process,
openchamberVersion,
runtimeName,
serverStartedAt,
gracefulShutdown,
getHealthSnapshot,
tunnelAuthController,
uiAuthController,
});
registerCommonRequestMiddleware(app, { express, verboseRequestLogs });
registerAuthAndAccessRoutes(app, {
express,
tunnelAuthController,
@@ -67,6 +67,8 @@ export const registerServerStatusRoutes = (app, dependencies) => {
serverStartedAt,
gracefulShutdown,
getHealthSnapshot,
tunnelAuthController = null,
uiAuthController = null,
} = dependencies;
const allocateLoopbackPort = async () => {
@@ -232,11 +234,33 @@ export const registerServerStatusRoutes = (app, dependencies) => {
});
});
app.post('/api/system/shutdown', (_req, res) => {
res.json({ ok: true });
gracefulShutdown({ exitProcess: true }).catch((error) => {
console.error('Shutdown request failed:', error?.message || error);
});
const requireShutdownAuth = async (req, res, next) => {
if (!uiAuthController || typeof uiAuthController.requireAuth !== 'function') {
return next();
}
const requestScope = typeof tunnelAuthController?.classifyRequestScope === 'function'
? tunnelAuthController.classifyRequestScope(req)
: 'local';
if (
(requestScope === 'tunnel' || requestScope === 'unknown-public')
&& typeof tunnelAuthController?.requireTunnelSession === 'function'
) {
return tunnelAuthController.requireTunnelSession(req, res, next);
}
return uiAuthController.requireAuth(req, res, next);
};
app.post('/api/system/shutdown', async (req, res, next) => {
try {
await requireShutdownAuth(req, res, () => {
res.json({ ok: true });
gracefulShutdown({ exitProcess: true }).catch((error) => {
console.error('Shutdown request failed:', error?.message || error);
});
});
} catch (error) {
next(error);
}
});
app.post('/api/system/dev-shutdown', express.json({ limit: '64kb' }), async (req, res) => {
@@ -25,6 +25,88 @@ describe('core-routes', () => {
expect(shutdownOpts).toEqual({ exitProcess: true });
});
it('should require UI auth before /api/system/shutdown when auth is configured', async () => {
const app = express();
const dependencies = {
gracefulShutdown: vi.fn(async () => {}),
getHealthSnapshot: () => ({ status: 'ok' }),
openchamberVersion: '1.0.0',
runtimeName: 'test',
express,
tunnelAuthController: {
classifyRequestScope: () => 'local',
requireTunnelSession: vi.fn(),
},
uiAuthController: {
requireAuth: vi.fn((_req, res) => res.status(401).json({ error: 'Unauthorized' })),
},
};
registerServerStatusRoutes(app, dependencies);
await request(app)
.post('/api/system/shutdown')
.expect(401, { error: 'Unauthorized' });
expect(dependencies.uiAuthController.requireAuth).toHaveBeenCalledTimes(1);
expect(dependencies.gracefulShutdown).not.toHaveBeenCalled();
});
it('should allow authenticated /api/system/shutdown requests', async () => {
const app = express();
const dependencies = {
gracefulShutdown: vi.fn(async () => {}),
getHealthSnapshot: () => ({ status: 'ok' }),
openchamberVersion: '1.0.0',
runtimeName: 'test',
express,
tunnelAuthController: {
classifyRequestScope: () => 'local',
requireTunnelSession: vi.fn(),
},
uiAuthController: {
requireAuth: vi.fn((_req, _res, next) => next()),
},
};
registerServerStatusRoutes(app, dependencies);
await request(app)
.post('/api/system/shutdown')
.expect(200, { ok: true });
expect(dependencies.uiAuthController.requireAuth).toHaveBeenCalledTimes(1);
expect(dependencies.gracefulShutdown).toHaveBeenCalledWith({ exitProcess: true });
});
it('should require tunnel auth for tunneled /api/system/shutdown requests', async () => {
const app = express();
const dependencies = {
gracefulShutdown: vi.fn(async () => {}),
getHealthSnapshot: () => ({ status: 'ok' }),
openchamberVersion: '1.0.0',
runtimeName: 'test',
express,
tunnelAuthController: {
classifyRequestScope: () => 'tunnel',
requireTunnelSession: vi.fn((_req, res) => res.status(401).json({ error: 'Tunnel auth required' })),
},
uiAuthController: {
requireAuth: vi.fn((_req, _res, next) => next()),
},
};
registerServerStatusRoutes(app, dependencies);
await request(app)
.post('/api/system/shutdown')
.expect(401, { error: 'Tunnel auth required' });
expect(dependencies.tunnelAuthController.requireTunnelSession).toHaveBeenCalledTimes(1);
expect(dependencies.uiAuthController.requireAuth).not.toHaveBeenCalled();
expect(dependencies.gracefulShutdown).not.toHaveBeenCalled();
});
it('should parse JSON bodies for snippet config routes', async () => {
const app = express();
registerCommonRequestMiddleware(app, { express });
@@ -740,6 +740,15 @@ export const createSettingsHelpers = (dependencies) => {
securityScopedBookmarks: bookmarks,
pinnedDirectories: normalizeStringArray(settings.pinnedDirectories),
typographySizes: sanitizeTypographySizesPartial(settings.typographySizes),
...(process.env.OPENCHAMBER_RUNTIME === 'desktop'
? {
desktopLanAccessActive: process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_ACTIVE === 'true',
desktopLanAccessBlockedReason:
process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON === 'missing-password'
? 'missing-password'
: null,
}
: {}),
showReasoningTraces:
typeof settings.showReasoningTraces === 'boolean'
? settings.showReasoningTraces
@@ -165,4 +165,27 @@ describe('settings helpers', () => {
const response = helpers.formatSettingsResponse({});
expect(response.collapsibleThinkingBlocks).toBe(true);
});
it('includes transient desktop LAN access runtime status in desktop settings response', () => {
const helpers = createTestHelpers();
const previousRuntime = process.env.OPENCHAMBER_RUNTIME;
const previousActive = process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_ACTIVE;
const previousReason = process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON;
try {
process.env.OPENCHAMBER_RUNTIME = 'desktop';
process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_ACTIVE = 'false';
process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON = 'missing-password';
const response = helpers.formatSettingsResponse({ desktopLanAccessEnabled: true });
expect(response.desktopLanAccessActive).toBe(false);
expect(response.desktopLanAccessBlockedReason).toBe('missing-password');
} finally {
if (typeof previousRuntime === 'string') process.env.OPENCHAMBER_RUNTIME = previousRuntime;
else delete process.env.OPENCHAMBER_RUNTIME;
if (typeof previousActive === 'string') process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_ACTIVE = previousActive;
else delete process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_ACTIVE;
if (typeof previousReason === 'string') process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON = previousReason;
else delete process.env.OPENCHAMBER_DESKTOP_LAN_ACCESS_BLOCKED_REASON;
}
});
});