fix(containers): guard container and port reads with stack:read (#1416)

The container list, the per-container log stream, and the ports-in-use
endpoint served data with only the global auth gate, while every mutating
route on the same router already enforced a role check. Align these reads
with the read model used across the stacks router by gating each on
stack:read, so a future restricted role cannot read container output it is
not entitled to. Every current role carries stack:read, so behavior is
unchanged today; this closes the gap before a more limited role exists.

Adds an authorization test covering denial, the admitted read model, and the
guard running ahead of the log stream's header flush.
This commit is contained in:
Anso
2026-06-22 20:54:04 -04:00
committed by GitHub
parent 69ba0e6d21
commit 82cc13951b
2 changed files with 119 additions and 0 deletions
@@ -0,0 +1,115 @@
/**
* Authorization tests for the container/ports read routes. These reads
* (`GET /api/containers`, `GET /api/containers/:id/logs`, `GET /api/ports/in-use`)
* are gated by `requirePermission('stack:read')`, the same read model used across
* the stacks router. Every shipped role carries `stack:read`, so the denial path
* is exercised by temporarily removing it from a role at runtime.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let DockerController: typeof import('../services/DockerController').default;
let FileSystemService: typeof import('../services/FileSystemService').FileSystemService;
let ROLE_PERMISSIONS: typeof import('../middleware/permissions').ROLE_PERMISSIONS;
const VIEWER = 'container-read-viewer';
const READ_PATHS = ['/api/containers', '/api/containers/abc123/logs', '/api/ports/in-use'];
/** Sign a viewer JWT using the live token_version so authMiddleware accepts it. */
function viewerToken(): string {
const user = DatabaseService.getInstance().getUserByUsername(VIEWER)!;
return jwt.sign({ username: VIEWER, role: 'viewer', tv: user.token_version }, TEST_JWT_SECRET, { expiresIn: '1m' });
}
/**
* Replace the DockerController / FileSystemService singletons with stubs so the
* handlers run without a Docker daemon. The logs stub ends the response itself
* (the real streamContainerLogs flushes SSE headers and streams), so a request
* that clears the guard resolves instead of hanging. Returns the spies so a test
* can assert whether a handler ever reached the Docker/FS layer.
*/
function stubDockerAndFs(): { docker: ReturnType<typeof vi.spyOn>; fs: ReturnType<typeof vi.spyOn> } {
const docker = vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getRunningContainers: vi.fn().mockResolvedValue([]),
streamContainerLogs: vi.fn().mockImplementation((_id: string, _req: unknown, res: import('express').Response) => {
res.status(200).end();
return Promise.resolve();
}),
getPortsInUse: vi.fn().mockResolvedValue([]),
} as unknown as ReturnType<typeof DockerController.getInstance>);
const fs = vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({
getStacks: vi.fn().mockResolvedValue([]),
} as unknown as ReturnType<typeof FileSystemService.getInstance>);
return { docker, fs };
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ default: DockerController } = await import('../services/DockerController'));
({ FileSystemService } = await import('../services/FileSystemService'));
({ ROLE_PERMISSIONS } = await import('../middleware/permissions'));
({ app } = await import('../index'));
const hash = await bcrypt.hash('password123', 1);
DatabaseService.getInstance().addUser({ username: VIEWER, password_hash: hash, role: 'viewer' });
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('container/ports reads deny a role without stack:read', () => {
let originalViewerPerms: typeof ROLE_PERMISSIONS.viewer;
let docker: ReturnType<typeof vi.spyOn>;
let fs: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
originalViewerPerms = ROLE_PERMISSIONS.viewer;
ROLE_PERMISSIONS.viewer = originalViewerPerms.filter((p) => p !== 'stack:read');
({ docker, fs } = stubDockerAndFs());
});
afterEach(() => {
ROLE_PERMISSIONS.viewer = originalViewerPerms;
});
it.each(READ_PATHS)('GET %s is rejected before any Docker work', async (path) => {
const res = await request(app).get(path).set('Authorization', `Bearer ${viewerToken()}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PERMISSION_DENIED');
// The guard short-circuits before the handler instantiates the controller. For the
// logs route this proves the 403 is sent before streamContainerLogs flushes SSE
// headers (DockerController.ts), since getInstance precedes that flush.
expect(docker).not.toHaveBeenCalled();
expect(fs).not.toHaveBeenCalled();
});
});
describe('container/ports reads admit a role with stack:read', () => {
beforeEach(() => {
stubDockerAndFs();
});
it.each(READ_PATHS)('GET %s is admitted', async (path) => {
const res = await request(app).get(path).set('Authorization', `Bearer ${viewerToken()}`);
expect(res.status).toBe(200);
});
});
describe('container/ports reads reject unauthenticated requests', () => {
it.each(READ_PATHS)('GET %s without a token is 401', async (path) => {
const res = await request(app).get(path);
expect(res.status).toBe(401);
});
});
+4
View File
@@ -2,11 +2,13 @@ import { Router, type Request, type Response } from 'express';
import DockerController from '../services/DockerController';
import { FileSystemService } from '../services/FileSystemService';
import { requireAdmin } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
export const containersRouter = Router();
containersRouter.get('/', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'stack:read')) return;
try {
const dockerController = DockerController.getInstance(req.nodeId);
const containers = await dockerController.getRunningContainers();
@@ -17,6 +19,7 @@ containersRouter.get('/', async (req: Request, res: Response) => {
});
containersRouter.get('/:id/logs', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'stack:read')) return;
try {
const id = req.params.id as string;
const dockerController = DockerController.getInstance(req.nodeId);
@@ -68,6 +71,7 @@ containersRouter.post('/:id/restart', async (req: Request, res: Response) => {
export const portsRouter = Router();
portsRouter.get('/in-use', async (req: Request, res: Response) => {
if (!requirePermission(req, res, 'stack:read')) return;
try {
const fsService = FileSystemService.getInstance(req.nodeId);
const stacks = await fsService.getStacks();