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
+112
View File
@@ -0,0 +1,112 @@
/**
* Tests for authentication: login, rate limiting, and auth middleware.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
// ─── Login ───────────────────────────────────────────────────────────────────
describe('POST /api/auth/login', () => {
it('returns 200 and sets a cookie on valid credentials', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: TEST_USERNAME, password: TEST_PASSWORD });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.headers['set-cookie']).toBeDefined();
});
it('returns 401 on wrong password', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: TEST_USERNAME, password: 'wrong-password' });
expect(res.status).toBe(401);
});
it('returns 401 on unknown username', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: 'nobody', password: 'anything' });
expect(res.status).toBe(401);
});
it('returns 400 when credentials are missing', async () => {
const res = await request(app).post('/api/auth/login').send({});
expect(res.status).toBe(400);
});
});
// ─── Auth middleware ──────────────────────────────────────────────────────────
describe('authMiddleware', () => {
it('rejects requests with no token (401)', async () => {
const res = await request(app).get('/api/stacks');
expect(res.status).toBe(401);
});
it('rejects requests with an invalid token (401)', async () => {
const res = await request(app)
.get('/api/stacks')
.set('Authorization', 'Bearer this.is.not.valid');
expect(res.status).toBe(401);
});
it('accepts a valid Bearer token', async () => {
// Issue a real token using the known test secret
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
const res = await request(app)
.get('/api/stacks')
.set('Authorization', `Bearer ${token}`);
// Will succeed (200) or fail with a docker/fs error (500) — but NOT 401
expect(res.status).not.toBe(401);
});
it('accepts a valid cookie token', async () => {
// First login to get the cookie
const loginRes = await request(app)
.post('/api/auth/login')
.send({ username: TEST_USERNAME, password: TEST_PASSWORD });
const cookies = loginRes.headers['set-cookie'] as string | string[];
const cookieHeader = Array.isArray(cookies) ? cookies[0] : cookies;
const res = await request(app)
.get('/api/stacks')
.set('Cookie', cookieHeader);
expect(res.status).not.toBe(401);
});
});
// ─── Protected endpoint: console-token ───────────────────────────────────────
describe('POST /api/system/console-token', () => {
it('returns 401 without authentication (was a security bug — C1 fix)', async () => {
const res = await request(app).post('/api/system/console-token');
expect(res.status).toBe(401);
});
it('returns a token when authenticated', async () => {
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
const res = await request(app)
.post('/api/system/console-token')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(typeof res.body.token).toBe('string');
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* Tests for the public /api/health endpoint.
* This endpoint must be reachable without authentication.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
beforeAll(async () => {
// setupTestDb must run before any app import so DATA_DIR is set first
tmpDir = await setupTestDb();
({ app } = await import('../index'));
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
describe('GET /api/health', () => {
it('returns 200 with status ok', async () => {
const res = await request(app).get('/api/health');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
});
it('returns uptime as a number', async () => {
const res = await request(app).get('/api/health');
expect(typeof res.body.uptime).toBe('number');
expect(res.body.uptime).toBeGreaterThanOrEqual(0);
});
it('does not require an auth token', async () => {
// No cookie, no Authorization header — must still return 200
const res = await request(app).get('/api/health');
expect(res.status).not.toBe(401);
expect(res.status).not.toBe(403);
});
});
@@ -0,0 +1,46 @@
/**
* Test DB helper — creates a temporary SQLite database, seeds it with a known
* admin credential, and sets process.env so DatabaseService uses it.
*
* Call this at the top of every test file *before* importing the app,
* because DatabaseService initialises its path on first getInstance() call.
*/
import os from 'os';
import path from 'path';
import fs from 'fs';
import bcrypt from 'bcrypt';
import crypto from 'crypto';
export const TEST_USERNAME = 'testadmin';
export const TEST_PASSWORD = 'testpassword123';
export let TEST_JWT_SECRET = '';
export async function setupTestDb(): Promise<string> {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-test-'));
process.env.DATA_DIR = tmpDir;
// Also point COMPOSE_DIR to a temp dir so FileSystemService doesn't fail on missing dir
const composeDir = path.join(tmpDir, 'compose');
fs.mkdirSync(composeDir, { recursive: true });
process.env.COMPOSE_DIR = composeDir;
// Initialise the DB (singleton will use DATA_DIR we just set)
const { DatabaseService } = await import('../../services/DatabaseService');
const db = DatabaseService.getInstance();
// Seed admin credentials
const passwordHash = await bcrypt.hash(TEST_PASSWORD, 1); // cost=1 for speed in tests
TEST_JWT_SECRET = crypto.randomBytes(32).toString('hex');
db.updateGlobalSetting('auth_username', TEST_USERNAME);
db.updateGlobalSetting('auth_password_hash', passwordHash);
db.updateGlobalSetting('auth_jwt_secret', TEST_JWT_SECRET);
return tmpDir;
}
export function cleanupTestDb(tmpDir: string): void {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// best-effort cleanup
}
}
+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);
});
});
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import { isValidStackName, isValidRemoteUrl, isPathWithinBase } from '../utils/validation';
// ─── isValidStackName ────────────────────────────────────────────────────────
describe('isValidStackName', () => {
it('accepts alphanumeric names', () => {
expect(isValidStackName('mystack')).toBe(true);
expect(isValidStackName('MyStack123')).toBe(true);
});
it('accepts hyphens and underscores', () => {
expect(isValidStackName('my-stack')).toBe(true);
expect(isValidStackName('my_stack')).toBe(true);
});
it('rejects path separators', () => {
expect(isValidStackName('../etc')).toBe(false);
expect(isValidStackName('foo/bar')).toBe(false);
expect(isValidStackName('foo\\bar')).toBe(false);
});
it('rejects dots', () => {
expect(isValidStackName('.hidden')).toBe(false);
expect(isValidStackName('foo.bar')).toBe(false);
});
it('rejects spaces and special characters', () => {
expect(isValidStackName('my stack')).toBe(false);
expect(isValidStackName('foo;rm -rf /')).toBe(false);
expect(isValidStackName('')).toBe(false);
});
});
// ─── isValidRemoteUrl ────────────────────────────────────────────────────────
describe('isValidRemoteUrl', () => {
it('accepts valid http URLs', () => {
const result = isValidRemoteUrl('http://192.168.1.10:3000');
expect(result.valid).toBe(true);
});
it('accepts valid https URLs', () => {
const result = isValidRemoteUrl('https://sencho.example.com');
expect(result.valid).toBe(true);
});
it('rejects malformed URLs', () => {
const result = isValidRemoteUrl('not-a-url');
expect(result.valid).toBe(false);
});
it('rejects non-http schemes', () => {
expect(isValidRemoteUrl('ftp://example.com').valid).toBe(false);
expect(isValidRemoteUrl('file:///etc/passwd').valid).toBe(false);
expect(isValidRemoteUrl('javascript:alert(1)').valid).toBe(false);
});
it('rejects localhost', () => {
expect(isValidRemoteUrl('http://localhost:3000').valid).toBe(false);
expect(isValidRemoteUrl('http://LOCALHOST:3000').valid).toBe(false);
});
it('rejects loopback IPs', () => {
expect(isValidRemoteUrl('http://127.0.0.1:3000').valid).toBe(false);
expect(isValidRemoteUrl('http://127.1.2.3').valid).toBe(false);
// Node.js URL.hostname preserves brackets: new URL('http://[::1]').hostname === '[::1]'
expect(isValidRemoteUrl('http://[::1]:3000').valid).toBe(false);
});
it('rejects 0.0.0.0', () => {
expect(isValidRemoteUrl('http://0.0.0.0:3000').valid).toBe(false);
});
it('allows LAN/private IPs (users need these for local network nodes)', () => {
// Users legitimately run Sencho nodes on their LAN
expect(isValidRemoteUrl('http://192.168.1.100:3000').valid).toBe(true);
expect(isValidRemoteUrl('http://10.0.0.5:3000').valid).toBe(true);
});
});
// ─── isPathWithinBase ────────────────────────────────────────────────────────
describe('isPathWithinBase', () => {
it('accepts paths within the base directory', () => {
expect(isPathWithinBase('/app/compose/mystack/.env', '/app/compose/mystack')).toBe(true);
});
it('accepts the base directory itself', () => {
expect(isPathWithinBase('/app/compose/mystack', '/app/compose/mystack')).toBe(true);
});
it('rejects paths that escape via ..', () => {
expect(isPathWithinBase('/app/compose/mystack/../../../etc/passwd', '/app/compose/mystack')).toBe(false);
});
it('rejects sibling directories', () => {
expect(isPathWithinBase('/app/compose/other-stack/.env', '/app/compose/mystack')).toBe(false);
});
});