x-9r-real-ip and the Host fallback were trusted from client-controlled
headers whenever custom-server.js was not in the request path (npm run
start, start:bun), letting a remote caller pose as local to skip API key
auth and reach LOCAL_ONLY_PATHS (/api/mcp/*, /api/tunnel/enable,
/api/auth/reset-password).
custom-server.js now generates a per-process secret at boot and stamps it
as x-9r-peer-token on every request it sanitizes. hasTrustedPeerHeaders()
(src/lib/auth/trustedPeer.js) gates trust in x-9r-real-ip on that secret;
otherwise the guard falls back to Host only in development, and fails
closed in production. Same gate on loginLimiter.getClientIp() so a spoofed
header cannot rotate the login lockout bucket.
Also: fix isLoopbackHostname for IPv6 (::1, ::ffff:127.0.0.1) which the
old split(":")[0] reduced to empty string; route npm run start /
start:bun through custom-server.js (postbuild copies it into
.next/standalone, build-cli.js fails without it) so documented deployments
keep passwordless local access.
67 lines
2.8 KiB
JavaScript
67 lines
2.8 KiB
JavaScript
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { mkdtempSync } from "node:fs";
|
|
import { describe, expect, it } from "vitest";
|
|
import { copyStandaloneAssets } from "../../scripts/copy-standalone-assets.mjs";
|
|
|
|
function createBuildFixture(distDir) {
|
|
const projectRoot = mkdtempSync(join(tmpdir(), "9router-standalone-assets-"));
|
|
const buildRoot = join(projectRoot, distDir);
|
|
mkdirSync(join(buildRoot, "standalone"), { recursive: true });
|
|
mkdirSync(join(buildRoot, "static", "chunks"), { recursive: true });
|
|
mkdirSync(join(projectRoot, "public"), { recursive: true });
|
|
writeFileSync(join(buildRoot, "static", "chunks", "app.js"), "static asset");
|
|
writeFileSync(join(projectRoot, "public", "favicon.svg"), "public asset");
|
|
return projectRoot;
|
|
}
|
|
|
|
describe("standalone build assets", () => {
|
|
it("copies static and public assets into the default standalone layout", () => {
|
|
const projectRoot = createBuildFixture(".next");
|
|
|
|
copyStandaloneAssets({ projectRoot, distDir: ".next" });
|
|
|
|
expect(readFileSync(join(projectRoot, ".next", "standalone", ".next", "static", "chunks", "app.js"), "utf8"))
|
|
.toBe("static asset");
|
|
expect(readFileSync(join(projectRoot, ".next", "standalone", "public", "favicon.svg"), "utf8"))
|
|
.toBe("public asset");
|
|
});
|
|
|
|
it("uses a custom Next dist directory", () => {
|
|
const projectRoot = createBuildFixture(".next-cli-build");
|
|
|
|
copyStandaloneAssets({ projectRoot, distDir: ".next-cli-build" });
|
|
|
|
expect(readFileSync(join(projectRoot, ".next-cli-build", "standalone", ".next-cli-build", "static", "chunks", "app.js"), "utf8"))
|
|
.toBe("static asset");
|
|
});
|
|
|
|
// Without the wrapper beside server.js nothing can prove a request is local.
|
|
it("copies the request-sanitizing server wrapper into the standalone output", () => {
|
|
const projectRoot = createBuildFixture(".next");
|
|
writeFileSync(join(projectRoot, "custom-server.js"), "wrapper");
|
|
|
|
copyStandaloneAssets({ projectRoot, distDir: ".next" });
|
|
|
|
expect(readFileSync(join(projectRoot, ".next", "standalone", "custom-server.js"), "utf8"))
|
|
.toBe("wrapper");
|
|
});
|
|
|
|
it("does not modify workspace-traced CLI builds", () => {
|
|
const projectRoot = createBuildFixture(".next-cli-build");
|
|
const previousMode = process.env.NEXT_TRACING_ROOT_MODE;
|
|
process.env.NEXT_TRACING_ROOT_MODE = "workspace";
|
|
|
|
try {
|
|
copyStandaloneAssets({ projectRoot, distDir: ".next-cli-build" });
|
|
} finally {
|
|
if (previousMode === undefined) delete process.env.NEXT_TRACING_ROOT_MODE;
|
|
else process.env.NEXT_TRACING_ROOT_MODE = previousMode;
|
|
}
|
|
|
|
expect(() => readFileSync(join(projectRoot, ".next-cli-build", "standalone", ".next-cli-build", "static", "chunks", "app.js")))
|
|
.toThrow();
|
|
});
|
|
});
|