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
+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);
});
});