fix(api-tokens): harden scope enforcement and block sensitive endpoints (#228)

* fix(api-tokens): harden scope enforcement and add expiration support

- Fix deploy-only allowlist to match actual routes (deploy, down, restart,
  stop, start, update) instead of non-existent /up, /pull, /compose/* paths
- Block API tokens from auth-sensitive routes (password change, node token
  generation) that bypass scope enforcement middleware
- Add WebSocket scope enforcement: read-only/deploy-only tokens can only
  access logs and notifications, not host console or container exec
- Prevent API token self-replication: tokens cannot create, list, or revoke
  other tokens regardless of scope
- Map deploy-only tokens to admin role so they pass requireAdmin on deploy
  routes (scope middleware still restricts which endpoints they can reach)
- Add optional token expiration (30, 60, 90, 365 days or no expiry)
- Add token name length validation (max 100 characters)
- Surface fetchTokens errors in frontend instead of swallowing silently
- Fix docs: correct deploy-only scope description and GitHub Actions example

* fix(api-tokens): block all sensitive management endpoints from API tokens

User management, SSO configuration, node management, license management,
and console access are now human-session-only. Add comprehensive unit
tests for scope enforcement, blocked endpoints, expiration, and revocation.

* fix(api-tokens): fix TS18048 possibly-undefined in test
This commit is contained in:
Anso
2026-03-28 22:14:22 -04:00
committed by GitHub
parent 26c74f2aad
commit 5b607de227
4 changed files with 268 additions and 3 deletions
+189
View File
@@ -0,0 +1,189 @@
/**
* Tests for API token scope enforcement, blocked endpoints, expiration, and revocation.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
/** Create an API token in the DB and return its raw JWT string. */
function createTestApiToken(
scope: 'read-only' | 'deploy-only' | 'full-admin',
expiresAt: number | null = null,
): string {
const rawToken = jwt.sign({ scope: 'api_token', jti: crypto.randomUUID() }, TEST_JWT_SECRET, { expiresIn: '1h' });
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
const db = DatabaseService.getInstance();
const user = db.getUserByUsername('testadmin');
db.addApiToken({
token_hash: tokenHash,
name: `test-${scope}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
scope,
user_id: user!.id,
created_at: Date.now(),
expires_at: expiresAt,
});
return rawToken;
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ app } = await import('../index'));
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
// ─── Scope Enforcement Middleware ────────────────────────────────────────────
describe('enforceApiTokenScope', () => {
it('read-only token allows GET requests', async () => {
const token = createTestApiToken('read-only');
const res = await request(app)
.get('/api/stacks')
.set('Authorization', `Bearer ${token}`);
// Should not be 403 SCOPE_DENIED (may be 200 or other non-scope error)
expect(res.body.code).not.toBe('SCOPE_DENIED');
});
it('read-only token blocks POST requests', async () => {
const token = createTestApiToken('read-only');
const res = await request(app)
.post('/api/stacks/test-stack/deploy')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('SCOPE_DENIED');
});
it('deploy-only token allows GET requests', async () => {
const token = createTestApiToken('deploy-only');
const res = await request(app)
.get('/api/stacks')
.set('Authorization', `Bearer ${token}`);
expect(res.body.code).not.toBe('SCOPE_DENIED');
});
it('deploy-only token allows POST to deploy patterns', async () => {
const token = createTestApiToken('deploy-only');
const res = await request(app)
.post('/api/stacks/test-stack/deploy')
.set('Authorization', `Bearer ${token}`);
// Should not be SCOPE_DENIED (may fail for other reasons like stack not found)
expect(res.body.code).not.toBe('SCOPE_DENIED');
});
it('deploy-only token blocks POST to non-deploy endpoints', async () => {
const token = createTestApiToken('deploy-only');
const res = await request(app)
.post('/api/stacks')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'test', content: 'version: "3"' });
expect(res.status).toBe(403);
expect(res.body.code).toBe('SCOPE_DENIED');
});
it('full-admin token passes scope enforcement on stack routes', async () => {
const token = createTestApiToken('full-admin');
const res = await request(app)
.get('/api/stacks')
.set('Authorization', `Bearer ${token}`);
expect(res.body.code).not.toBe('SCOPE_DENIED');
});
});
// ─── Blocked Endpoints (human-session-only) ─────────────────────────────────
describe('API token blocked endpoints', () => {
let fullAdminToken: string;
beforeAll(() => {
fullAdminToken = createTestApiToken('full-admin');
});
const blockedEndpoints: Array<{ method: 'get' | 'post' | 'put' | 'delete'; path: string; body?: Record<string, unknown> }> = [
// Password management
{ method: 'put', path: '/api/auth/password', body: { oldPassword: 'x', newPassword: 'y' } },
// Node token generation
{ method: 'post', path: '/api/auth/generate-node-token' },
// User management
{ method: 'get', path: '/api/users' },
{ method: 'post', path: '/api/users', body: { username: 'test', password: 'test123', role: 'viewer' } },
{ method: 'put', path: '/api/users/1', body: { role: 'viewer' } },
{ method: 'delete', path: '/api/users/1' },
// SSO configuration
{ method: 'get', path: '/api/sso/config' },
{ method: 'get', path: '/api/sso/config/ldap' },
{ method: 'put', path: '/api/sso/config/ldap', body: { enabled: true } },
{ method: 'delete', path: '/api/sso/config/ldap' },
{ method: 'post', path: '/api/sso/config/ldap/test' },
// Node management
{ method: 'post', path: '/api/nodes', body: { name: 'test', type: 'local' } },
{ method: 'put', path: '/api/nodes/1', body: { name: 'updated' } },
{ method: 'delete', path: '/api/nodes/1' },
// License management
{ method: 'post', path: '/api/license/activate', body: { license_key: 'test' } },
{ method: 'post', path: '/api/license/deactivate' },
// Console token
{ method: 'post', path: '/api/system/console-token' },
// Token self-management
{ method: 'get', path: '/api/api-tokens' },
{ method: 'post', path: '/api/api-tokens', body: { name: 'test', scope: 'read-only' } },
{ method: 'delete', path: '/api/api-tokens/1' },
];
for (const { method, path, body } of blockedEndpoints) {
it(`${method.toUpperCase()} ${path} returns 403 SCOPE_DENIED`, async () => {
let req = request(app)[method](path).set('Authorization', `Bearer ${fullAdminToken}`);
if (body) req = req.send(body);
const res = await req;
expect(res.status).toBe(403);
expect(res.body.code).toBe('SCOPE_DENIED');
});
}
});
// ─── Token Expiration ───────────────────────────────────────────────────────
describe('API token expiration', () => {
it('expired token returns 401', async () => {
const token = createTestApiToken('full-admin', Date.now() - 1000); // expired 1s ago
const res = await request(app)
.get('/api/stacks')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(401);
});
it('non-expired token is accepted', async () => {
const token = createTestApiToken('full-admin', Date.now() + 86400000); // expires in 1 day
const res = await request(app)
.get('/api/stacks')
.set('Authorization', `Bearer ${token}`);
expect(res.status).not.toBe(401);
});
});
// ─── Token Revocation ───────────────────────────────────────────────────────
describe('API token revocation', () => {
it('revoked token returns 401', async () => {
const rawToken = createTestApiToken('full-admin');
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
// Revoke by finding the token ID
const db = DatabaseService.getInstance();
const apiToken = db.getApiTokenByHash(tokenHash)!;
db.revokeApiToken(apiToken.id);
const res = await request(app)
.get('/api/stacks')
.set('Authorization', `Bearer ${rawToken}`);
expect(res.status).toBe(401);
});
});
+60
View File
@@ -816,6 +816,10 @@ app.get('/api/license', (_req: Request, res: Response): void => {
});
app.post('/api/license/activate', async (req: Request, res: Response): Promise<void> => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage licenses.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
try {
const { license_key } = req.body;
@@ -836,6 +840,10 @@ app.post('/api/license/activate', async (req: Request, res: Response): Promise<v
});
app.post('/api/license/deactivate', async (_req: Request, res: Response): Promise<void> => {
if (_req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage licenses.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(_req, res)) return;
try {
const result = await LicenseService.getInstance().deactivate();
@@ -1605,6 +1613,10 @@ app.post('/api/webhooks/:id/trigger', async (req: Request, res: Response): Promi
// --- User Management (local-only, admin + Pro gated for creation) ---
app.get('/api/users', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access user management.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
try {
const users = DatabaseService.getInstance().getUsers();
@@ -1616,6 +1628,10 @@ app.get('/api/users', authMiddleware, async (req: Request, res: Response): Promi
});
app.post('/api/users', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access user management.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
if (!requirePro(req, res)) return;
try {
@@ -1667,6 +1683,10 @@ app.post('/api/users', authMiddleware, async (req: Request, res: Response): Prom
});
app.put('/api/users/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access user management.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
@@ -1728,6 +1748,10 @@ app.put('/api/users/:id', authMiddleware, async (req: Request, res: Response): P
});
app.delete('/api/users/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access user management.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
@@ -3077,6 +3101,10 @@ app.post('/api/notifications/test', authMiddleware, async (req: Request, res: Re
// to receive a short-lived token. The remote's WS upgrade handler allows 'console_session'
// tokens through its isProxyToken guard, keeping the long-lived api_token off interactive paths.
app.post('/api/system/console-token', authMiddleware, (req: Request, res: Response): void => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot generate console tokens.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
@@ -3096,6 +3124,10 @@ app.post('/api/system/console-token', authMiddleware, (req: Request, res: Respon
// --- SSO Config Routes (admin + Team Pro, local-only) ---
app.get('/api/sso/config', (req: Request, res: Response): void => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access SSO configuration.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
@@ -3115,6 +3147,10 @@ app.get('/api/sso/config', (req: Request, res: Response): void => {
});
app.get('/api/sso/config/:provider', (req: Request, res: Response): void => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access SSO configuration.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
@@ -3135,6 +3171,10 @@ app.get('/api/sso/config/:provider', (req: Request, res: Response): void => {
});
app.put('/api/sso/config/:provider', (req: Request, res: Response): void => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access SSO configuration.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
@@ -3154,6 +3194,10 @@ app.put('/api/sso/config/:provider', (req: Request, res: Response): void => {
});
app.delete('/api/sso/config/:provider', (req: Request, res: Response): void => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access SSO configuration.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
@@ -3166,6 +3210,10 @@ app.delete('/api/sso/config/:provider', (req: Request, res: Response): void => {
});
app.post('/api/sso/config/:provider/test', async (req: Request, res: Response): Promise<void> => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot access SSO configuration.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
@@ -3622,6 +3670,10 @@ app.get('/api/nodes/:id', async (req: Request, res: Response) => {
// Create a new node
app.post('/api/nodes', async (req: Request, res: Response) => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage nodes.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
try {
const { name, type, compose_dir, is_default, api_url, api_token } = req.body;
@@ -3663,6 +3715,10 @@ app.post('/api/nodes', async (req: Request, res: Response) => {
// Update a node
app.put('/api/nodes/:id', async (req: Request, res: Response) => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage nodes.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
try {
const id = parseInt(req.params.id as string);
@@ -3689,6 +3745,10 @@ app.put('/api/nodes/:id', async (req: Request, res: Response) => {
// Delete a node
app.delete('/api/nodes/:id', async (req: Request, res: Response) => {
if (req.apiTokenScope) {
res.status(403).json({ error: 'API tokens cannot manage nodes.', code: 'SCOPE_DENIED' });
return;
}
if (!requireAdmin(req, res)) return;
try {
const id = parseInt(req.params.id as string);