147 lines
6 KiB
TypeScript
147 lines
6 KiB
TypeScript
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/<NAME> or /resource/Sales Invoice/<NAME>
|
|
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/<other>.
|
|
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");
|
|
}
|