diff --git a/api/command-source/[id].js b/api/command-source/[id].js index 5ed6de8c3..848afa85d 100644 --- a/api/command-source/[id].js +++ b/api/command-source/[id].js @@ -6,9 +6,16 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const PROJECT_ROOT = join(__dirname, "../.."); +const VALID_ID = /^[a-zA-Z0-9_-]+$/; + export default function handler(req, res) { try { const { id } = req.query; + + if (!id || !VALID_ID.test(id)) { + return res.status(400).json({ error: "Invalid command ID" }); + } + const commandPath = join(PROJECT_ROOT, "source", "skills", id, "SKILL.md"); if (!existsSync(commandPath)) { @@ -19,6 +26,6 @@ export default function handler(req, res) { res.status(200).json({ content }); } catch (error) { console.error("Error in /api/command-source:", error); - res.status(500).json({ error: error.message }); + res.status(500).json({ error: "Internal server error" }); } } diff --git a/api/commands.js b/api/commands.js index 24e429c2b..5c2107be3 100644 --- a/api/commands.js +++ b/api/commands.js @@ -42,7 +42,7 @@ export default function handler(req, res) { res.status(200).json(commands); } catch (error) { console.error("Error in /api/commands:", error); - res.status(500).json({ error: error.message, stack: error.stack }); + res.status(500).json({ error: "Internal server error" }); } } diff --git a/api/download/[type]/[provider]/[id].js b/api/download/[type]/[provider]/[id].js index 3d7a37813..d9c0989cf 100644 --- a/api/download/[type]/[provider]/[id].js +++ b/api/download/[type]/[provider]/[id].js @@ -33,6 +33,9 @@ function getFilePath(type, provider, id) { return null; } +const VALID_ID = /^[a-zA-Z0-9_-]+$/; +const ALLOWED_PROVIDERS = ['cursor', 'claude-code', 'gemini', 'codex', 'agents', 'universal']; + export default function handler(req, res) { try { const { type, provider, id } = req.query; @@ -41,6 +44,14 @@ export default function handler(req, res) { return res.status(400).json({ error: "Invalid type" }); } + if (!provider || !ALLOWED_PROVIDERS.includes(provider)) { + return res.status(400).json({ error: "Invalid provider" }); + } + + if (!id || !VALID_ID.test(id)) { + return res.status(400).json({ error: "Invalid file ID" }); + } + const filePath = getFilePath(type, provider, id); if (!filePath) { @@ -52,13 +63,13 @@ export default function handler(req, res) { } const content = readFileSync(filePath); - const fileName = basename(filePath); + const fileName = basename(filePath).replace(/[^a-zA-Z0-9._-]/g, ''); res.setHeader("Content-Type", "application/octet-stream"); res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`); res.send(content); } catch (error) { console.error("Error downloading file:", error); - res.status(500).json({ error: error.message }); + res.status(500).json({ error: "Internal server error" }); } } diff --git a/api/download/bundle/[provider].js b/api/download/bundle/[provider].js index 834f30e0e..c79a00d75 100644 --- a/api/download/bundle/[provider].js +++ b/api/download/bundle/[provider].js @@ -6,9 +6,16 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const PROJECT_ROOT = join(__dirname, "../../.."); +const ALLOWED_PROVIDERS = ['cursor', 'claude-code', 'gemini', 'codex', 'agents', 'universal']; + export default function handler(req, res) { try { const { provider } = req.query; + + if (!provider || !ALLOWED_PROVIDERS.includes(provider)) { + return res.status(400).json({ error: "Invalid provider" }); + } + const distDir = join(PROJECT_ROOT, "dist"); const zipPath = join(distDir, `${provider}.zip`); @@ -18,11 +25,12 @@ export default function handler(req, res) { const content = readFileSync(zipPath); res.setHeader("Content-Type", "application/zip"); - res.setHeader("Content-Disposition", `attachment; filename="impeccable-style-${provider}.zip"`); + const safeProvider = provider.replace(/[^a-zA-Z0-9._-]/g, ''); + res.setHeader("Content-Disposition", `attachment; filename="impeccable-style-${safeProvider}.zip"`); res.send(content); } catch (error) { console.error("Error downloading bundle:", error); - res.status(500).json({ error: error.message }); + res.status(500).json({ error: "Internal server error" }); } } diff --git a/api/patterns.js b/api/patterns.js index 23fe4ae51..52eb0956e 100644 --- a/api/patterns.js +++ b/api/patterns.js @@ -13,6 +13,6 @@ export default function handler(req, res) { res.status(200).json({ patterns, antipatterns }); } catch (error) { console.error("Error in /api/patterns:", error); - res.status(500).json({ error: error.message, stack: error.stack }); + res.status(500).json({ error: "Internal server error" }); } } diff --git a/api/skills.js b/api/skills.js index 788f9effb..e91ed2541 100644 --- a/api/skills.js +++ b/api/skills.js @@ -39,6 +39,6 @@ export default function handler(req, res) { res.status(200).json(skills); } catch (error) { console.error("Error in /api/skills:", error); - res.status(500).json({ error: error.message }); + res.status(500).json({ error: "Internal server error" }); } } diff --git a/server/index.js b/server/index.js index e6d5bf296..bf64f91bd 100644 --- a/server/index.js +++ b/server/index.js @@ -20,42 +20,48 @@ const server = serve({ // Static assets - all public subdirectories "/assets/*": async (req) => { const url = new URL(req.url); + if (url.pathname.includes('..')) return new Response("Bad Request", { status: 400 }); const filePath = `./public${url.pathname}`; const assetFile = file(filePath); if (await assetFile.exists()) { - return new Response(assetFile); + return new Response(assetFile, { + headers: { "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY" } + }); } return new Response("Not Found", { status: 404 }); }, "/css/*": async (req) => { const url = new URL(req.url); + if (url.pathname.includes('..')) return new Response("Bad Request", { status: 400 }); const filePath = `./public${url.pathname}`; const assetFile = file(filePath); if (await assetFile.exists()) { return new Response(assetFile, { - headers: { "Content-Type": "text/css" } + headers: { "Content-Type": "text/css", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY" } }); } return new Response("Not Found", { status: 404 }); }, "/js/*": async (req) => { const url = new URL(req.url); + if (url.pathname.includes('..')) return new Response("Bad Request", { status: 400 }); const filePath = `./public${url.pathname}`; const assetFile = file(filePath); if (await assetFile.exists()) { return new Response(assetFile, { - headers: { "Content-Type": "application/javascript" } + headers: { "Content-Type": "application/javascript", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY" } }); } return new Response("Not Found", { status: 404 }); }, "/antipattern-examples/*": async (req) => { const url = new URL(req.url); + if (url.pathname.includes('..')) return new Response("Bad Request", { status: 400 }); const filePath = `./public${url.pathname}`; const assetFile = file(filePath); if (await assetFile.exists()) { return new Response(assetFile, { - headers: { "Content-Type": "text/html" } + headers: { "Content-Type": "text/html", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY" } }); } return new Response("Not Found", { status: 404 }); @@ -88,11 +94,14 @@ const server = serve({ // API: Get command source content "/api/command-source/:id": async (req) => { const { id } = req.params; - const content = await getCommandSource(id); - if (!content) { + const result = await getCommandSource(id); + if (result && result.error) { + return Response.json({ error: result.error }, { status: result.status }); + } + if (!result) { return Response.json({ error: "Command not found" }, { status: 404 }); } - return Response.json({ content }); + return Response.json({ content: result }); }, // API: Download individual file @@ -111,6 +120,9 @@ const server = serve({ // Serve root-level static files (og-image.png, favicon, robots.txt, etc.) fetch(req) { const url = new URL(req.url); + if (url.pathname.includes('..')) { + return new Response("Bad Request", { status: 400 }); + } const filePath = `./public${url.pathname}`; const staticFile = file(filePath); if (staticFile.size > 0) { diff --git a/server/lib/api-handlers.js b/server/lib/api-handlers.js index a52b45b1f..4c5596142 100644 --- a/server/lib/api-handlers.js +++ b/server/lib/api-handlers.js @@ -3,6 +3,7 @@ import { basename, join, dirname } from "path"; import { existsSync } from "fs"; import { fileURLToPath } from "url"; import { readPatterns, parseFrontmatter } from "../../scripts/lib/utils.js"; +import { isValidId, isAllowedProvider, isAllowedType, sanitizeFilename } from "./validation.js"; // Get project root directory (works in both Node.js and Bun, including Vercel) const __filename = fileURLToPath(import.meta.url); @@ -47,6 +48,10 @@ export async function getCommands() { // Get command/skill source content export async function getCommandSource(id) { + if (!isValidId(id)) { + return { error: "Invalid command ID", status: 400 }; + } + const skillPath = join(PROJECT_ROOT, "source", "skills", id, "SKILL.md"); try { @@ -87,10 +92,18 @@ export function getFilePath(type, provider, id) { // Handle individual file download export async function handleFileDownload(type, provider, id) { - if (type !== "skill" && type !== "command") { + if (!isAllowedType(type)) { return new Response("Invalid type", { status: 400 }); } + if (!isAllowedProvider(provider)) { + return new Response("Invalid provider", { status: 400 }); + } + + if (!isValidId(id)) { + return new Response("Invalid file ID", { status: 400 }); + } + const filePath = getFilePath(type, provider, id); if (!filePath) { @@ -103,7 +116,7 @@ export async function handleFileDownload(type, provider, id) { } const content = await readFile(filePath); - const fileName = basename(filePath); + const fileName = sanitizeFilename(basename(filePath)); return new Response(content, { headers: { "Content-Type": "application/octet-stream", @@ -128,6 +141,10 @@ export async function getPatterns() { // Handle bundle download export async function handleBundleDownload(provider) { + if (!isAllowedProvider(provider)) { + return new Response("Invalid provider", { status: 400 }); + } + const distDir = join(PROJECT_ROOT, "dist"); const zipPath = join(distDir, `${provider}.zip`); @@ -137,10 +154,11 @@ export async function handleBundleDownload(provider) { } const content = await readFile(zipPath); + const safeProvider = sanitizeFilename(provider); return new Response(content, { headers: { "Content-Type": "application/zip", - "Content-Disposition": `attachment; filename="impeccable-style-${provider}.zip"`, + "Content-Disposition": `attachment; filename="impeccable-style-${safeProvider}.zip"`, }, }); } catch (error) { diff --git a/server/lib/validation.js b/server/lib/validation.js new file mode 100644 index 000000000..603360c12 --- /dev/null +++ b/server/lib/validation.js @@ -0,0 +1,24 @@ +// Shared validation helpers for input sanitization + +// Only allow alphanumeric, hyphens, and underscores in IDs +export const VALID_ID = /^[a-zA-Z0-9_-]+$/; + +export const ALLOWED_PROVIDERS = ['cursor', 'claude-code', 'gemini', 'codex', 'agents', 'universal']; +export const ALLOWED_TYPES = ['skill', 'command']; + +export function isValidId(id) { + return typeof id === 'string' && VALID_ID.test(id); +} + +export function isAllowedProvider(provider) { + return ALLOWED_PROVIDERS.includes(provider); +} + +export function isAllowedType(type) { + return ALLOWED_TYPES.includes(type); +} + +// Sanitize a filename for use in Content-Disposition headers +export function sanitizeFilename(filename) { + return filename.replace(/[^a-zA-Z0-9._-]/g, ''); +}