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
+52
View File
@@ -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)
);
}