Skip to content
Open
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,4 @@ opencodetmp

# Spec Workflow MCP
.spec-workflow
.claude-context
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

55 changes: 54 additions & 1 deletion src/core/__tests__/security-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { tmpdir } from 'os';
import {
isLocalhostAddress,
getSecurityConfig,
generateAllowedOrigins,
DEFAULT_SECURITY_CONFIG,
VITE_DEV_PORT,
RateLimiter,
AuditLogger,
AuditLogEntry,
Expand Down Expand Up @@ -66,10 +68,61 @@ describe('security-utils', () => {
});
});

describe('generateAllowedOrigins', () => {
const originalNodeEnv = process.env.NODE_ENV;

afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
});

it('should include dashboard port origins', () => {
const origins = generateAllowedOrigins(5000);
expect(origins).toContain('http://localhost:5000');
expect(origins).toContain('http://127.0.0.1:5000');
});

it('should include Vite dev port in non-production environments', () => {
process.env.NODE_ENV = 'development';
const origins = generateAllowedOrigins(5000);
expect(origins).toContain(`http://localhost:${VITE_DEV_PORT}`);
expect(origins).toContain(`http://127.0.0.1:${VITE_DEV_PORT}`);
});

it('should include Vite dev port when NODE_ENV is undefined', () => {
// This is the key test - when NODE_ENV is not set, we should still include Vite dev port
// because we check !== 'production' rather than === 'development'
delete process.env.NODE_ENV;
const origins = generateAllowedOrigins(5000);
expect(origins).toContain(`http://localhost:${VITE_DEV_PORT}`);
expect(origins).toContain(`http://127.0.0.1:${VITE_DEV_PORT}`);
});

it('should NOT include Vite dev port in production', () => {
process.env.NODE_ENV = 'production';
const origins = generateAllowedOrigins(5000);
expect(origins).not.toContain(`http://localhost:${VITE_DEV_PORT}`);
expect(origins).not.toContain(`http://127.0.0.1:${VITE_DEV_PORT}`);
});

it('should use custom port for dashboard origins', () => {
const origins = generateAllowedOrigins(3000);
expect(origins).toContain('http://localhost:3000');
expect(origins).toContain('http://127.0.0.1:3000');
});
});

