Harden request origin checks and configure allowed hosts
This commit is contained in:
@@ -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_.
|
||||
|
||||
@@ -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
41
docs/local-network.md
Normal 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.
|
||||
18
lib/api.ts
18
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,
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
180
tests/request-origin.test.ts
Normal file
180
tests/request-origin.test.ts
Normal 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/,
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user