From 04cc4f39f616a39d7b36317f25261d818d5cd277 Mon Sep 17 00:00:00 2001 From: pavel444-byte Date: Sun, 4 Oct 2026 17:00:02 +0500 Subject: [PATCH] Harden request origin checks and configure allowed hosts --- .env.example | 6 ++ app/auth/callback/route.ts | 7 +- docs/local-network.md | 41 ++++++++ lib/api.ts | 32 ++----- lib/request-origin.ts | 79 +++++++++++++++ tests/request-origin.test.ts | 180 +++++++++++++++++++++++++++++++++++ 6 files changed, 319 insertions(+), 26 deletions(-) create mode 100644 docs/local-network.md create mode 100644 lib/request-origin.ts create mode 100644 tests/request-origin.test.ts diff --git a/.env.example b/.env.example index 6c50556..c6a0c91 100644 --- a/.env.example +++ b/.env.example @@ -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_. diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts index 732344f..290218e 100644 --- a/app/auth/callback/route.ts +++ b/app/auth/callback/route.ts @@ -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)); } diff --git a/docs/local-network.md b/docs/local-network.md new file mode 100644 index 0000000..8aa5de7 --- /dev/null +++ b/docs/local-network.md @@ -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. diff --git a/lib/api.ts b/lib/api.ts index e8f4737..5ff161f 100644 --- a/lib/api.ts +++ b/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 }); } } diff --git a/lib/request-origin.ts b/lib/request-origin.ts new file mode 100644 index 0000000..55d5d37 --- /dev/null +++ b/lib/request-origin.ts @@ -0,0 +1,79 @@ +type OriginRequest = Pick; +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, + 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"); + } +} diff --git a/tests/request-origin.test.ts b/tests/request-origin.test.ts new file mode 100644 index 0000000..982be68 --- /dev/null +++ b/tests/request-origin.test.ts @@ -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 = {}, +) => + 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/, + ); + } +});