describe('getSecurityConfig', () => {
it('should return defaults when no config provided', () => {
const config = getSecurityConfig();
expect(config).toEqual(DEFAULT_SECURITY_CONFIG);
// In non-production, dynamic origins include Vite dev server ports
expect(config.rateLimitEnabled).toBe(DEFAULT_SECURITY_CONFIG.rateLimitEnabled);
expect(config.rateLimitPerMinute).toBe(DEFAULT_SECURITY_CONFIG.rateLimitPerMinute);
expect(config.auditLogEnabled).toBe(DEFAULT_SECURITY_CONFIG.auditLogEnabled);
expect(config.auditLogRetentionDays).toBe(DEFAULT_SECURITY_CONFIG.auditLogRetentionDays);
expect(config.corsEnabled).toBe(DEFAULT_SECURITY_CONFIG.corsEnabled);
// allowedOrigins includes default port + Vite dev port (5173) in non-production
expect(config.allowedOrigins).toContain('http://localhost:5000');
expect(config.allowedOrigins).toContain('http://127.0.0.1:5000');
});

it('should merge user config with defaults', () => {
Expand Down
24 changes: 22 additions & 2 deletions src/core/security-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,25 @@ export const DEFAULT_SECURITY_CONFIG: SecurityConfig = {
allowedOrigins: [`http://localhost:${DEFAULT_DASHBOARD_PORT}`, `http://127.0.0.1:${DEFAULT_DASHBOARD_PORT}`]
};

// Default Vite dev server port (used when running frontend in dev mode)
export const VITE_DEV_PORT = 5173;

/**
* Generate allowed origins for CORS based on the actual port
* @param port - The port the dashboard is running on
* @returns Array of allowed origin URLs
*/
export function generateAllowedOrigins(port: number): string[] {
return [`http://localhost:${port}`, `http://127.0.0.1:${port}`];
const origins = [`http://localhost:${port}`, `http://127.0.0.1:${port}`];

// In non-production environments, also allow Vite dev server origin (port 5173)
// The Vite proxy forwards requests but preserves the Origin header
// Use !== 'production' to be permissive by default for local dev tools
if (process.env.NODE_ENV !== 'production') {
origins.push(`http://localhost:${VITE_DEV_PORT}`, `http://127.0.0.1:${VITE_DEV_PORT}`);
}

return origins;
}

/**
Expand Down Expand Up @@ -254,6 +266,14 @@ export class AuditLogger {
export function createSecurityHeadersMiddleware(port?: number) {
const actualPort = port || DEFAULT_DASHBOARD_PORT;

// Build connect-src directive with WebSocket endpoints
let connectSrc = `'self' ws://localhost:${actualPort} ws://127.0.0.1:${actualPort}`;

// In non-production environments, also allow Vite dev server connections
if (process.env.NODE_ENV !== 'production') {
connectSrc += ` ws://localhost:${VITE_DEV_PORT} ws://127.0.0.1:${VITE_DEV_PORT}`;
}

return async (request: FastifyRequest, reply: FastifyReply) => {
// Add security headers
reply.header('X-Content-Type-Options', 'nosniff'); // Prevent MIME type sniffing
Expand All @@ -266,7 +286,7 @@ export function createSecurityHeadersMiddleware(port?: number) {
// connect-src allows WebSocket connections to the dashboard on the actual port
reply.header(
'Content-Security-Policy',
`default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data:; connect-src 'self' ws://localhost:${actualPort} ws://127.0.0.1:${actualPort};`
`default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data:; connect-src ${connectSrc};`
);
};
}
Expand Down
26 changes: 26 additions & 0 deletions src/dashboard/approval-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,32 @@ export class ApprovalStorage extends EventEmitter {
await fs.writeFile(filePath, JSON.stringify(approval, null, 2), 'utf-8');
}

/**
* Revert an approval back to pending status, clearing response and timestamp
* Used for undo operations after batch approvals/rejections
*/
async revertToPending(id: string): Promise<void> {
const approval = await this.getApproval(id);
if (!approval) {
throw new Error(`Approval ${id} not found`);
}

// Revert to pending state
approval.status = 'pending';

// Clear response fields
delete approval.response;
delete approval.respondedAt;
delete approval.annotations;
delete approval.comments;

const filePath = await this.findApprovalPath(id);
if (!filePath) {
throw new Error(`Approval ${id} file not found`);
}
await fs.writeFile(filePath, JSON.stringify(approval, null, 2), 'utf-8');
}

async createRevision(
originalId: string,
newContent: string,
Expand Down
187 changes: 170 additions & 17 deletions src/dashboard/multi-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,37 +660,190 @@ export class MultiProjectDashboardServer {

// Approval actions (approve, reject, needs-revision)
this.app.post('/api/projects/:projectId/approvals/:id/:action', async (request, reply) => {
const { projectId, id, action } = request.params as { projectId: string; id: string; action: string };
const { response, annotations, comments } = request.body as {
response: string;
annotations?: string;
comments?: any[];
try {
const { projectId, id, action } = request.params as { projectId: string; id: string; action: string };

const { response, annotations, comments } = (request.body || {}) as {
response: string;
annotations?: string;
comments?: any[];
};

const project = this.projectManager.getProject(projectId);
if (!project) {
return reply.code(404).send({ error: 'Project not found' });
}

const validActions = ['approve', 'reject', 'needs-revision'];
if (!validActions.includes(action)) {
return reply.code(400).send({ error: 'Invalid action' });
}

// Convert action name to status value
const actionToStatus: Record<string, 'approved' | 'rejected' | 'needs-revision'> = {
'approve': 'approved',
'reject': 'rejected',
'needs-revision': 'needs-revision'
};
const status = actionToStatus[action];

await project.approvalStorage.updateApproval(id, status, response, annotations, comments);
return { success: true };
} catch (error: any) {
return reply.code(500).send({ error: error.message || 'Internal server error' });
}
});

// Undo batch operations - revert items back to pending
// IMPORTANT: This route MUST be defined BEFORE the /batch/:action route
// because Fastify matches routes in order of registration
this.app.post('/api/projects/:projectId/approvals/batch/undo', async (request, reply) => {
const { projectId } = request.params as { projectId: string };
const { ids } = request.body as { ids: string[] };

const project = this.projectManager.getProject(projectId);
if (!project) {
return reply.code(404).send({ error: 'Project not found' });
}

// Validate ids array
if (!Array.isArray(ids) || ids.length === 0) {
return reply.code(400).send({ error: 'ids must be a non-empty array' });
}

// Batch size limit
const BATCH_SIZE_LIMIT = 100;
if (ids.length > BATCH_SIZE_LIMIT) {
return reply.code(400).send({
error: `Batch size exceeds limit. Maximum ${BATCH_SIZE_LIMIT} items allowed.`
});
}

// Validate ID format
const idPattern = /^[a-zA-Z0-9_-]+$/;
const invalidIds = ids.filter(id => !idPattern.test(id));
if (invalidIds.length > 0) {
return reply.code(400).send({
error: `Invalid ID format: ${invalidIds.slice(0, 3).join(', ')}${invalidIds.length > 3 ? '...' : ''}`
});
}

// Process all undo operations with continue-on-error
const results: { succeeded: string[]; failed: Array<{ id: string; error: string }> } = {
succeeded: [],
failed: []
};

for (const id of ids) {
try {
// Revert to pending status, clear response and respondedAt
await project.approvalStorage.revertToPending(id);
results.succeeded.push(id);
} catch (error: any) {
results.failed.push({ id, error: error.message });
}
}

// Broadcast WebSocket update for successful undos
if (results.succeeded.length > 0) {
this.broadcastToProject(projectId, {
type: 'batch-approval-undo',
ids: results.succeeded,
count: results.succeeded.length
});
}

return {
success: results.failed.length === 0,
total: ids.length,
succeeded: results.succeeded,
failed: results.failed
};
});

// Batch approval actions (approve, reject only - no batch needs-revision)
this.app.post('/api/projects/:projectId/approvals/batch/:action', async (request, reply) => {
const { projectId, action } = request.params as { projectId: string; action: string };
const { ids, response } = request.body as {
ids: string[];
response?: string;
};

const project = this.projectManager.getProject(projectId);
if (!project) {
return reply.code(404).send({ error: 'Project not found' });
}

const validActions = ['approve', 'reject', 'needs-revision'];
if (!validActions.includes(action)) {
return reply.code(400).send({ error: 'Invalid action' });
// Only allow approve and reject for batch operations (UX recommendation)
const validBatchActions = ['approve', 'reject'];
if (!validBatchActions.includes(action)) {
return reply.code(400).send({
error: 'Invalid batch action. Only "approve" and "reject" are allowed for batch operations.'
});
}

// Validate ids array
if (!Array.isArray(ids) || ids.length === 0) {
return reply.code(400).send({ error: 'ids must be a non-empty array' });
}

// Batch size limit (PE recommendation)
const BATCH_SIZE_LIMIT = 100;
if (ids.length > BATCH_SIZE_LIMIT) {
return reply.code(400).send({
error: `Batch size exceeds limit. Maximum ${BATCH_SIZE_LIMIT} items allowed.`
});
}

// Validate ID format (PE recommendation - alphanumeric with hyphens/underscores)
const idPattern = /^[a-zA-Z0-9_-]+$/;
const invalidIds = ids.filter(id => !idPattern.test(id));
if (invalidIds.length > 0) {
return reply.code(400).send({
error: `Invalid ID format: ${invalidIds.slice(0, 3).join(', ')}${invalidIds.length > 3 ? '...' : ''}`
});
}

// Convert action name to status value
const actionToStatus: Record<string, 'approved' | 'rejected' | 'needs-revision'> = {
const actionToStatus: Record<string, 'approved' | 'rejected'> = {
'approve': 'approved',
'reject': 'rejected',
'needs-revision': 'needs-revision'
'reject': 'rejected'
};
const status = actionToStatus[action];
const batchResponse = response || `Batch ${action}d`;

try {
await project.approvalStorage.updateApproval(id, status, response, annotations, comments);
return { success: true };
} catch (error: any) {
return reply.code(404).send({ error: error.message });
// Process all approvals with continue-on-error (PE recommendation)
const results: { succeeded: string[]; failed: Array<{ id: string; error: string }> } = {
succeeded: [],
failed: []
};

// Debounce WebSocket broadcasts - collect all updates first (use results.succeeded)

for (const id of ids) {
try {
await project.approvalStorage.updateApproval(id, status, batchResponse);
results.succeeded.push(id);
} catch (error: any) {
results.failed.push({ id, error: error.message });
}
}

// Single consolidated WebSocket broadcast for all successful updates
if (results.succeeded.length > 0) {
this.broadcastToProject(projectId, {
type: 'batch-approval-update',
action: action,
ids: results.succeeded,
count: results.succeeded.length
});
}

return {
success: results.failed.length === 0,
total: ids.length,
succeeded: results.succeeded,
failed: results.failed
};
});

// Get all snapshots for an approval
Expand Down
Loading