import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/lib/auth"; import { getCustomerByKeycloakId } from "@/lib/erpnext"; const ERPNEXT_URL = process.env.ERPNEXT_API_URL || "http://localhost:8080"; const API_KEY = process.env.ERPNEXT_API_KEY || ""; const API_SECRET = process.env.ERPNEXT_API_SECRET || ""; /** * Server-side proxy to ERPNext API. * All client-side dashboard calls go through /api/erp/... instead of hitting ERPNext directly. * This avoids CORS issues and keeps API credentials server-side. * * SECURITY: requires an authenticated session AND restricts which ERPNext * doctypes/resources can be reached — the proxy carries admin credentials, so * it must never be an open relay. Reads are limited to CMS/catalog data; * writes are limited to lead capture. Everything else (Customer, Sales Invoice, * Payment Log, ...) is handled by dedicated, ownership-checked routes. */ // Resources the authenticated dashboard legitimately reads (see lib/api.ts). // NOTE: filtering by customer is still done client-side — a hardened version // should inject the session's own customer server-side to prevent IDOR. This // allowlist at least stops the proxy from reaching unrelated doctypes // (User, Role, API keys, ...) and requires a valid session. const READ_ALLOW = [ "resource/Website Content", "resource/Item", "resource/Subscription Plan", "resource/Subscription", "resource/Sales Invoice", "resource/Customer", "method/frappe.client.get_list", "method/frappe.client.get_value", ]; // Write endpoints allowed through the generic proxy. const WRITE_ALLOW = ["resource/Lead", "resource/Customer"]; function isAllowed(erpPath: string, allow: string[]): boolean { const decoded = decodeURIComponent(erpPath).replace(/^\/api\//, ""); return allow.some((a) => decoded === a || decoded.startsWith(a + "/") || decoded.startsWith(a + "?")); } // Doctypes whose rows belong to a specific customer. Any request touching one // of these must reference ONLY the caller's own customer — otherwise it's an // IDOR (reading/altering another user's data). Website Content / Item / // Subscription Plan are shared catalog data and are not scoped. const CUSTOMER_SCOPED = ["Customer", "Sales Invoice", "Subscription"]; /** * Enforce that a request to a customer-scoped resource only references the * caller's own ERPNext customer. Returns null if OK, or an error response. */ function enforceOwnership( erpPath: string, search: string, ownCustomer: string | null ): NextResponse | null { const decoded = decodeURIComponent(erpPath).replace(/^\/api\//, ""); const scoped = CUSTOMER_SCOPED.find( (dt) => decoded === `resource/${dt}` || decoded.startsWith(`resource/${dt}/`) || decoded.startsWith(`resource/${dt}?`) ); if (!scoped) return null; // shared/catalog resource — no scoping needed if (!ownCustomer) { return NextResponse.json({ error: "No customer profile for this account" }, { status: 403 }); } // Direct access by name: /resource/Customer/ or /resource/Sales Invoice/ const nameMatch = decoded.match(new RegExp(`^resource/${scoped}/(.+)$`)); if (nameMatch) { const name = decodeURIComponent(nameMatch[1]); // Sales Invoice / Subscription names aren't the customer — allow by name only // for Customer (which IS the customer). Invoice/Subscription by-name reads // are covered by the filter check below; deny bare Customer/. if (scoped === "Customer" && name !== ownCustomer) { return NextResponse.json({ error: "Forbidden: not your resource" }, { status: 403 }); } return null; } // Filtered list: the customer/party filter value must be the caller's own. const q = decodeURIComponent(search); const referenced = [...q.matchAll(/"(?:customer|party)"\s*,\s*"="\s*,\s*"([^"]+)"/g)].map((m) => m[1]); if (referenced.some((c) => c !== ownCustomer)) { return NextResponse.json({ error: "Forbidden: not your resource" }, { status: 403 }); } // A list with no customer filter on a scoped doctype would leak all rows — deny. if (referenced.length === 0) { return NextResponse.json({ error: "Forbidden: customer filter required" }, { status: 403 }); } return null; } async function proxy(request: NextRequest, path: string[], method: "GET" | "POST" | "PUT") { const session = await auth(); if (!session?.user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const erpPath = `/api/${path.join("/")}`; const allow = method === "GET" ? READ_ALLOW : WRITE_ALLOW; if (!isAllowed(erpPath, allow)) { return NextResponse.json( { error: "Forbidden: resource not permitted via generic proxy" }, { status: 403 } ); } const search = request.nextUrl.searchParams.toString(); // Prevent IDOR: scope customer-owned resources to the caller's own customer. const ownCustomer = session.user.id ? await getCustomerByKeycloakId(session.user.id) : null; const ownershipError = enforceOwnership(erpPath, search, ownCustomer); if (ownershipError) return ownershipError; const url = `${ERPNEXT_URL}${erpPath}${method === "GET" && search ? `?${search}` : ""}`; try { const res = await fetch(url, { method, headers: { Authorization: `token ${API_KEY}:${API_SECRET}`, ...(method !== "GET" ? { "Content-Type": "application/json" } : {}), }, ...(method !== "GET" ? { body: await request.text() } : {}), }); const data = await res.json(); return NextResponse.json(data, { status: res.status }); } catch { return NextResponse.json({ error: "ERPNext API unavailable" }, { status: 502 }); } } export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { return proxy(request, (await params).path, "GET"); } export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { return proxy(request, (await params).path, "POST"); } export async function PUT(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { return proxy(request, (await params).path, "PUT"); }