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)
|
# Public website URL (HTTPS for hosted contributor pairing)
|
||||||
MINEGEO_URL=http://localhost:3000
|
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_URL=
|
||||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
|
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
|
||||||
# Server only. Never prefix this key with NEXT_PUBLIC_.
|
# Server only. Never prefix this key with NEXT_PUBLIC_.
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { authClient } from "@/lib/supabase";
|
import { authClient } from "@/lib/supabase";
|
||||||
|
import { requestOrigin } from "@/lib/request-origin";
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
const origin = requestOrigin(request, process.env.MINEGEO_URL);
|
||||||
const code = request.nextUrl.searchParams.get("code");
|
const code = request.nextUrl.searchParams.get("code");
|
||||||
if (code) {
|
if (code) {
|
||||||
const client = await authClient();
|
const client = await authClient();
|
||||||
const { error } = await client.auth.exchangeCodeForSession(code);
|
const { error } = await client.auth.exchangeCodeForSession(code);
|
||||||
if (!error)
|
if (!error) return NextResponse.redirect(new URL("/dashboard", origin));
|
||||||
return NextResponse.redirect(new URL("/dashboard", request.url));
|
|
||||||
}
|
}
|
||||||
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 { admin, required, user } from "./supabase";
|
||||||
import { datasetSchema, faces, difficulties } from "../shared/protocol";
|
import { datasetSchema, faces, difficulties } from "../shared/protocol";
|
||||||
import { mediaUrl } from "./catalog";
|
import { mediaUrl } from "./catalog";
|
||||||
|
import { requestOrigin, verifyOrigin } from "./request-origin";
|
||||||
const hash = (value: string) =>
|
const hash = (value: string) =>
|
||||||
createHash("sha256").update(value).digest("hex");
|
createHash("sha256").update(value).digest("hex");
|
||||||
const idSchema = z.string().uuid();
|
const idSchema = z.string().uuid();
|
||||||
@@ -91,21 +92,6 @@ async function jsonBody(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
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[]) {
|
export async function handle(request: NextRequest, path: string[]) {
|
||||||
try {
|
try {
|
||||||
verifyOrigin(request);
|
verifyOrigin(request);
|
||||||
@@ -537,7 +523,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
|||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
deviceCode,
|
deviceCode,
|
||||||
userCode: code,
|
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,
|
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