diff --git a/src/app/api/community-workflows/[id]/route.ts b/src/app/api/community-workflows/[id]/route.ts index 3963a4e3..91f2901b 100644 --- a/src/app/api/community-workflows/[id]/route.ts +++ b/src/app/api/community-workflows/[id]/route.ts @@ -5,6 +5,9 @@ const COMMUNITY_WORKFLOWS_API_URL = process.env.COMMUNITY_WORKFLOWS_API_URL || "https://nodebananapro.com/api/public/community-workflows"; +// Allowed hostnames for presigned URL fetches (SSRF protection) +const ALLOWED_R2_HOSTS = [".r2.cloudflarestorage.com"]; + interface RouteParams { params: Promise<{ id: string }>; } @@ -13,7 +16,8 @@ interface RouteParams { * GET: Load a specific community workflow by ID from the remote API * * This proxies to the node-banana-pro hosted service which stores - * community workflows in R2 storage. + * community workflows in R2 storage. The API returns a presigned URL + * which we use to fetch the actual workflow data directly from R2. */ export async function GET(request: Request, { params }: RouteParams) { const controller = new AbortController(); @@ -22,22 +26,22 @@ export async function GET(request: Request, { params }: RouteParams) { try { const { id } = await params; - const response = await fetch( + // Step 1: Get the presigned URL from the API + const urlResponse = await fetch( `${COMMUNITY_WORKFLOWS_API_URL}/${encodeURIComponent(id)}`, { headers: { Accept: "application/json", }, signal: controller.signal, - // Cache for 10 minutes (individual workflows change less frequently) - next: { revalidate: 600 }, + // Short cache since presigned URLs expire + next: { revalidate: 60 }, } ); - clearTimeout(timeoutId); - - if (!response.ok) { - if (response.status === 404) { + if (!urlResponse.ok) { + clearTimeout(timeoutId); + if (urlResponse.status === 404) { return NextResponse.json( { success: false, @@ -48,22 +52,86 @@ export async function GET(request: Request, { params }: RouteParams) { } console.error( - "Error fetching community workflow:", - response.status, - response.statusText + "Error fetching community workflow URL:", + urlResponse.status, + urlResponse.statusText ); return NextResponse.json( { success: false, error: "Failed to load workflow", }, - { status: response.status } + { status: urlResponse.status } + ); + } + + const urlData = await urlResponse.json(); + + if (!urlData.success || !urlData.downloadUrl) { + clearTimeout(timeoutId); + return NextResponse.json( + { + success: false, + error: urlData.error || "Failed to get download URL", + }, + { status: 500 } + ); + } + + // Step 2: Validate presigned URL to prevent SSRF + let downloadUrl: URL; + try { + downloadUrl = new URL(urlData.downloadUrl); + } catch { + clearTimeout(timeoutId); + console.error("Invalid download URL received:", urlData.downloadUrl); + return NextResponse.json( + { success: false, error: "Invalid download URL" }, + { status: 500 } + ); + } + + const isAllowedHost = + downloadUrl.protocol === "https:" && + ALLOWED_R2_HOSTS.some((host) => downloadUrl.hostname.endsWith(host)); + + if (!isAllowedHost) { + clearTimeout(timeoutId); + console.error("Untrusted download URL hostname:", downloadUrl.hostname); + return NextResponse.json( + { success: false, error: "Untrusted download URL" }, + { status: 403 } + ); + } + + // Step 3: Fetch the actual workflow from the presigned R2 URL + const workflowResponse = await fetch(downloadUrl.href, { + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!workflowResponse.ok) { + console.error( + "Error fetching workflow from R2:", + workflowResponse.status, + workflowResponse.statusText + ); + return NextResponse.json( + { + success: false, + error: "Failed to download workflow", + }, + { status: 500 } ); } - const data = await response.json(); + const workflow = await workflowResponse.json(); - return NextResponse.json(data); + return NextResponse.json({ + success: true, + workflow, + }); } catch (error) { clearTimeout(timeoutId);