Harden request origin checks and configure allowed hosts
This commit is contained in:
32
lib/api.ts
32
lib/api.ts
@@ -5,6 +5,7 @@ import { z } from "zod";
|
||||
import { admin, required, user } from "./supabase";
|
||||
import { datasetSchema, faces, difficulties } from "../shared/protocol";
|
||||
import { mediaUrl } from "./catalog";
|
||||
import { requestOrigin, verifyOrigin } from "./request-origin";
|
||||
const hash = (value: string) =>
|
||||
createHash("sha256").update(value).digest("hex");
|
||||
const idSchema = z.string().uuid();
|
||||
@@ -91,21 +92,6 @@ async function jsonBody(request: NextRequest) {
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
}
|
||||
function verifyOrigin(request: NextRequest) {
|
||||
if (request.method === "GET" || request.method === "HEAD") return;
|
||||
const origin = request.headers.get("origin");
|
||||
const allowed = new Set([
|
||||
new URL(request.url).origin,
|
||||
process.env.MINEGEO_URL || "http://localhost:3000",
|
||||
]);
|
||||
if (origin && !allowed.has(origin)) throw new Error("Invalid request origin");
|
||||
if (
|
||||
!origin &&
|
||||
!request.headers.get("authorization") &&
|
||||
!request.headers.get("content-type")?.includes("application/json")
|
||||
)
|
||||
throw new Error("JSON request required");
|
||||
}
|
||||
export async function handle(request: NextRequest, path: string[]) {
|
||||
try {
|
||||
verifyOrigin(request);
|
||||
@@ -537,7 +523,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
return NextResponse.json({
|
||||
deviceCode,
|
||||
userCode: code,
|
||||
verificationUri: `${process.env.MINEGEO_URL || new URL(request.url).origin}/pair?code=${code}`,
|
||||
verificationUri: `${process.env.MINEGEO_URL ? new URL(process.env.MINEGEO_URL).origin : requestOrigin(request)}/pair?code=${code}`,
|
||||
interval: 5,
|
||||
});
|
||||
}
|
||||
@@ -622,13 +608,13 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
.parse(await jsonBody(request));
|
||||
if (b.decision === "reject" && !b.reason?.trim())
|
||||
throw new Error("Provide a rejection reason");
|
||||
const {error}=await client.rpc("publish_dataset", {
|
||||
p_dataset: b.datasetId,
|
||||
p_reviewer: reviewer,
|
||||
p_decision: b.decision,
|
||||
p_reason: b.reason ?? null,
|
||||
});
|
||||
if(error)throw new Error(error.message);
|
||||
const { error } = await client.rpc("publish_dataset", {
|
||||
p_dataset: b.datasetId,
|
||||
p_reviewer: reviewer,
|
||||
p_decision: b.decision,
|
||||
p_reason: b.reason ?? null,
|
||||
});
|
||||
if (error) throw new Error(error.message);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
}
|
||||
|
||||
79
lib/request-origin.ts
Normal file
79
lib/request-origin.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
type OriginRequest = Pick<Request, "url" | "headers" | "method">;
|
||||
type OriginConfiguration = {
|
||||
websiteUrl?: string;
|
||||
additionalOrigins?: string;
|
||||
allowAnyOrigin?: boolean;
|
||||
};
|
||||
|
||||
function parseOrigin(value: string): URL {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error("Invalid request origin");
|
||||
}
|
||||
if (
|
||||
!["http:", "https:"].includes(parsed.protocol) ||
|
||||
parsed.username ||
|
||||
parsed.password
|
||||
) {
|
||||
throw new Error("Invalid request origin");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const originOf = (value: string) => parseOrigin(value).origin;
|
||||
|
||||
/** Next may build request.url using the listening address rather than the LAN host. */
|
||||
export function requestOrigin(
|
||||
request: Pick<Request, "url" | "headers">,
|
||||
websiteUrl?: string,
|
||||
): string {
|
||||
const url = new URL(request.url);
|
||||
const host = request.headers.get("host");
|
||||
if (!host) return originOf(url.origin);
|
||||
// Use the direct request's authority, including its port. Forwarded hosts are
|
||||
// intentionally excluded; proxy deployments can configure their public URL.
|
||||
if (/[\s/\\?#@,]/.test(host)) throw new Error("Invalid request origin");
|
||||
const direct = parseOrigin(`${url.protocol}//${host}`);
|
||||
// Preserve HTTPS when a configured public host terminates TLS at a proxy.
|
||||
// A different configured host must not redirect a LAN login to localhost.
|
||||
if (websiteUrl) {
|
||||
const configured = parseOrigin(websiteUrl);
|
||||
if (configured.host === direct.host) return configured.origin;
|
||||
}
|
||||
return direct.origin;
|
||||
}
|
||||
|
||||
export function verifyOrigin(
|
||||
request: OriginRequest,
|
||||
configuration: OriginConfiguration = {
|
||||
websiteUrl: process.env.MINEGEO_URL,
|
||||
additionalOrigins: process.env.MINEGEO_ALLOWED_ORIGINS,
|
||||
allowAnyOrigin: process.env.MINEGEO_ALLOW_ANY_ORIGIN === "true",
|
||||
},
|
||||
) {
|
||||
if (request.method === "GET" || request.method === "HEAD") return;
|
||||
const origin = request.headers.get("origin");
|
||||
if (origin !== null) {
|
||||
if (configuration.allowAnyOrigin) return;
|
||||
const parsed = parseOrigin(origin);
|
||||
if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
|
||||
throw new Error("Invalid request origin");
|
||||
}
|
||||
const allowed = new Set([requestOrigin(request)]);
|
||||
for (const value of [
|
||||
configuration.websiteUrl,
|
||||
...(configuration.additionalOrigins?.split(",") ?? []),
|
||||
]) {
|
||||
if (value?.trim()) allowed.add(originOf(value.trim()));
|
||||
}
|
||||
if (!allowed.has(originOf(origin)))
|
||||
throw new Error("Invalid request origin");
|
||||
} else if (
|
||||
!request.headers.get("authorization") &&
|
||||
!request.headers.get("content-type")?.includes("application/json")
|
||||
) {
|
||||
throw new Error("JSON request required");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user