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