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:
2026-03-25 01:28:24 +03:00
parent 7d8a8fb0fc
commit 395ed821b7
9 changed files with 72 additions and 50 deletions
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock child_process for Docker discovery // Mock child_process for Docker discovery
vi.mock('node:child_process', () => ({ vi.mock('node:child_process', () => ({
exec: vi.fn() execFile: vi.fn()
})); }));
vi.mock('node:util', () => ({ vi.mock('node:util', () => ({
@@ -14,11 +14,11 @@ vi.mock('../appService.js', () => ({
findAll: vi.fn() findAll: vi.fn()
})); }));
import { exec } from 'node:child_process'; import { execFile } from 'node:child_process';
import { findAll as findAllApps } from '../appService.js'; import { findAll as findAllApps } from '../appService.js';
import { discoverDocker, discoverTraefik, discoverAll } from '../discoveryService.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>; const mockFindAllApps = findAllApps as ReturnType<typeof vi.fn>;
describe('discoveryService', () => { 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'); 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'); 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'); const result = await discoverDocker('/var/run/docker.sock');
@@ -93,7 +93,7 @@ describe('discoveryService', () => {
}); });
it('returns error when Docker socket is inaccessible', async () => { 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'); const result = await discoverDocker('/var/run/docker.sock');
@@ -178,7 +178,7 @@ describe('discoveryService', () => {
describe('discoverAll', () => { describe('discoverAll', () => {
it('marks already-registered services', async () => { it('marks already-registered services', async () => {
mockExec.mockResolvedValue({ mockExecFile.mockResolvedValue({
stdout: JSON.stringify([ stdout: JSON.stringify([
{ {
Id: 'c1', Id: 'c1',
@@ -202,7 +202,7 @@ describe('discoveryService', () => {
}); });
it('deduplicates by URL preferring Traefik', async () => { it('deduplicates by URL preferring Traefik', async () => {
mockExec.mockResolvedValue({ mockExecFile.mockResolvedValue({
stdout: JSON.stringify([ stdout: JSON.stringify([
{ {
Id: 'c1', Id: 'c1',
@@ -102,8 +102,8 @@ describe('exportService', () => {
healthcheckTimeout: 5000 healthcheckTimeout: 5000
}); });
// Internal fields should not be present // Internal fields should not be present
expect((result.apps[0] as Record<string, unknown>).id).toBeUndefined(); expect((result.apps[0] as unknown 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>).createdById).toBeUndefined();
}); });
it('maps boards with nested sections and widgets', async () => { it('maps boards with nested sections and widgets', async () => {
+10 -5
View File
@@ -1,8 +1,8 @@
import { exec } from 'node:child_process'; import { execFile } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { findAll as findAllApps } from './appService.js'; import { findAll as findAllApps } from './appService.js';
const execAsync = promisify(exec); const execFileAsync = promisify(execFile);
// --- Types --- // --- Types ---
@@ -97,10 +97,15 @@ export async function discoverDocker(socketPath: string): Promise<{
readonly services: readonly DiscoveredService[]; readonly services: readonly DiscoveredService[];
readonly error?: string; readonly error?: string;
}> { }> {
if (!/^[\w/.:-]+$/.test(socketPath)) {
return { services: [], error: 'Invalid Docker socket path' };
}
try { try {
// Use curl with Unix socket to query Docker API // Use execFile (no shell) to avoid command injection
const { stdout } = await execAsync( const { stdout } = await execFileAsync(
`curl -s --unix-socket "${socketPath}" http://localhost/containers/json?all=false`, 'curl',
['-s', '--unix-socket', socketPath, 'http://localhost/containers/json?all=false'],
{ timeout: 10000 } { timeout: 10000 }
); );
+3 -3
View File
@@ -151,10 +151,10 @@ class ThemeStore {
this.primaryHue = values.primaryHue; this.primaryHue = values.primaryHue;
this.primarySaturation = values.primarySaturation; this.primarySaturation = values.primarySaturation;
this.backgroundType = values.backgroundType; this.backgroundType = values.backgroundType;
// Re-enable on next microtask so the effect reads suppressBroadcast=true // Use setTimeout to ensure all Svelte 5 effects have fired before re-enabling broadcast
queueMicrotask(() => { setTimeout(() => {
this.#suppressBroadcast = false; this.#suppressBroadcast = false;
}); }, 100);
} }
/** /**
+6 -4
View File
@@ -21,10 +21,15 @@ export interface DataChangeMessage {
export type SyncMessage = ThemeChangeMessage | 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 { function getChannel(): BroadcastChannel | null {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
if (singletonChannel) return singletonChannel;
try { try {
return new BroadcastChannel(CHANNEL_NAME); singletonChannel = new BroadcastChannel(CHANNEL_NAME);
return singletonChannel;
} catch { } catch {
return null; return null;
} }
@@ -38,7 +43,6 @@ export function broadcastThemeChange(theme: ThemeChangeMessage['payload']): void
if (!channel) return; if (!channel) return;
const message: ThemeChangeMessage = { type: 'theme-change', payload: theme }; const message: ThemeChangeMessage = { type: 'theme-change', payload: theme };
channel.postMessage(message); channel.postMessage(message);
channel.close();
} }
/** /**
@@ -49,7 +53,6 @@ export function broadcastDataChange(entity: 'board' | 'app' | 'widget'): void {
if (!channel) return; if (!channel) return;
const message: DataChangeMessage = { type: 'data-change', payload: { entity } }; const message: DataChangeMessage = { type: 'data-change', payload: { entity } };
channel.postMessage(message); channel.postMessage(message);
channel.close();
} }
/** /**
@@ -70,6 +73,5 @@ export function onSyncMessage(callback: (msg: SyncMessage) => void): () => void
return () => { return () => {
channel.removeEventListener('message', handler); channel.removeEventListener('message', handler);
channel.close();
}; };
} }
+3 -3
View File
@@ -240,9 +240,9 @@ const importSettingsSchema = z.object({
export const importDataSchema = z.object({ export const importDataSchema = z.object({
version: z.string(), version: z.string(),
exportedAt: z.string(), exportedAt: z.string(),
apps: z.array(importAppSchema), apps: z.array(importAppSchema).max(1000),
boards: z.array(importBoardSchema), boards: z.array(importBoardSchema).max(100),
groups: z.array(importGroupSchema), groups: z.array(importGroupSchema).max(100),
settings: importSettingsSchema settings: importSettingsSchema
}); });
+15 -4
View File
@@ -1,9 +1,15 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { z } from 'zod';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js'; import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { discoverAll, type DiscoveryConfig } from '$lib/server/services/discoveryService.js'; import { discoverAll, type DiscoveryConfig } from '$lib/server/services/discoveryService.js';
import { success, error } from '$lib/server/utils/response.js'; import { success, error } from '$lib/server/utils/response.js';
const discoverConfigSchema = z.object({
dockerSocketPath: z.string().regex(/^[\w/.:-]+$/).optional(),
traefikApiUrl: z.string().url().optional()
});
/** /**
* POST /api/admin/discover — Scan Docker and Traefik for services. Admin only. * POST /api/admin/discover — Scan Docker and Traefik for services. Admin only.
* *
@@ -12,16 +18,21 @@ import { success, error } from '$lib/server/utils/response.js';
export const POST: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => {
requireAdmin(event); requireAdmin(event);
let body: DiscoveryConfig; let rawBody: unknown;
try { try {
body = await event.request.json(); rawBody = await event.request.json();
} catch { } catch {
return json(error('Invalid JSON body'), { status: 400 }); return json(error('Invalid JSON body'), { status: 400 });
} }
const parsed = discoverConfigSchema.safeParse(rawBody);
if (!parsed.success) {
return json(error(`Validation failed: ${parsed.error.issues.map((i) => i.message).join(', ')}`), { status: 400 });
}
const config: DiscoveryConfig = { const config: DiscoveryConfig = {
dockerSocketPath: body.dockerSocketPath || undefined, dockerSocketPath: parsed.data.dockerSocketPath || undefined,
traefikApiUrl: body.traefikApiUrl || undefined traefikApiUrl: parsed.data.traefikApiUrl || undefined
}; };
if (!config.dockerSocketPath && !config.traefikApiUrl) { if (!config.dockerSocketPath && !config.traefikApiUrl) {
@@ -1,20 +1,19 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { z } from 'zod';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { requireAdmin } from '$lib/server/middleware/authorize.js'; import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { create } from '$lib/server/services/appService.js'; import { create } from '$lib/server/services/appService.js';
import { success, error } from '$lib/server/utils/response.js'; import { success, error } from '$lib/server/utils/response.js';
interface ApproveServiceInput { const approveSchema = z.object({
readonly name: string; services: z.array(z.object({
readonly url: string; name: z.string().min(1),
readonly source: 'docker' | 'traefik'; url: z.string().url(),
readonly icon?: string; source: z.enum(['docker', 'traefik']),
readonly description?: string; icon: z.string().optional(),
} description: z.string().optional()
})).min(1).max(100)
interface ApproveBody { });
readonly services: readonly ApproveServiceInput[];
}
/** /**
* POST /api/admin/discover/approve — Approve discovered services and create app entries. Admin only. * POST /api/admin/discover/approve — Approve discovered services and create app entries. Admin only.
@@ -24,26 +23,24 @@ interface ApproveBody {
export const POST: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => {
const user = requireAdmin(event); const user = requireAdmin(event);
let body: ApproveBody; let rawBody: unknown;
try { try {
body = await event.request.json(); rawBody = await event.request.json();
} catch { } catch {
return json(error('Invalid JSON body'), { status: 400 }); return json(error('Invalid JSON body'), { status: 400 });
} }
if (!body.services || !Array.isArray(body.services) || body.services.length === 0) { const parsed = approveSchema.safeParse(rawBody);
return json(error('At least one service must be provided for approval'), { status: 400 }); if (!parsed.success) {
return json(error(`Validation failed: ${parsed.error.issues.map((i) => i.message).join(', ')}`), { status: 400 });
} }
const body = parsed.data;
const created: string[] = []; const created: string[] = [];
const errors: string[] = []; const errors: string[] = [];
for (const service of body.services) { for (const service of body.services) {
if (!service.name || !service.url) {
errors.push(`Skipped invalid service entry (missing name or url)`);
continue;
}
try { try {
const app = await create({ const app = await create({
name: service.name, name: service.name,
+7
View File
@@ -50,6 +50,13 @@ self.addEventListener('fetch', (event: FetchEvent) => {
// Skip cross-origin requests // Skip cross-origin requests
if (url.origin !== self.location.origin) return; if (url.origin !== self.location.origin) return;
// Sensitive API paths: never cache, always go to network
const sensitiveApiPrefixes = ['/api/users/', '/api/admin/', '/api/auth/'];
if (sensitiveApiPrefixes.some((prefix) => url.pathname.startsWith(prefix))) {
event.respondWith(fetch(request));
return;
}
// API calls: network-first with cache fallback // API calls: network-first with cache fallback
if (url.pathname.startsWith('/api/')) { if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request)); event.respondWith(networkFirst(request));