From 5b607de227eecf4000208b347d8157f2d5d94651 Mon Sep 17 00:00:00 2001 From: Anso Date: Sat, 28 Mar 2026 22:14:22 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 2 +- backend/src/__tests__/api-tokens.test.ts | 189 +++++++++++++++++++++++ backend/src/index.ts | 60 +++++++ docs/features/api-tokens.mdx | 20 ++- 4 files changed, 268 insertions(+), 3 deletions(-) create mode 100644 backend/src/__tests__/api-tokens.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3da436e7..c5e594d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -* **api-tokens:** harden scope enforcement — fix deploy-only allowlist to match actual routes, add WebSocket scope gating, block API tokens from auth-sensitive endpoints and token self-management, add configurable expiration support (30/60/90/365 days) +* **api-tokens:** harden scope enforcement — fix deploy-only allowlist to match actual routes, add WebSocket scope gating, block API tokens from all sensitive management endpoints (user management, SSO configuration, node management, license management, console access, password change, node-token generation, and token self-management), add configurable expiration support (30/60/90/365 days) ## [0.14.0](https://github.com/AnsoCode/Sencho/compare/v0.13.2...v0.14.0) (2026-03-28) diff --git a/backend/src/__tests__/api-tokens.test.ts b/backend/src/__tests__/api-tokens.test.ts new file mode 100644 index 00000000..eb97575e --- /dev/null +++ b/backend/src/__tests__/api-tokens.test.ts @@ -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 }> = [ + // 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); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 2d336a40..a5c75bb8 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -816,6 +816,10 @@ app.get('/api/license', (_req: Request, res: Response): void => { }); app.post('/api/license/activate', async (req: Request, res: Response): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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); diff --git a/docs/features/api-tokens.mdx b/docs/features/api-tokens.mdx index 622bcf1f..02c40321 100644 --- a/docs/features/api-tokens.mdx +++ b/docs/features/api-tokens.mdx @@ -17,15 +17,31 @@ Every token is created with one of three permission levels: |-------|----------------| | **Read Only** | `GET` requests only — view stacks, containers, metrics, and settings | | **Deploy Only** | Everything in Read Only, plus stack operations: deploy, down, restart, stop, start, update | -| **Full Admin** | Unrestricted access — equivalent to an admin user session | +| **Full Admin** | Full stack and container management — all read and write operations on stacks, containers, images, and system metrics | Choose the narrowest scope that fits your use case. A CI pipeline that only deploys stacks should use **Deploy Only**, not Full Admin. +### Universal restrictions + +Regardless of scope, **all** API tokens are blocked from: + +| Category | Description | +|----------|-------------| +| **Password management** | Changing user passwords | +| **User management** | Creating, updating, deleting, or listing user accounts | +| **SSO configuration** | Viewing, creating, updating, deleting, or testing SSO providers | +| **Node management** | Adding, updating, or deleting remote nodes | +| **License management** | Activating or deactivating license keys | +| **Token management** | Creating, listing, or revoking API tokens | +| **Console access** | Generating console session tokens for interactive terminals | + +These restrictions ensure that API tokens cannot escalate privileges or modify the identity and infrastructure configuration of your Sencho instance. These operations require a human user session (browser login). + ## Creating a token 1. Open **Settings Hub** and navigate to the **API Tokens** tab (visible to Team Pro admins only). 2. Click **Create Token**. -3. Enter a descriptive name (e.g., "GitHub Actions deploy") and select a permission scope. +3. Enter a descriptive name (e.g., "GitHub Actions deploy"), select a permission scope, and optionally choose an expiration period (30, 60, 90 days, or 1 year). Tokens without an expiration must be revoked manually. 4. Click **Create**. The raw token is displayed **once** — copy it immediately.