fix: address all final review findings for Phase 3
- CRITICAL: Fix command injection in discoveryService (execFile instead of exec, path validation regex) - CRITICAL: Add Zod validation on discover API endpoint - HIGH: Add Zod validation on discover/approve endpoint - HIGH: Add array length limits to import schema (1000/100/100) - HIGH: Fix theme broadcast echo loop (setTimeout vs queueMicrotask) - MEDIUM: Singleton BroadcastChannel instead of create-per-send - MEDIUM: Exclude sensitive APIs from service worker cache - MEDIUM: Fix TypeScript cast errors in exportService tests
This commit is contained in:
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock child_process for Docker discovery
|
||||
vi.mock('node:child_process', () => ({
|
||||
exec: vi.fn()
|
||||
execFile: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('node:util', () => ({
|
||||
@@ -14,11 +14,11 @@ vi.mock('../appService.js', () => ({
|
||||
findAll: vi.fn()
|
||||
}));
|
||||
|
||||
import { exec } from 'node:child_process';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { findAll as findAllApps } from '../appService.js';
|
||||
import { discoverDocker, discoverTraefik, discoverAll } from '../discoveryService.js';
|
||||
|
||||
const mockExec = exec as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockExecFile = execFile as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockFindAllApps = findAllApps as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe('discoveryService', () => {
|
||||
@@ -39,7 +39,7 @@ describe('discoveryService', () => {
|
||||
}
|
||||
];
|
||||
|
||||
mockExec.mockResolvedValue({ stdout: JSON.stringify(containers) });
|
||||
mockExecFile.mockResolvedValue({ stdout: JSON.stringify(containers) });
|
||||
|
||||
const result = await discoverDocker('/var/run/docker.sock');
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('discoveryService', () => {
|
||||
}
|
||||
];
|
||||
|
||||
mockExec.mockResolvedValue({ stdout: JSON.stringify(containers) });
|
||||
mockExecFile.mockResolvedValue({ stdout: JSON.stringify(containers) });
|
||||
|
||||
const result = await discoverDocker('/var/run/docker.sock');
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('discoveryService', () => {
|
||||
}
|
||||
];
|
||||
|
||||
mockExec.mockResolvedValue({ stdout: JSON.stringify(containers) });
|
||||
mockExecFile.mockResolvedValue({ stdout: JSON.stringify(containers) });
|
||||
|
||||
const result = await discoverDocker('/var/run/docker.sock');
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('discoveryService', () => {
|
||||
});
|
||||
|
||||
it('returns error when Docker socket is inaccessible', async () => {
|
||||
mockExec.mockRejectedValue(new Error('connect ENOENT /var/run/docker.sock'));
|
||||
mockExecFile.mockRejectedValue(new Error('connect ENOENT /var/run/docker.sock'));
|
||||
|
||||
const result = await discoverDocker('/var/run/docker.sock');
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('discoveryService', () => {
|
||||
|
||||
describe('discoverAll', () => {
|
||||
it('marks already-registered services', async () => {
|
||||
mockExec.mockResolvedValue({
|
||||
mockExecFile.mockResolvedValue({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
Id: 'c1',
|
||||
@@ -202,7 +202,7 @@ describe('discoveryService', () => {
|
||||
});
|
||||
|
||||
it('deduplicates by URL preferring Traefik', async () => {
|
||||
mockExec.mockResolvedValue({
|
||||
mockExecFile.mockResolvedValue({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
Id: 'c1',
|
||||
|
||||
@@ -102,8 +102,8 @@ describe('exportService', () => {
|
||||
healthcheckTimeout: 5000
|
||||
});
|
||||
// Internal fields should not be present
|
||||
expect((result.apps[0] as Record<string, unknown>).id).toBeUndefined();
|
||||
expect((result.apps[0] as Record<string, unknown>).createdById).toBeUndefined();
|
||||
expect((result.apps[0] as unknown as Record<string, unknown>).id).toBeUndefined();
|
||||
expect((result.apps[0] as unknown as Record<string, unknown>).createdById).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps boards with nested sections and widgets', async () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { exec } from 'node:child_process';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { findAll as findAllApps } from './appService.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -97,10 +97,15 @@ export async function discoverDocker(socketPath: string): Promise<{
|
||||
readonly services: readonly DiscoveredService[];
|
||||
readonly error?: string;
|
||||
}> {
|
||||
if (!/^[\w/.:-]+$/.test(socketPath)) {
|
||||
return { services: [], error: 'Invalid Docker socket path' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Use curl with Unix socket to query Docker API
|
||||
const { stdout } = await execAsync(
|
||||
`curl -s --unix-socket "${socketPath}" http://localhost/containers/json?all=false`,
|
||||
// Use execFile (no shell) to avoid command injection
|
||||
const { stdout } = await execFileAsync(
|
||||
'curl',
|
||||
['-s', '--unix-socket', socketPath, 'http://localhost/containers/json?all=false'],
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
|
||||
@@ -151,10 +151,10 @@ class ThemeStore {
|
||||
this.primaryHue = values.primaryHue;
|
||||
this.primarySaturation = values.primarySaturation;
|
||||
this.backgroundType = values.backgroundType;
|
||||
// Re-enable on next microtask so the effect reads suppressBroadcast=true
|
||||
queueMicrotask(() => {
|
||||
// Use setTimeout to ensure all Svelte 5 effects have fired before re-enabling broadcast
|
||||
setTimeout(() => {
|
||||
this.#suppressBroadcast = false;
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,10 +21,15 @@ export interface DataChangeMessage {
|
||||
|
||||
export type SyncMessage = ThemeChangeMessage | DataChangeMessage;
|
||||
|
||||
/** Singleton channel instance, lazily created and reused for all sends and listens. */
|
||||
let singletonChannel: BroadcastChannel | null = null;
|
||||
|
||||
function getChannel(): BroadcastChannel | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
if (singletonChannel) return singletonChannel;
|
||||
try {
|
||||
return new BroadcastChannel(CHANNEL_NAME);
|
||||
singletonChannel = new BroadcastChannel(CHANNEL_NAME);
|
||||
return singletonChannel;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -38,7 +43,6 @@ export function broadcastThemeChange(theme: ThemeChangeMessage['payload']): void
|
||||
if (!channel) return;
|
||||
const message: ThemeChangeMessage = { type: 'theme-change', payload: theme };
|
||||
channel.postMessage(message);
|
||||
channel.close();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,7 +53,6 @@ export function broadcastDataChange(entity: 'board' | 'app' | 'widget'): void {
|
||||
if (!channel) return;
|
||||
const message: DataChangeMessage = { type: 'data-change', payload: { entity } };
|
||||
channel.postMessage(message);
|
||||
channel.close();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +73,5 @@ export function onSyncMessage(callback: (msg: SyncMessage) => void): () => void
|
||||
|
||||
return () => {
|
||||
channel.removeEventListener('message', handler);
|
||||
channel.close();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -240,9 +240,9 @@ const importSettingsSchema = z.object({
|
||||
export const importDataSchema = z.object({
|
||||
version: z.string(),
|
||||
exportedAt: z.string(),
|
||||
apps: z.array(importAppSchema),
|
||||
boards: z.array(importBoardSchema),
|
||||
groups: z.array(importGroupSchema),
|
||||
apps: z.array(importAppSchema).max(1000),
|
||||
boards: z.array(importBoardSchema).max(100),
|
||||
groups: z.array(importGroupSchema).max(100),
|
||||
settings: importSettingsSchema
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user