diff --git a/src/commands/browser.ts b/src/commands/browser.ts index afeadea..a41814e 100644 --- a/src/commands/browser.ts +++ b/src/commands/browser.ts @@ -101,6 +101,9 @@ export const browserCommand = new Command("browser") .description("Open web UI for task management") .option("-p, --port ", "Port number", String(DEFAULT_PORT)) .option("--no-open", "Don't open browser automatically") + .option("--cors-origin ", "Custom CORS origin(s) for development (comma-separated)") + .option("--cors-credentials", "Enable CORS credentials (default: true)", true) + .option("--strict-cors", "Use strict CORS (localhost only, no dev origins)") .action(async (options) => { const projectRoot = findProjectRoot(); if (!projectRoot) { @@ -143,7 +146,26 @@ export const browserCommand = new Command("browser") // Always save port to config so notify-server stays in sync await saveServerPort(projectRoot, port); + // Configure CORS options + let corsOrigin: string | string[] | undefined; + + if (options.strictCors) { + // Strict mode: localhost only + corsOrigin = "http://localhost:3000"; + } else if (options.corsOrigin) { + // Custom origins from CLI + corsOrigin = options.corsOrigin.split(",").map((o: string) => o.trim()); + } + // else: use default dev origins (localhost:3000, localhost:5173, etc.) + console.log(chalk.cyan("◆ Starting Knowns.dev Web UI...")); + if (options.corsOrigin) { + console.log(chalk.gray(` CORS origins: ${options.corsOrigin}`)); + } else if (options.strictCors) { + console.log(chalk.gray(" CORS: Strict mode (localhost:3000 only)")); + } else { + console.log(chalk.gray(" CORS: Development mode (localhost + common dev ports)")); + } console.log(""); try { @@ -151,6 +173,8 @@ export const browserCommand = new Command("browser") port, projectRoot, open: options.open, + corsOrigin, + corsCredentials: options.corsCredentials, }); console.log(""); diff --git a/src/import/providers/git.ts b/src/import/providers/git.ts index 54985fb..745d840 100644 --- a/src/import/providers/git.ts +++ b/src/import/providers/git.ts @@ -1,7 +1,8 @@ /** - * Git Import Provider + * Git Import Provider - SECURED VERSION * * Imports from git repositories using sparse checkout when possible. + * Security fix: Replaced execSync with spawnSync to prevent command injection */ import { execSync, spawnSync } from "node:child_process"; @@ -148,37 +149,39 @@ export class GitProvider extends ImportProvider { /** * Sparse checkout - only fetch .knowns/ directory + * SECURITY FIX: Use spawnSync with args array instead of execSync to prevent command injection */ private async sparseCheckout(source: string, tempDir: string, ref: string): Promise { // Initialize repo with sparse checkout - execSync("git init", { cwd: tempDir, stdio: "pipe" }); - execSync(`git remote add origin "${source}"`, { cwd: tempDir, stdio: "pipe" }); + spawnSync("git", ["init"], { cwd: tempDir, stdio: "pipe" }); + spawnSync("git", ["remote", "add", "origin", source], { cwd: tempDir, stdio: "pipe" }); // Configure sparse checkout - execSync("git config core.sparseCheckout true", { cwd: tempDir, stdio: "pipe" }); - execSync("git sparse-checkout init --cone", { cwd: tempDir, stdio: "pipe" }); - execSync(`git sparse-checkout set ${KNOWNS_DIR}`, { cwd: tempDir, stdio: "pipe" }); + spawnSync("git", ["config", "core.sparseCheckout", "true"], { cwd: tempDir, stdio: "pipe" }); + spawnSync("git", ["sparse-checkout", "init", "--cone"], { cwd: tempDir, stdio: "pipe" }); + spawnSync("git", ["sparse-checkout", "set", KNOWNS_DIR], { cwd: tempDir, stdio: "pipe" }); // Fetch only what we need const fetchRef = ref === "HEAD" ? "HEAD" : ref; - execSync(`git fetch --depth=1 origin ${fetchRef}`, { cwd: tempDir, stdio: "pipe" }); + spawnSync("git", ["fetch", "--depth=1", "origin", fetchRef], { cwd: tempDir, stdio: "pipe" }); // Checkout if (ref === "HEAD") { - execSync("git checkout FETCH_HEAD", { cwd: tempDir, stdio: "pipe" }); + spawnSync("git", ["checkout", "FETCH_HEAD"], { cwd: tempDir, stdio: "pipe" }); } else { - execSync(`git checkout ${ref}`, { cwd: tempDir, stdio: "pipe" }); + spawnSync("git", ["checkout", ref], { cwd: tempDir, stdio: "pipe" }); } } /** * Full clone - fallback for older git versions + * SECURITY FIX: Use spawnSync with args array instead of execSync to prevent command injection */ private async fullClone(source: string, tempDir: string, ref: string): Promise { if (ref === "HEAD") { - execSync(`git clone --depth=1 "${source}" .`, { cwd: tempDir, stdio: "pipe" }); + spawnSync("git", ["clone", "--depth=1", source, "."], { cwd: tempDir, stdio: "pipe" }); } else { - execSync(`git clone --depth=1 --branch "${ref}" "${source}" .`, { cwd: tempDir, stdio: "pipe" }); + spawnSync("git", ["clone", "--depth=1", "--branch", ref, source, "."], { cwd: tempDir, stdio: "pipe" }); } } diff --git a/src/search/store.ts b/src/search/store.ts index a96c3e4..132014d 100644 --- a/src/search/store.ts +++ b/src/search/store.ts @@ -282,6 +282,7 @@ export class SearchStore { /** * Search for similar chunks using sqlite-vec native vector search + * SECURITY FIX: Use parameterized queries to prevent SQL injection */ search( queryEmbedding: number[], @@ -310,15 +311,18 @@ export class SearchStore { WHERE v.embedding MATCH ? AND k = ${k} `; + const params: unknown[] = [embeddingBlob]; + if (type !== "all") { - sql += ` AND c.type = '${type}'`; + sql += " AND c.type = ?"; + params.push(type); } sql += ` ORDER BY v.distance `; - const rows = this.db.prepare(sql).all(embeddingBlob) as Array<{ + const rows = this.db.prepare(sql).all(...params) as Array<{ vec_rowid: number; distance: number; id: string; @@ -405,14 +409,18 @@ export class SearchStore { /** * Get all chunks (for keyword search fallback) + * SECURITY FIX: Use parameterized queries to prevent SQL injection */ getAllChunks(type?: "doc" | "task"): Chunk[] { let sql = "SELECT * FROM chunks"; + const params: unknown[] = []; + if (type) { - sql += ` WHERE type = '${type}'`; + sql += " WHERE type = ?"; + params.push(type); } - const rows = this.db.prepare(sql).all() as Array<{ + const rows = this.db.prepare(sql).all(...params) as Array<{ id: string; type: string; content: string; @@ -434,13 +442,17 @@ export class SearchStore { /** * Count chunks + * SECURITY FIX: Use parameterized queries to prevent SQL injection */ count(type?: "doc" | "task"): number { let sql = "SELECT COUNT(*) as count FROM chunks"; + const params: unknown[] = []; + if (type) { - sql += ` WHERE type = '${type}'`; + sql += " WHERE type = ?"; + params.push(type); } - const result = this.db.prepare(sql).get() as { count: number }; + const result = this.db.prepare(sql).get(...params) as { count: number }; return result.count; } diff --git a/src/server/index.ts b/src/server/index.ts index 4d6ea4f..875a84e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -33,6 +33,8 @@ interface ServerOptions { port: number; projectRoot: string; open: boolean; + corsOrigin?: string | string[]; // Custom CORS origin(s) for development + corsCredentials?: boolean; // Enable credentials for CORS (default: true) } export async function startServer(options: ServerOptions) { @@ -88,7 +90,22 @@ export async function startServer(options: ServerOptions) { // Create Express app const app = express(); - app.use(cors()); + + // SECURITY FIX: Restrict CORS with flexible configuration for development + // Default: localhost only (secure) + // Dev mode: Allow custom origins via options + const corsOptions = { + origin: options.corsOrigin || [ + "http://localhost:3000", + "http://127.0.0.1:3000", + // Allow common Vite dev ports + "http://localhost:5173", + "http://127.0.0.1:5173", + ], + credentials: options.corsCredentials ?? true, + }; + + app.use(cors(corsOptions)); app.use(express.json()); // Serve static assets from Vite build (assets folder with hashed names)