Harden request origin checks and configure allowed hosts

This commit is contained in:
2026-10-04 17:00:02 +05:00
parent 5289c17adf
commit 04cc4f39f6
6 changed files with 319 additions and 26 deletions

View File

@@ -1,5 +1,11 @@
# Public website URL (HTTPS for hosted contributor pairing)
MINEGEO_URL=http://localhost:3000
# Bind to 0.0.0.0 when intentionally accessing a development server over the LAN.
MINEGEO_HOST=127.0.0.1
# Optional comma-separated exact public origins for a reverse proxy or aliases.
MINEGEO_ALLOWED_ORIGINS=
# Testing only: true bypasses browser origin checks (including CSRF origin protection).
MINEGEO_ALLOW_ANY_ORIGIN=false
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
# Server only. Never prefix this key with NEXT_PUBLIC_.

View File

@@ -1,12 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { authClient } from "@/lib/supabase";
import { requestOrigin } from "@/lib/request-origin";
export async function GET(request: NextRequest) {
const origin = requestOrigin(request, process.env.MINEGEO_URL);
const code = request.nextUrl.searchParams.get("code");
if (code) {
const client = await authClient();
const { error } = await client.auth.exchangeCodeForSession(code);
if (!error)
return NextResponse.redirect(new URL("/dashboard", request.url));
if (!error) return NextResponse.redirect(new URL("/dashboard", origin));
}
return NextResponse.redirect(new URL("/dashboard?auth=failed", request.url));
return NextResponse.redirect(new URL("/dashboard?auth=failed", origin));
}

41
docs/local-network.md Normal file
View File

@@ -0,0 +1,41 @@
# Using MineGeo on a Mac or another LAN server
MineGeo validates browser requests against the hostname and port used to reach
the server. The internal Next.js listening address can differ from that address.
Supabase's email redirect settings do not configure MineGeo's origin validation.
In the server's `.env.local`, set the public URL to the address you actually use.
For example, if the Mac is `192.168.1.50` and MineGeo runs on port `3000`:
```dotenv
MINEGEO_URL=http://192.168.1.50:3000
# Needed only if other devices access the Mac over the LAN:
MINEGEO_HOST=0.0.0.0
```
Use your own IP address and port, then restart MineGeo. `MINEGEO_URL` also controls
the browser pairing link generated for the capture tools.
In Supabase **Authentication → URL Configuration**, use the same base address for
the Site URL and add `http://192.168.1.50:3000/auth/callback` to Redirect URLs.
MineGeo sends the current browser origin plus `/auth/callback` when requesting an
email sign-in link. After changing redirect settings, request a fresh link.
See [Supabase's redirect URL documentation](https://supabase.com/docs/guides/auth/redirect-urls).
For a reverse proxy whose public scheme or hostname differs from the backend,
set `MINEGEO_URL` to the public HTTPS URL. Additional exact origins can be listed
in `MINEGEO_ALLOWED_ORIGINS`, separated by commas. Arbitrary forwarded-host headers
do not authorize additional origins.
For a testing server reached through changing IP addresses, ports, or domains,
add this to its `.env.local` and restart MineGeo:
```dotenv
MINEGEO_ALLOW_ANY_ORIGIN=true
```
This bypasses MineGeo's browser origin checks, including their CSRF protection.
The default is `false`; only the exact value `true` enables the bypass.
Authentication, permissions, and JSON request requirements still apply. This
setting controls MineGeo's API checks; Supabase email sign-in still needs allowed
redirect URLs, and `MINEGEO_URL` still controls capture-tool pairing links.

View File

@@ -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
View 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");
}
}

View File

