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
+1 -1
View File
@@ -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)
+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);
+18 -2
View File
@@ -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.
<Frame>