427 lines
14 KiB
JavaScript
427 lines
14 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { isIP } from "node:net";
|
|
import { readFile } from "node:fs/promises";
|
|
import vm from "node:vm";
|
|
import ts from "typescript";
|
|
|
|
const environment = { PUBLIC_DATA_CACHE_TTL_MS: "60000" };
|
|
const sharedDependencies = { "node:net": { isIP } };
|
|
async function compile(path, dependencies = {}, extra = {}) {
|
|
const exports = {};
|
|
const { outputText } = ts.transpileModule(
|
|
await readFile(
|
|
new URL("../src/lib/api/" + path + ".ts", import.meta.url),
|
|
"utf8",
|
|
),
|
|
{
|
|
compilerOptions: {
|
|
target: ts.ScriptTarget.ES2022,
|
|
module: ts.ModuleKind.CommonJS,
|
|
},
|
|
},
|
|
);
|
|
vm.runInNewContext(outputText, {
|
|
exports,
|
|
URL,
|
|
Headers,
|
|
Request,
|
|
Response,
|
|
FormData,
|
|
AbortSignal,
|
|
TextDecoder,
|
|
Buffer,
|
|
structuredClone,
|
|
process: { env: environment },
|
|
require: (name) => {
|
|
if (name in dependencies) return dependencies[name];
|
|
if (name in sharedDependencies) return sharedDependencies[name];
|
|
throw new Error("Unknown dependency: " + name);
|
|
},
|
|
...extra,
|
|
});
|
|
return exports;
|
|
}
|
|
const config = await compile("config");
|
|
const protocol = await compile("protocol");
|
|
const cache = await compile("public-cache");
|
|
sharedDependencies["./user-agent"] = await compile("user-agent");
|
|
sharedDependencies["./sdk-headers"] = await compile("sdk-headers");
|
|
sharedDependencies["./client-headers"] = await compile("client-headers");
|
|
sharedDependencies["../site-navigation"] = await compile("../site-navigation", {
|
|
"./api/protocol": protocol,
|
|
"./routing": await compile("../routing"),
|
|
});
|
|
|
|
test("development uses its configured domain and supports legacy overrides", () => {
|
|
delete environment.UPSTREAM_API_URL;
|
|
delete environment.API_BASE_URL;
|
|
assert.equal(config.getApiBaseUrl(), "https://webhgcapi.mfcg.me");
|
|
environment.APP_ENV = "test";
|
|
assert.equal(config.getApiBaseUrl(), "https://webhgcapi.mfcg.me");
|
|
environment.APP_ENV = "production";
|
|
assert.equal(config.getApiBaseUrl(), "https://webhgcapi.mfcg.me");
|
|
delete environment.APP_ENV;
|
|
environment.API_BASE_URL = "https://legacy-api.example/";
|
|
assert.equal(config.getApiBaseUrl(), "https://legacy-api.example");
|
|
environment.UPSTREAM_API_URL = " http://127.0.0.1:21111/ ";
|
|
assert.equal(config.getApiBaseUrl(), "http://127.0.0.1:21111");
|
|
for (const value of [
|
|
"",
|
|
"javascript:bad",
|
|
"https://user:secret@api.example",
|
|
"https://api.example/?query=1",
|
|
"https://api.example/#fragment",
|
|
]) {
|
|
environment.UPSTREAM_API_URL = value;
|
|
assert.throws(() => config.getApiBaseUrl(), /UPSTREAM_API_URL/);
|
|
}
|
|
});
|
|
|
|
test("explicit current mode resolves the current domain per request, including protocol and port", () => {
|
|
environment.UPSTREAM_API_URL = "current";
|
|
delete environment.API_BASE_URL;
|
|
for (const APP_ENV of ["test", "production"]) {
|
|
environment.APP_ENV = APP_ENV;
|
|
assert.equal(config.usesCurrentApiOrigin(), true);
|
|
assert.equal(
|
|
config.getApiBaseUrl({
|
|
url: "http://0.0.0.0:21030/api/app/config/info",
|
|
headers: new Headers({
|
|
Host: "first.example:8443",
|
|
"X-Forwarded-Proto": "https",
|
|
"X-Forwarded-Host": "ignored.example",
|
|
}),
|
|
}),
|
|
"https://first.example:8443",
|
|
);
|
|
assert.equal(
|
|
config.getApiBaseUrl({
|
|
url: "http://second.example:21030/api/app/config/info",
|
|
headers: new Headers(),
|
|
}),
|
|
"http://second.example:21030",
|
|
);
|
|
assert.equal(
|
|
config.getApiBaseUrl({
|
|
headers: new Headers({
|
|
"X-Forwarded-Host": "fallback.example",
|
|
"X-Forwarded-Proto": "https",
|
|
}),
|
|
}),
|
|
"https://fallback.example",
|
|
);
|
|
assert.throws(() => config.getApiBaseUrl(), /请求域名/);
|
|
for (const Host of [
|
|
"user:secret@api.test",
|
|
"api.test/path",
|
|
"api.test?x=1",
|
|
"api.test,other.test",
|
|
"api.test\\other",
|
|
]) {
|
|
assert.throws(() =>
|
|
config.getApiBaseUrl({ headers: new Headers({ Host }) }),
|
|
);
|
|
}
|
|
assert.throws(() =>
|
|
config.getApiBaseUrl({
|
|
headers: new Headers({ Host: "api.test", "X-Forwarded-Proto": "ftp" }),
|
|
}),
|
|
);
|
|
}
|
|
delete environment.APP_ENV;
|
|
});
|
|
|
|
test("SSR and API proxy share the configured upstream while cache entries stay isolated by backend", async () => {
|
|
const requests = [];
|
|
const incoming = new Headers({
|
|
"User-Agent": "visitor-ua",
|
|
"X-Real-IP": "203.0.113.9",
|
|
});
|
|
const server = await compile(
|
|
"server",
|
|
{
|
|
"./protocol": protocol,
|
|
"./config": config,
|
|
"./public-cache": cache,
|
|
react: { cache: (fn) => fn },
|
|
"next/headers": { headers: async () => incoming },
|
|
},
|
|
{
|
|
fetch: async (url, options) => {
|
|
requests.push({ url: String(url), ...options });
|
|
return Response.json({
|
|
code: 200,
|
|
data: { source: new URL(url).origin },
|
|
});
|
|
},
|
|
},
|
|
);
|
|
environment.UPSTREAM_API_URL = "https://api-a.example";
|
|
const first = await server.upstream("config/info");
|
|
assert.equal(first.data.source, "https://api-a.example");
|
|
assert.equal(requests[0].headers["User-Agent"], "visitor-ua");
|
|
assert.equal(requests[0].headers["X-Real-IP"], "203.0.113.9");
|
|
environment.UPSTREAM_API_URL = "https://api-b.example";
|
|
assert.equal(
|
|
(await server.upstream("config/info")).data.source,
|
|
"https://api-b.example",
|
|
);
|
|
environment.UPSTREAM_API_URL = "https://api-a.example";
|
|
assert.equal(
|
|
(await server.upstream("config/info")).data.source,
|
|
"https://api-a.example",
|
|
);
|
|
assert.equal(requests.length, 2);
|
|
await server.upstream("auth/getInfo", { token: "server-only-token" });
|
|
assert.equal(
|
|
requests.at(-1).url,
|
|
"https://api-a.example/api/app/auth/getInfo",
|
|
);
|
|
assert.equal(requests.at(-1).headers.Authorization, "server-only-token");
|
|
});
|
|
|
|
test("SSR, API handlers and private requests use the current origin without mixing public caches", async () => {
|
|
environment.UPSTREAM_API_URL = "current";
|
|
let incoming = new Headers({
|
|
Host: "site-a.example",
|
|
"X-Forwarded-Proto": "https",
|
|
});
|
|
const requests = [];
|
|
const server = await compile(
|
|
"server",
|
|
{
|
|
"./protocol": protocol,
|
|
"./config": config,
|
|
"./public-cache": cache,
|
|
react: { cache: (fn) => fn },
|
|
"next/headers": { headers: async () => incoming },
|
|
},
|
|
{
|
|
fetch: async (url, options) => {
|
|
requests.push({ url: String(url), ...options });
|
|
return Response.json({
|
|
code: 200,
|
|
data: { source: new URL(url).origin },
|
|
});
|
|
},
|
|
},
|
|
);
|
|
assert.equal(
|
|
(await server.upstream("config/info")).data.source,
|
|
"https://site-a.example",
|
|
);
|
|
incoming = new Headers({
|
|
Host: "site-b.example",
|
|
"X-Forwarded-Proto": "http",
|
|
});
|
|
assert.equal(
|
|
(await server.upstream("config/info")).data.source,
|
|
"http://site-b.example",
|
|
);
|
|
const request = new Request("https://site-a.example/api/app/config/info");
|
|
assert.equal(
|
|
(await server.upstream("config/info", { request })).data.source,
|
|
"https://site-a.example",
|
|
);
|
|
assert.equal(requests.length, 2);
|
|
await server.upstream("auth/getInfo", {
|
|
request,
|
|
token: "private-current-token",
|
|
});
|
|
assert.equal(
|
|
requests.at(-1).url,
|
|
"https://site-a.example/api/app/auth/getInfo",
|
|
);
|
|
assert.equal(requests.at(-1).headers.Authorization, "private-current-token");
|
|
assert.equal(requests.at(-1).headers[config.UPSTREAM_HOP_HEADER], "1");
|
|
});
|
|
|
|
test("current-origin media preserves signatures and fails immediately if the upstream returns to Next", async () => {
|
|
environment.UPSTREAM_API_URL = "current";
|
|
const requests = [];
|
|
const media = await compile(
|
|
"media",
|
|
{ "node:net": { isIP }, "./config": config },
|
|
{
|
|
fetch: async (url, options) => {
|
|
requests.push({ url: String(url), ...options });
|
|
return new Response(new Uint8Array(16));
|
|
},
|
|
},
|
|
);
|
|
const request = new Request(
|
|
"http://0.0.0.0:21030/enkey?id=1&ticket=a%2Fb+1",
|
|
{
|
|
headers: {
|
|
Host: "deployed.example",
|
|
"X-Forwarded-Proto": "https",
|
|
Cookie: "drama_session=secret",
|
|
},
|
|
},
|
|
);
|
|
assert.equal((await media.proxyMedia(request, "/enkey", true)).status, 200);
|
|
assert.equal(
|
|
requests[0].url,
|
|
"https://deployed.example/enkey?id=1&ticket=a%2Fb+1",
|
|
);
|
|
assert.equal(requests[0].headers.get("cookie"), null);
|
|
assert.equal(requests[0].headers.get(config.UPSTREAM_HOP_HEADER), "1");
|
|
const loop = new Request("https://deployed.example/enkey", {
|
|
headers: { [config.UPSTREAM_HOP_HEADER]: "1" },
|
|
});
|
|
const response = await media.proxyMedia(loop, "/enkey", true);
|
|
assert.equal(response.status, 502);
|
|
assert.match(await response.text(), /转发配置/);
|
|
assert.equal(requests.length, 1);
|
|
});
|
|
|
|
test("media and keys use the configured upstream while preserving signatures, byte ranges and credential isolation", async () => {
|
|
const reads = [];
|
|
const media = await compile(
|
|
"media",
|
|
{ "node:net": { isIP }, "./config": config },
|
|
{
|
|
fetch: async (url, options) => {
|
|
reads.push({ url: String(url), headers: options.headers });
|
|
return new Response(
|
|
new URL(url).pathname === "/enkey/"
|
|
? new Uint8Array(16)
|
|
: "#EXTM3U\n#EXT-X-ENDLIST\n",
|
|
);
|
|
},
|
|
},
|
|
);
|
|
environment.UPSTREAM_API_URL = "http://127.0.0.1:21111";
|
|
const incoming = new Request(
|
|
"https://current-site.example/h5/m3u8/video.m3u8?md=a%2Fb+value&x=1&x=2",
|
|
{
|
|
headers: {
|
|
Range: "bytes=0-2",
|
|
Cookie: "drama_session=secret",
|
|
Authorization: "secret",
|
|
"X-Real-IP": "203.0.113.4",
|
|
},
|
|
},
|
|
);
|
|
assert.equal(
|
|
(await media.proxyMedia(incoming, "/h5/m3u8/video.m3u8")).status,
|
|
200,
|
|
);
|
|
assert.equal(
|
|
reads.at(-1).url,
|
|
"http://127.0.0.1:21111/h5/m3u8/video.m3u8?md=a%2Fb+value&x=1&x=2",
|
|
);
|
|
assert.equal(reads.at(-1).headers.get("range"), "bytes=0-2");
|
|
assert.equal(reads.at(-1).headers.get("x-real-ip"), "203.0.113.4");
|
|
assert.equal(reads.at(-1).headers.get("x-forwarded-for"), null);
|
|
assert.equal(reads.at(-1).headers.get("authorization"), null);
|
|
assert.equal(reads.at(-1).headers.get("cookie"), null);
|
|
const key = await media.proxyMedia(
|
|
new Request("https://current-site.example/enkey?token=a%2Fb"),
|
|
"/enkey/",
|
|
true,
|
|
);
|
|
assert.equal(key.status, 200);
|
|
assert.equal((await key.arrayBuffer()).byteLength, 16);
|
|
assert.equal(reads.at(-1).url, "http://127.0.0.1:21111/enkey/?token=a%2Fb");
|
|
});
|
|
|
|
test("key proxies follow one same-origin slash change in either direction without changing signed queries or headers", async () => {
|
|
environment.UPSTREAM_API_URL = "https://key-api.example";
|
|
const query = "?id=12700&t=123&ticket=a%2Fb+1&ticket=x%252Fy";
|
|
for (const pathname of ["/enkey", "/enkey/", "/newenkey", "/newenkey/"]) {
|
|
const alternate = pathname.endsWith("/")
|
|
? pathname.slice(0, -1)
|
|
: pathname + "/";
|
|
const reads = [];
|
|
const media = await compile(
|
|
"media",
|
|
{ "node:net": { isIP }, "./config": config },
|
|
{
|
|
fetch: async (url, options) => {
|
|
reads.push({ url: String(url), ...options });
|
|
return reads.length === 1
|
|
? new Response("", {
|
|
status: 301,
|
|
headers: {
|
|
Location: alternate + (pathname.endsWith("/") ? "" : query),
|
|
},
|
|
})
|
|
: new Response(new Uint8Array(16));
|
|
},
|
|
},
|
|
);
|
|
const result = await media.proxyMedia(
|
|
new Request("http://local.test" + pathname + query, {
|
|
headers: {
|
|
"X-Real-IP": "203.0.113.7",
|
|
"User-Agent": "key-test",
|
|
Cookie: "drama_session=private",
|
|
Authorization: "private",
|
|
},
|
|
}),
|
|
pathname,
|
|
true,
|
|
);
|
|
assert.equal(result.status, 200);
|
|
assert.equal((await result.arrayBuffer()).byteLength, 16);
|
|
assert.deepEqual(
|
|
reads.map((item) => item.url),
|
|
[
|
|
"https://key-api.example" + pathname + query,
|
|
"https://key-api.example" + alternate + query,
|
|
],
|
|
);
|
|
for (const read of reads) {
|
|
assert.equal(read.redirect, "manual");
|
|
assert.equal(read.cache, "no-store");
|
|
assert.equal(read.headers.get("X-Real-IP"), "203.0.113.7");
|
|
assert.equal(read.headers.get("User-Agent"), "key-test");
|
|
assert.equal(
|
|
read.headers.get("X-User-Agent"),
|
|
"DevType=desktop;SysType=h5_pc;Ver=1.0.0;BuildID=;device_brand=;device_model=;system_name=Other;system_version=;sid=",
|
|
);
|
|
assert.equal(read.headers.get("Cookie"), null);
|
|
assert.equal(read.headers.get("Authorization"), null);
|
|
}
|
|
assert.equal(reads[0].signal, reads[1].signal);
|
|
}
|
|
});
|
|
|
|
test("key proxies reject unrelated, cross-origin and repeated redirects before forwarding credentials or signatures", async () => {
|
|
environment.UPSTREAM_API_URL = "https://key-api.example";
|
|
for (const [location, repeated] of [
|
|
["https://external.example/enkey/", false],
|
|
["/newenkey/", false],
|
|
["/enkey/?ticket=changed", false],
|
|
["https://user:secret@key-api.example/enkey/", false],
|
|
["/enkey/", true],
|
|
[null, false],
|
|
]) {
|
|
const reads = [];
|
|
const media = await compile(
|
|
"media",
|
|
{ "node:net": { isIP }, "./config": config },
|
|
{
|
|
fetch: async (url) => {
|
|
reads.push(String(url));
|
|
return new Response("", {
|
|
status: 302,
|
|
headers: location
|
|
? { Location: reads.length === 1 ? location : "/enkey" }
|
|
: {},
|
|
});
|
|
},
|
|
},
|
|
);
|
|
const result = await media.proxyMedia(
|
|
new Request("http://local.test/enkey?ticket=original"),
|
|
"/enkey",
|
|
true,
|
|
);
|
|
assert.equal(result.status, 502, String(location));
|
|
assert.equal(reads.length, repeated ? 2 : 1, String(location));
|
|
}
|
|
});
|