security: pre-release hardening, automated testing, and production readiness

SECURITY (critical fixes):
- Add authMiddleware to /api/system/console-token (was publicly accessible)
- Validate api_url on node create/update to prevent SSRF (rejects localhost/loopback)
- Add rate limiting (5 req/15 min/IP) to /api/auth/login and /api/auth/setup
- Fix path traversal in env_file resolution — absolute/escaping paths rejected
- Add stack name validation to GET routes (was only on PUT/POST)
- Add helmet security headers middleware
- Restrict CORS to FRONTEND_URL in production

PRODUCTION READINESS:
- Add GET /api/health public endpoint + HEALTHCHECK in Dockerfile
- Add SIGTERM/SIGINT graceful shutdown handler (drains connections, closes DB)
- Run container as non-root sencho user in Dockerfile

QUALITY:
- Fix 4 silent empty catch{} blocks in EditorLayout (now show toast.error)
- Connect ErrorBoundary to root App in main.tsx
- Replace WebSocket.Server with named WebSocketServer import (ESM compat)

TESTING (new automated test suite):
- Install Vitest; 38 backend tests across 4 suites covering validation utilities,
  health endpoint, auth middleware, login flows, SSRF protection, and path traversal
- Extract isValidStackName/isValidRemoteUrl/isPathWithinBase to utils/validation.ts
- Playwright E2E scaffolding: auth, stacks, nodes specs + shared login helper
- CI: run Vitest + ESLint on every PR
This commit is contained in:
SaelixCode
2026-03-21 21:59:44 -04:00
parent 94d6c8fc0f
commit ce50db0fde
22 changed files with 2445 additions and 30 deletions
+96
View File
@@ -0,0 +1,96 @@
/**
* Tests for node management API — focusing on api_url validation (SSRF fix C2).
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
authHeader = `Bearer ${token}`;
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
describe('POST /api/nodes — api_url SSRF validation (C2 fix)', () => {
it('rejects localhost api_url', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Authorization', authHeader)
.send({ name: 'bad-node', type: 'remote', api_url: 'http://localhost:6379' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/loopback/i);
});
it('rejects 127.0.0.1 api_url', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Authorization', authHeader)
.send({ name: 'bad-node-2', type: 'remote', api_url: 'http://127.0.0.1:5432' });
expect(res.status).toBe(400);
});
it('rejects non-http scheme', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Authorization', authHeader)
.send({ name: 'bad-node-3', type: 'remote', api_url: 'ftp://example.com' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/http/i);
});
it('rejects malformed URL', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Authorization', authHeader)
.send({ name: 'bad-node-4', type: 'remote', api_url: 'not-a-url' });
expect(res.status).toBe(400);
});
it('accepts valid LAN IP', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Authorization', authHeader)
.send({
name: 'lan-node',
type: 'remote',
api_url: 'http://192.168.1.50:3000',
api_token: 'sometoken',
});
// Should succeed (201 or 200) — not a validation error
expect(res.status).not.toBe(400);
});
it('requires api_url for remote nodes', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Authorization', authHeader)
.send({ name: 'missing-url', type: 'remote' });
expect(res.status).toBe(400);
});
});
describe('Stack name validation on GET routes (H3 fix)', () => {
it('rejects path traversal in GET /api/stacks/:stackName', async () => {
const res = await request(app)
.get('/api/stacks/..%2F..%2Fetc%2Fpasswd')
.set('Authorization', authHeader);
expect(res.status).toBe(400);
});
it('rejects dots in stack name', async () => {
const res = await request(app)
.get('/api/stacks/.hidden')
.set('Authorization', authHeader);
expect(res.status).toBe(400);
});
});