Harden request origin checks and configure allowed hosts
This commit is contained in:
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