@@ -10,8 +10,24 @@ import { clientIp, userAgent } from "@/lib/request";
|
||||
const Body = z.object({
|
||||
operatorId: z.string().min(1),
|
||||
pin: z.string().regex(/^\d{4}$/, "PIN must be 4 digits"),
|
||||
next: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Accept only same-origin redirects into the operator area. A scanned QR card
|
||||
* routes the operator to /op/scan/<token>; if they aren't signed in we bounce
|
||||
* them to the login page with ?next=<path>, log them in, then land them back
|
||||
* on the scan page. Anything that doesn't look like a plain /op/... path is
|
||||
* dropped on the floor to avoid open-redirect abuse.
|
||||
*/
|
||||
function safeNext(raw: string | undefined): string {
|
||||
if (!raw) return "/op";
|
||||
if (!raw.startsWith("/op")) return "/op";
|
||||
// Reject protocol-relative ("//evil.com") or backslash-smuggled inputs.
|
||||
if (raw.startsWith("//") || raw.includes("\\")) return "/op";
|
||||
return raw;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const parsed = Body.safeParse(await req.json().catch(() => ({})));
|
||||
if (!parsed.success) {
|
||||
@@ -19,6 +35,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const { operatorId, pin } = parsed.data;
|
||||
const redirect = safeNext(parsed.data.next);
|
||||
if (!isValidPin(pin)) {
|
||||
return NextResponse.json({ error: "Invalid PIN" }, { status: 400 });
|
||||
}
|
||||
@@ -76,5 +93,5 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
await audit({ actorId: user.id, action: "login", entity: "User", entityId: user.id, ipAddress: ip });
|
||||
|
||||
return NextResponse.json({ ok: true, redirect: "/op" });
|
||||
return NextResponse.json({ ok: true, redirect });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user