From b8d31b0376bc9ebc4ba105d97fabdbb619b76346 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Wed, 18 Feb 2026 20:43:32 +1300 Subject: [PATCH] fix: add security guards and type consistency for PR #71 - Add localhost-only guard to /api/open-file (rejects non-localhost with 403) - Restrict open-file paths to user's home directory - Change savedFilename/savedFilePath from optional to required-but-nullable - Remove zip from KNOWN_3D_EXTENSIONS (not a valid 3D format) - Export getExtensionFromUrl for testability Co-Authored-By: Claude Opus 4.6 --- src/app/api/open-file/route.ts | 35 ++++++++++++++++++++++++++++ src/app/api/save-generation/route.ts | 4 ++-- src/types/nodes.ts | 4 ++-- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/app/api/open-file/route.ts b/src/app/api/open-file/route.ts index 06d3d9a6..597a6c7e 100644 --- a/src/app/api/open-file/route.ts +++ b/src/app/api/open-file/route.ts @@ -7,7 +7,33 @@ import os from "os"; const execFileAsync = promisify(execFile); +function isLocalhostRequest(req: NextRequest): boolean { + const forwarded = req.headers.get("x-forwarded-for"); + if (forwarded) { + const firstIp = forwarded.split(",")[0].trim(); + if (firstIp !== "127.0.0.1" && firstIp !== "::1" && firstIp !== "::ffff:127.0.0.1") { + return false; + } + } + + const host = req.headers.get("host") || ""; + const hostname = host.split(":")[0]; + if (hostname && hostname !== "localhost" && hostname !== "127.0.0.1" && hostname !== "::1") { + return false; + } + + return true; +} + export async function POST(req: NextRequest) { + // Only allow requests from localhost + if (!isLocalhostRequest(req)) { + return NextResponse.json( + { success: false, error: "Forbidden: localhost only" }, + { status: 403 } + ); + } + try { const body = await req.json(); const { filePath: inputPath } = body; @@ -22,6 +48,15 @@ export async function POST(req: NextRequest) { // Normalize and resolve the path to prevent traversal attacks const normalizedPath = path.resolve(inputPath); + // Restrict to user's home directory + const homeDir = os.homedir(); + if (!normalizedPath.startsWith(homeDir + path.sep) && normalizedPath !== homeDir) { + return NextResponse.json( + { success: false, error: "Path is outside allowed directory" }, + { status: 403 } + ); + } + // Validate that the path exists and is a file try { const stats = await stat(normalizedPath); diff --git a/src/app/api/save-generation/route.ts b/src/app/api/save-generation/route.ts index 52460966..317f5783 100644 --- a/src/app/api/save-generation/route.ts +++ b/src/app/api/save-generation/route.ts @@ -49,11 +49,11 @@ function isHttpUrl(str: string): boolean { } // Known file extensions for 3D models and common media -const KNOWN_3D_EXTENSIONS = new Set(["glb", "gltf", "obj", "fbx", "usdz", "stl", "ply", "zip"]); +const KNOWN_3D_EXTENSIONS = new Set(["glb", "gltf", "obj", "fbx", "usdz", "stl", "ply"]); const KNOWN_MEDIA_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "mp4", "webm", "mov"]); // Helper to extract a recognized file extension from a URL pathname -function getExtensionFromUrl(url: string): string | null { +export function getExtensionFromUrl(url: string): string | null { try { const urlObj = new URL(url); const pathname = urlObj.pathname; diff --git a/src/types/nodes.ts b/src/types/nodes.ts index ab92a652..61b3c60b 100644 --- a/src/types/nodes.ts +++ b/src/types/nodes.ts @@ -183,8 +183,8 @@ export interface Generate3DNodeData extends BaseNodeData { inputImageRefs?: string[]; inputPrompt: string | null; output3dUrl: string | null; - savedFilename?: string | null; - savedFilePath?: string | null; + savedFilename: string | null; + savedFilePath: string | null; selectedModel?: SelectedModel; parameters?: Record; inputSchema?: ModelInputDef[];