@@ -0,0 +1,180 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { requestOrigin, verifyOrigin } from "../lib/request-origin";
const internalUrl = "http://localhost:3000/api/v1/tokens";
const makeRequest = (
host: string,
origin?: string,
extra: Record<string, string> = {},
) =>
new Request(internalUrl, {
method: "POST",
headers: {
host,
"content-type": "application/json",
...(origin === undefined ? {} : { origin }),
...extra,
},
});
test("the testing switch accepts any origin only when explicitly enabled", () => {
const request = makeRequest("localhost:3000", "https://test-domain.example");
assert.doesNotThrow(() => verifyOrigin(request, { allowAnyOrigin: true }));
for (const configuration of [{}, { allowAnyOrigin: false }]) {
assert.throws(
() => verifyOrigin(request, configuration),
/Invalid request origin/,
);
}
});
test("the environment switch requires the exact value true", () => {
const previous = process.env.MINEGEO_ALLOW_ANY_ORIGIN;
const request = makeRequest("localhost:3000", "https://test-domain.example");
try {
for (const value of [undefined, "false", "0", "TRUE", "true"]) {
if (value === undefined) delete process.env.MINEGEO_ALLOW_ANY_ORIGIN;
else process.env.MINEGEO_ALLOW_ANY_ORIGIN = value;
if (value === "true") assert.doesNotThrow(() => verifyOrigin(request));
else assert.throws(() => verifyOrigin(request), /Invalid request origin/);
}
} finally {
if (previous === undefined) delete process.env.MINEGEO_ALLOW_ANY_ORIGIN;
else process.env.MINEGEO_ALLOW_ANY_ORIGIN = previous;
}
});
test("the testing switch retains JSON checks for requests without an origin", () => {
assert.throws(
() =>
verifyOrigin(
new Request(internalUrl, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
}),
{ allowAnyOrigin: true },
),
/JSON request required/,
);
});
test("accepts a Mac LAN origin when Next uses localhost internally", () => {
const request = makeRequest("192.168.1.50:3000", "http://192.168.1.50:3000");
assert.equal(requestOrigin(request), "http://192.168.1.50:3000");
assert.equal(
requestOrigin(request, "http://localhost:3000/"),
"http://192.168.1.50:3000",
);
assert.doesNotThrow(() =>
verifyOrigin(request, { websiteUrl: "http://localhost:3000/" }),
);
});
test("matches ports exactly and rejects cross-site and internal-address origins", () => {
for (const origin of [
"http://192.168.1.50:3001",
"https://192.168.1.50:3000",
"https://unrelated.example",
"http://localhost:3000",
]) {
assert.throws(
() => verifyOrigin(makeRequest("192.168.1.50:3000", origin), {}),
/Invalid request origin/,
);
}
});
test("normalizes explicit public URLs and supports configured proxy origins", () => {
assert.doesNotThrow(() =>
verifyOrigin(makeRequest("localhost:3000", "https://minegeo.example"), {
websiteUrl: "https://minegeo.example/",
}),
);
assert.doesNotThrow(() =>
verifyOrigin(makeRequest("localhost:3000", "https://preview.example"), {
additionalOrigins: " https://preview.example/ , https://other.example ",
}),
);
assert.throws(
() =>
verifyOrigin(makeRequest("localhost:3000", "https://unrelated.example"), {
websiteUrl: "https://minegeo.example/",
}),
/Invalid request origin/,
);
});
test("forwarded hosts cannot authorize an unrelated origin", () => {
assert.throws(
() =>
verifyOrigin(
makeRequest("192.168.1.50:3000", "https://unrelated.example", {
"x-forwarded-host": "unrelated.example",
"x-forwarded-proto": "https",
}),
{},
),
/Invalid request origin/,
);
});
test("login redirects preserve a configured public host's HTTPS scheme", () => {
assert.equal(
requestOrigin(makeRequest("minegeo.example"), "https://minegeo.example"),
"https://minegeo.example",
);
});
test("handles IPv6 and default ports without authorizing malformed authorities", () => {
assert.doesNotThrow(() =>
verifyOrigin(makeRequest("[::1]:3000", "http://[::1]:3000"), {}),
);
assert.doesNotThrow(() =>
verifyOrigin(
makeRequest("minegeo.example:80", "http://minegeo.example"),
{},
),
);
for (const host of [
"trusted.example@unrelated.example",
"trusted.example/path",
"trusted.example,unrelated.example",
"trusted.example:invalid-port",
])
assert.throws(
() => requestOrigin(makeRequest(host)),
/Invalid request origin/,
);
});
test("keeps Java JSON and bearer requests usable without a browser Origin", () => {
assert.doesNotThrow(() => verifyOrigin(makeRequest("localhost:3000"), {}));
assert.doesNotThrow(() =>
verifyOrigin(
new Request(internalUrl, {
method: "POST",
headers: { authorization: "Bearer test" },
}),
{},
),
);
assert.throws(
() =>
verifyOrigin(
new Request(internalUrl, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
}),
{},
),
/JSON request required/,
);
});
test("opaque, credential-bearing, and path-bearing origins cannot bypass validation", () => {
for (const origin of [
"null",
"http://name:password@localhost:3000",
"http://localhost:3000/extra",
"http://localhost:3000?extra=1",
]) {
assert.throws(
() => verifyOrigin(makeRequest("localhost:3000", origin), {}),
/Invalid request origin/,
);
}
});