mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 14:08:19 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user