mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+109
-22
@@ -1,7 +1,9 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import cors from 'cors';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import WebSocket from 'ws';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import helmet from 'helmet';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import DockerController, { globalDockerNetwork } from './services/DockerController';
|
||||
import { FileSystemService } from './services/FileSystemService';
|
||||
@@ -25,6 +27,7 @@ import { ImageUpdateService } from './services/ImageUpdateService';
|
||||
import { templateService } from './services/TemplateService';
|
||||
import { ErrorParser } from './utils/ErrorParser';
|
||||
import { NodeRegistry } from './services/NodeRegistry';
|
||||
import { isValidStackName, isValidRemoteUrl } from './utils/validation';
|
||||
import YAML from 'yaml';
|
||||
import fs, { promises as fsPromises } from 'fs';
|
||||
|
||||
@@ -64,8 +67,20 @@ const getCookieOptions = (req: Request) => ({
|
||||
});
|
||||
|
||||
// Middleware
|
||||
|
||||
// Security headers (X-Frame-Options, X-Content-Type-Options, etc.)
|
||||
// crossOriginEmbedderPolicy is disabled because the Monaco editor uses workers
|
||||
// that don't set the required COEP headers.
|
||||
app.use(helmet({ crossOriginEmbedderPolicy: false }));
|
||||
|
||||
// CORS — in production restrict to the configured frontend origin.
|
||||
// In development, mirror the request origin so Vite's dev server works.
|
||||
const corsOrigin = process.env.NODE_ENV === 'production' && process.env.FRONTEND_URL
|
||||
? process.env.FRONTEND_URL
|
||||
: true;
|
||||
|
||||
app.use(cors({
|
||||
origin: true,
|
||||
origin: corsOrigin,
|
||||
credentials: true,
|
||||
}));
|
||||
// Conditionally parse JSON bodies. Remote proxy requests must NOT have their body
|
||||
@@ -177,6 +192,22 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
|
||||
}
|
||||
};
|
||||
|
||||
// Rate limiter for auth endpoints — prevents brute-force attacks.
|
||||
// 5 attempts per 15-minute window per IP. Applies to login and setup only;
|
||||
// password-change is already protected by authMiddleware + old-password verification.
|
||||
const authRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 5,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Too many attempts. Please try again in 15 minutes.' },
|
||||
});
|
||||
|
||||
// Public health endpoint - no auth required (used by Docker HEALTHCHECK and uptime monitors)
|
||||
app.get('/api/health', (_req: Request, res: Response): void => {
|
||||
res.json({ status: 'ok', uptime: process.uptime() });
|
||||
});
|
||||
|
||||
// Auth Routes (no authentication required)
|
||||
|
||||
// Check if setup is needed
|
||||
@@ -192,7 +223,7 @@ app.get('/api/auth/status', async (req: Request, res: Response): Promise<void> =
|
||||
});
|
||||
|
||||
// Initial setup endpoint
|
||||
app.post('/api/auth/setup', async (req: Request, res: Response): Promise<void> => {
|
||||
app.post('/api/auth/setup', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const dbSvc = DatabaseService.getInstance();
|
||||
const settings = dbSvc.getGlobalSettings();
|
||||
@@ -243,7 +274,7 @@ app.post('/api/auth/setup', async (req: Request, res: Response): Promise<void> =
|
||||
});
|
||||
|
||||
// Login endpoint
|
||||
app.post('/api/auth/login', async (req: Request, res: Response): Promise<void> => {
|
||||
app.post('/api/auth/login', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
@@ -443,7 +474,7 @@ app.use('/api/', (req: Request, res: Response, next: NextFunction): void => {
|
||||
const server = http.createServer(app);
|
||||
|
||||
// WebSocket server with authentication
|
||||
const wss = new WebSocket.Server({ noServer: true });
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
let terminalWs: WebSocket | null = null;
|
||||
|
||||
@@ -504,7 +535,7 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
// When a nodeId pointing to a remote node is provided, fall through to the
|
||||
// proxy block below so the browser subscribes to that remote node's push stream.
|
||||
if (pathname === '/ws/notifications' && (!node || node.type !== 'remote')) {
|
||||
const notifWss = new WebSocket.Server({ noServer: true });
|
||||
const notifWss = new WebSocketServer({ noServer: true });
|
||||
notifWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
notifWss.close();
|
||||
notificationSubscribers.add(ws);
|
||||
@@ -568,7 +599,7 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
|
||||
if (logsMatch) {
|
||||
// Dedicated stack logs WebSocket - uses Supervisor loop for persistent logs
|
||||
const logsWss = new WebSocket.Server({ noServer: true });
|
||||
const logsWss = new WebSocketServer({ noServer: true });
|
||||
logsWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
// Close the per-connection server immediately after the upgrade is complete.
|
||||
// The wss instance is only needed to negotiate the handshake; keeping it open
|
||||
@@ -591,7 +622,7 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const hostConsoleWss = new WebSocket.Server({ noServer: true });
|
||||
const hostConsoleWss = new WebSocketServer({ noServer: true });
|
||||
hostConsoleWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
hostConsoleWss.close();
|
||||
let targetDirectory = '';
|
||||
@@ -708,6 +739,9 @@ app.get('/api/stacks', async (req: Request, res: Response) => {
|
||||
app.get('/api/stacks/:stackName', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
const content = await FileSystemService.getInstance(req.nodeId).getStackContent(stackName);
|
||||
res.send(content);
|
||||
} catch (error) {
|
||||
@@ -768,20 +802,22 @@ async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promis
|
||||
const service = parsed.services[serviceName];
|
||||
if (!service?.env_file) continue;
|
||||
|
||||
const addEnvPath = (rawPath: string) => {
|
||||
const resolved = path.resolve(stackDir, rawPath);
|
||||
// Reject paths that escape the stack directory
|
||||
if (!resolved.startsWith(path.resolve(stackDir) + path.sep) && resolved !== path.resolve(stackDir)) {
|
||||
console.warn(`[Security] env_file path "${rawPath}" escapes stack directory — skipping`);
|
||||
return;
|
||||
}
|
||||
envFiles.add(resolved);
|
||||
};
|
||||
|
||||
if (typeof service.env_file === 'string') {
|
||||
const resolvedPath = path.isAbsolute(service.env_file)
|
||||
? service.env_file
|
||||
: path.resolve(stackDir, service.env_file);
|
||||
envFiles.add(resolvedPath);
|
||||
addEnvPath(service.env_file);
|
||||
} else if (Array.isArray(service.env_file)) {
|
||||
for (const entry of service.env_file) {
|
||||
const entryPath = typeof entry === 'string' ? entry : (entry?.path || '');
|
||||
if (entryPath) {
|
||||
const resolvedPath = path.isAbsolute(entryPath)
|
||||
? entryPath
|
||||
: path.resolve(stackDir, entryPath);
|
||||
envFiles.add(resolvedPath);
|
||||
}
|
||||
if (entryPath) addEnvPath(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -801,6 +837,9 @@ async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promis
|
||||
app.get('/api/stacks/:stackName/envs', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName);
|
||||
res.json({ envFiles: envPaths });
|
||||
} catch (error) {
|
||||
@@ -811,6 +850,9 @@ app.get('/api/stacks/:stackName/envs', async (req: Request, res: Response) => {
|
||||
app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!isValidStackName(stackName)) {
|
||||
return res.status(400).json({ error: 'Invalid stack name' });
|
||||
}
|
||||
const requestedFile = req.query.file as string | undefined;
|
||||
const envPaths = await resolveAllEnvFilePaths(req.nodeId, stackName);
|
||||
|
||||
@@ -1539,7 +1581,7 @@ app.post('/api/notifications/test', async (req: Request, res: Response) => {
|
||||
// to a remote node, it calls this endpoint (authenticated with the long-lived api_token)
|
||||
// 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', (req: Request, res: Response): void => {
|
||||
app.post('/api/system/console-token', authMiddleware, (req: Request, res: Response): void => {
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const jwtSecret = settings.auth_jwt_secret;
|
||||
@@ -1801,6 +1843,7 @@ app.post('/api/image-updates/refresh', authMiddleware, (_req: Request, res: Resp
|
||||
// Node Management API
|
||||
// =========================
|
||||
|
||||
|
||||
// List all nodes
|
||||
app.get('/api/nodes', async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -1836,8 +1879,14 @@ app.post('/api/nodes', async (req: Request, res: Response) => {
|
||||
if (!type || !['local', 'remote'].includes(type)) {
|
||||
return res.status(400).json({ error: 'Node type must be "local" or "remote"' });
|
||||
}
|
||||
if (type === 'remote' && (!api_url || typeof api_url !== 'string')) {
|
||||
return res.status(400).json({ error: 'API URL is required for remote nodes' });
|
||||
if (type === 'remote') {
|
||||
if (!api_url || typeof api_url !== 'string') {
|
||||
return res.status(400).json({ error: 'API URL is required for remote nodes' });
|
||||
}
|
||||
const urlCheck = isValidRemoteUrl(api_url);
|
||||
if (!urlCheck.valid) {
|
||||
return res.status(400).json({ error: urlCheck.reason });
|
||||
}
|
||||
}
|
||||
|
||||
const id = DatabaseService.getInstance().addNode({
|
||||
@@ -1865,6 +1914,13 @@ app.put('/api/nodes/:id', async (req: Request, res: Response) => {
|
||||
const id = parseInt(req.params.id as string);
|
||||
const updates = req.body;
|
||||
|
||||
if (updates.api_url !== undefined && updates.api_url !== '') {
|
||||
const urlCheck = isValidRemoteUrl(updates.api_url);
|
||||
if (!urlCheck.valid) {
|
||||
return res.status(400).json({ error: urlCheck.reason });
|
||||
}
|
||||
}
|
||||
|
||||
DatabaseService.getInstance().updateNode(id, updates);
|
||||
|
||||
// Evict cached Docker connection so it reconnects with new config
|
||||
@@ -1948,4 +2004,35 @@ async function startServer() {
|
||||
});
|
||||
}
|
||||
|
||||
startServer();
|
||||
// Only start the server when this file is the entry point (not when imported by tests).
|
||||
if (require.main === module) {
|
||||
startServer();
|
||||
}
|
||||
|
||||
// Exports used by tests (supertest requires the http.Server instance).
|
||||
export { app, server };
|
||||
|
||||
// Graceful shutdown — allows in-flight requests to finish, then cleanly stops
|
||||
// background services and closes the SQLite connection before the process exits.
|
||||
// Docker sends SIGTERM when the container stops; Ctrl-C sends SIGINT in dev.
|
||||
const gracefulShutdown = (signal: string) => {
|
||||
console.log(`[Shutdown] ${signal} received — shutting down gracefully…`);
|
||||
|
||||
server.close(() => {
|
||||
console.log('[Shutdown] HTTP server closed');
|
||||
try { MonitorService.getInstance().stop(); } catch { /* already stopped */ }
|
||||
try { ImageUpdateService.getInstance().stop(); } catch { /* already stopped */ }
|
||||
try { DatabaseService.getInstance().getDb().close(); } catch { /* already closed */ }
|
||||
console.log('[Shutdown] Done — exiting');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Force-exit after 10 s if connections refuse to drain
|
||||
setTimeout(() => {
|
||||
console.error('[Shutdown] Timed out waiting for connections — forcing exit');
|
||||
process.exit(1);
|
||||
}, 10_000).unref();
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Stack name must only contain URL-safe characters with no path separators.
|
||||
* Prevents path-traversal attacks when the name is used to build filesystem paths.
|
||||
*/
|
||||
export const isValidStackName = (name: string): boolean =>
|
||||
/^[a-zA-Z0-9_-]+$/.test(name);
|
||||
|
||||
/**
|
||||
* Validates that a remote node API URL is a safe, well-formed HTTP/HTTPS URL.
|
||||
* Rejects loopback addresses to prevent SSRF against local services.
|
||||
* Private/LAN IPs are allowed — users legitimately point Sencho at nodes on their LAN.
|
||||
*/
|
||||
export function isValidRemoteUrl(
|
||||
raw: string,
|
||||
): { valid: true; url: URL } | { valid: false; reason: string } {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'API URL must be a valid URL (e.g. https://my-server.example.com:3000)',
|
||||
};
|
||||
}
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
return { valid: false, reason: 'API URL must use http:// or https://' };
|
||||
}
|
||||
// Node.js URL API preserves brackets for IPv6: new URL('http://[::1]').hostname === '[::1]'
|
||||
const loopback = /^(localhost|127(\.\d+){3}|\[::1\]|0\.0\.0\.0)$/i;
|
||||
if (loopback.test(url.hostname)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: 'API URL cannot point to localhost or loopback — use the actual host address',
|
||||
};
|
||||
}
|
||||
return { valid: true, url };
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that a resolved file path stays within a given base directory.
|
||||
* Returns true if the path is safe, false if it escapes the base.
|
||||
*/
|
||||
export function isPathWithinBase(resolvedPath: string, baseDir: string): boolean {
|
||||
const normalizedBase = path.resolve(baseDir);
|
||||
const normalizedPath = path.resolve(resolvedPath);
|
||||
return (
|
||||
normalizedPath === normalizedBase ||
|
||||
normalizedPath.startsWith(normalizedBase + path.sep)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user