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

@@ -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/,
);
}
});