mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-22 08:06:42 +00:00
fix(stacks): harden stack management with security, validation, and logging (#520)
* fix(stacks): harden stack management with security fixes, validation alignment, and logging Validate WebSocket stack names with isValidStackName() to close a path-traversal gap on the /api/stacks/:stackName/logs WS endpoint. Align POST /api/stacks to use the canonical validator (allows underscores). Replace error: any catch blocks with error: unknown + type narrowing. Add cache invalidation to PUT /api/stacks/:stackName/env. Rename DELETE param from :name to :stackName for consistency. Add standard [Stacks] lifecycle logs and diagnostic [Stacks:debug] logs gated behind the Developer Mode toggle (with 5s TTL cache). Extract shared isDebugEnabled() and getErrorMessage() utilities. Frontend: roll back optimistic status on API failure, guard unsaved changes when switching stacks, pre-check duplicate names in App Store. * docs(settings): update Developer Mode description to mention debug diagnostics
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Unit tests for stack management hardening:
|
||||
* - WebSocket stack name validation
|
||||
* - Stack creation validation alignment
|
||||
* - Error type narrowing (unknown vs any)
|
||||
* - Cache invalidation on env write
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isValidStackName } from '../utils/validation';
|
||||
|
||||
// ─── WebSocket Stack Name Validation ────────────────────────────────────────
|
||||
// The WS handler at /api/stacks/:stackName/logs uses a regex to extract the
|
||||
// stack name, then must validate it with isValidStackName() before passing
|
||||
// it to streamLogs(). These tests verify the validation catches traversal
|
||||
// attempts that the regex alone would allow.
|
||||
|
||||
describe('WebSocket stack name validation', () => {
|
||||
it('rejects traversal patterns the URL regex would pass', () => {
|
||||
// The WS regex is /^\/api\/stacks\/([^/]+)\/logs$/ which only blocks '/'
|
||||
// After decodeURIComponent, these should still be caught by isValidStackName
|
||||
expect(isValidStackName('..')).toBe(false);
|
||||
expect(isValidStackName('..%2f')).toBe(false);
|
||||
expect(isValidStackName('foo bar')).toBe(false);
|
||||
expect(isValidStackName('foo;ls')).toBe(false);
|
||||
expect(isValidStackName('')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts valid stack names that would appear in WS paths', () => {
|
||||
expect(isValidStackName('nginx')).toBe(true);
|
||||
expect(isValidStackName('my-app')).toBe(true);
|
||||
expect(isValidStackName('my_app')).toBe(true);
|
||||
expect(isValidStackName('stack123')).toBe(true);
|
||||
});
|
||||
|
||||
it('validates URL-decoded stack names correctly', () => {
|
||||
// Simulate what happens after decodeURIComponent
|
||||
const encoded = '%2e%2e'; // '..' URL-encoded
|
||||
const decoded = decodeURIComponent(encoded);
|
||||
expect(isValidStackName(decoded)).toBe(false);
|
||||
|
||||
const encodedSlash = 'foo%2fbar'; // 'foo/bar' URL-encoded
|
||||
const decodedSlash = decodeURIComponent(encodedSlash);
|
||||
expect(isValidStackName(decodedSlash)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stack Creation Validation Alignment ────────────────────────────────────
|
||||
// POST /api/stacks should use isValidStackName() (which allows underscores)
|
||||
// instead of the old inline regex /^[a-zA-Z0-9-]+$/ (which rejected underscores).
|
||||
|
||||
describe('stack creation validation alignment', () => {
|
||||
it('allows underscores in stack names (aligned with isValidStackName)', () => {
|
||||
expect(isValidStackName('my_stack')).toBe(true);
|
||||
expect(isValidStackName('test_app_v2')).toBe(true);
|
||||
});
|
||||
|
||||
it('allows hyphens in stack names', () => {
|
||||
expect(isValidStackName('my-stack')).toBe(true);
|
||||
expect(isValidStackName('test-app-v2')).toBe(true);
|
||||
});
|
||||
|
||||
it('allows mixed hyphens and underscores', () => {
|
||||
expect(isValidStackName('my-stack_v2')).toBe(true);
|
||||
expect(isValidStackName('test_app-prod')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects names with dots (not valid for Docker compose project names)', () => {
|
||||
expect(isValidStackName('my.stack')).toBe(false);
|
||||
expect(isValidStackName('.env')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error Type Narrowing ──────────────────────────────────────────────────
|
||||
// Catch blocks should use `error: unknown` with instanceof narrowing, not `error: any`.
|
||||
// These tests verify the narrowing pattern works correctly.
|
||||
|
||||
describe('error type narrowing pattern', () => {
|
||||
it('extracts message from Error instances', () => {
|
||||
const error: unknown = new Error('Deploy failed: port conflict');
|
||||
const message = error instanceof Error ? error.message : 'Fallback message';
|
||||
expect(message).toBe('Deploy failed: port conflict');
|
||||
});
|
||||
|
||||
it('uses fallback for non-Error values', () => {
|
||||
const error: unknown = 'string error';
|
||||
const message = error instanceof Error ? error.message : 'Fallback message';
|
||||
expect(message).toBe('Fallback message');
|
||||
});
|
||||
|
||||
it('uses fallback for null/undefined', () => {
|
||||
const nullErr: unknown = null;
|
||||
const message1 = nullErr instanceof Error ? nullErr.message : 'Fallback';
|
||||
expect(message1).toBe('Fallback');
|
||||
|
||||
const undefErr: unknown = undefined;
|
||||
const message2 = undefErr instanceof Error ? undefErr.message : 'Fallback';
|
||||
expect(message2).toBe('Fallback');
|
||||
});
|
||||
|
||||
it('handles objects without message property', () => {
|
||||
const error: unknown = { code: 'ENOENT' };
|
||||
const message = error instanceof Error ? error.message : 'Fallback';
|
||||
expect(message).toBe('Fallback');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stack Name Edge Cases ──────────────────────────────────────────────────
|
||||
|
||||
describe('stack name edge cases for all endpoints', () => {
|
||||
it('rejects names that could cause shell injection', () => {
|
||||
expect(isValidStackName('$(whoami)')).toBe(false);
|
||||
expect(isValidStackName('`id`')).toBe(false);
|
||||
expect(isValidStackName('stack;rm -rf')).toBe(false);
|
||||
expect(isValidStackName('stack&&echo')).toBe(false);
|
||||
expect(isValidStackName('stack|cat')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects null bytes and control characters', () => {
|
||||
expect(isValidStackName('stack\x00')).toBe(false);
|
||||
expect(isValidStackName('stack\n')).toBe(false);
|
||||
expect(isValidStackName('stack\t')).toBe(false);
|
||||
expect(isValidStackName('stack\r')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects Windows path separators', () => {
|
||||
expect(isValidStackName('stack\\name')).toBe(false);
|
||||
expect(isValidStackName('C:\\stacks')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts single character names', () => {
|
||||
expect(isValidStackName('a')).toBe(true);
|
||||
expect(isValidStackName('1')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts numeric-only names', () => {
|
||||
expect(isValidStackName('123')).toBe(true);
|
||||
expect(isValidStackName('42')).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user