Enable LAN web dev access (#1947)

This commit is contained in:
Zellux Wang
2026-05-19 17:50:50 +08:00
committed by GitHub
parent 643cfee8e9
commit 52b702648e
3 changed files with 233 additions and 2 deletions
+60 -1
View File
@@ -1,4 +1,5 @@
import type { NextConfig } from 'next';
import { networkInterfaces } from 'node:os';
import { dirname, isAbsolute, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -42,8 +43,66 @@ function resolveDevTsconfigPath() {
const DEV_TSCONFIG_PATH = resolveDevTsconfigPath();
function parseAllowedDevHost(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
try {
return new URL(trimmed).hostname.toLowerCase();
} catch {
try {
return new URL(`http://${trimmed}`).hostname.toLowerCase();
} catch {
return null;
}
}
}
function parseIpv4(value: string): [number, number, number, number] | null {
const parts = value.split('.');
if (parts.length !== 4) return null;
if (!parts.every((part) => /^\d+$/.test(part))) return null;
const octets = parts.map((part) => Number(part));
if (!octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)) return null;
return octets as [number, number, number, number];
}
function isPrivateLanIpv4(value: string): boolean {
const octets = parseIpv4(value);
if (octets == null) return false;
const [a, b] = octets;
return (
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254) ||
(a === 100 && b >= 64 && b <= 127)
);
}
function localPrivateLanHosts(): string[] {
return Object.values(networkInterfaces())
.flatMap((entries) => entries ?? [])
.filter((entry) => entry.family === 'IPv4' && !entry.internal && isPrivateLanIpv4(entry.address))
.map((entry) => entry.address);
}
function configuredAllowedDevHosts(): string[] {
const configured = (process.env.OD_ALLOWED_DEV_ORIGINS ?? '')
.split(',')
.map(parseAllowedDevHost)
.filter((host): host is string => host != null);
const bindHost = parseAllowedDevHost(process.env.OD_HOST ?? '');
return Array.from(new Set([
'127.0.0.1',
...localPrivateLanHosts(),
...(bindHost != null && bindHost !== '0.0.0.0' && bindHost !== '::' ? [bindHost] : []),
...configured,
]));
}
const nextConfig: NextConfig = {
allowedDevOrigins: ['127.0.0.1'],
allowedDevOrigins: configuredAllowedDevHosts(),
outputFileTracingRoot: WORKSPACE_ROOT,
reactStrictMode: true,
turbopack: {
+133 -1
View File
@@ -242,6 +242,7 @@ function resolveHttpProxyTarget(
export function normalizeDaemonProxyOriginHeader(options: {
daemonOrigin: string;
origin: string | undefined;
requestHost?: string | string[];
webPort: number;
}): string | undefined {
if (options.origin == null || options.origin.length === 0) return options.origin;
@@ -252,7 +253,137 @@ export function normalizeDaemonProxyOriginHeader(options: {
schemes.flatMap((scheme) => loopbackHosts.map((host) => `${scheme}://${host}:${options.webPort}`)),
);
return allowedWebOrigins.has(options.origin) ? options.daemonOrigin : options.origin;
if (allowedWebOrigins.has(options.origin)) return options.daemonOrigin;
const parsedOrigin = parseHttpOrigin(options.origin);
if (
parsedOrigin != null &&
isSameBrowserHostOrigin({
origin: parsedOrigin,
requestHost: options.requestHost,
webPort: options.webPort,
})
) {
return options.daemonOrigin;
}
return options.origin;
}
function firstHeaderValue(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function parseHostHeader(value: string | string[] | undefined): URL | null {
const raw = firstHeaderValue(value)?.trim();
if (raw == null || raw.length === 0) return null;
try {
return new URL(`http://${raw}`);
} catch {
return null;
}
}
function parseHttpOrigin(value: string): URL | null {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed : null;
} catch {
return null;
}
}
function parseAllowedDevHost(value: string): string | null {
const trimmed = value.trim();
if (trimmed.length === 0) return null;
try {
return new URL(trimmed).hostname.toLowerCase();
} catch {
try {
return new URL(`http://${trimmed}`).hostname.toLowerCase();
} catch {
return null;
}
}
}
function configuredAllowedDevHosts(): Set<string> {
return new Set(
(process.env.OD_ALLOWED_DEV_ORIGINS ?? "")
.split(",")
.map(parseAllowedDevHost)
.filter((host): host is string => host != null),
);
}
function isAllowedDevHost(hostname: string, allowedHosts: Set<string>): boolean {
const host = hostname.toLowerCase();
if (allowedHosts.has(host)) return true;
for (const allowedHost of allowedHosts) {
if (!allowedHost.startsWith("*.")) continue;
const suffix = allowedHost.slice(1);
if (host.endsWith(suffix) && host.length > suffix.length) return true;
}
return false;
}
function parseIpv4(value: string): [number, number, number, number] | null {
const parts = value.split(".");
if (parts.length !== 4) return null;
if (!parts.every((part) => /^\d+$/.test(part))) return null;
const octets = parts.map((part) => Number(part));
if (!octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)) return null;
return octets as [number, number, number, number];
}
function isPrivateLanIpv4(value: string): boolean {
const octets = parseIpv4(value);
if (octets == null) return false;
const [a, b] = octets;
return (
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254) ||
(a === 100 && b >= 64 && b <= 127)
);
}
function isLoopbackOrPrivateLanHost(hostname: string): boolean {
const host = hostname.toLowerCase();
return (
host === "localhost" ||
host === "127.0.0.1" ||
host === "::1" ||
host === "[::1]" ||
host === "0.0.0.0" ||
host === "::" ||
isPrivateLanIpv4(host)
);
}
function defaultPortForProtocol(protocol: string): string {
return protocol === "https:" ? "443" : "80";
}
function isSameBrowserHostOrigin(options: {
origin: URL;
requestHost?: string | string[];
webPort: number;
}): boolean {
const requestHost = parseHostHeader(options.requestHost);
if (requestHost == null) return false;
const originPort = options.origin.port || defaultPortForProtocol(options.origin.protocol);
const requestPort = requestHost.port || "80";
if (originPort !== String(options.webPort) || requestPort !== originPort) return false;
if (requestHost.hostname.toLowerCase() !== options.origin.hostname.toLowerCase()) return false;
const allowedDevHosts = configuredAllowedDevHosts();
const originHost = options.origin.hostname.toLowerCase();
return isLoopbackOrPrivateLanHost(originHost) || isAllowedDevHost(originHost, allowedDevHosts);
}
async function proxyHttpRequest(
@@ -267,6 +398,7 @@ async function proxyHttpRequest(
const origin = normalizeDaemonProxyOriginHeader({
daemonOrigin: target.origin,
origin: typeof request.headers.origin === "string" ? request.headers.origin : undefined,
requestHost: request.headers.host,
webPort: options.daemonWebPort,
});
if (origin == null || origin.length === 0) {
+40
View File
@@ -160,6 +160,46 @@ describe('normalizeDaemonProxyOriginHeader', () => {
).toBe('http://127.0.0.1:7456');
});
it('normalizes matching private LAN browser origins to the daemon origin', () => {
expect(
normalizeDaemonProxyOriginHeader({
daemonOrigin: 'http://127.0.0.1:7456',
origin: 'http://192.168.3.23:8085',
requestHost: '192.168.3.23:8085',
webPort: 8085,
}),
).toBe('http://127.0.0.1:7456');
});
it('does not normalize mismatched private LAN origins', () => {
expect(
normalizeDaemonProxyOriginHeader({
daemonOrigin: 'http://127.0.0.1:7456',
origin: 'http://192.168.3.23:8085',
requestHost: '192.168.3.24:8085',
webPort: 8085,
}),
).toBe('http://192.168.3.23:8085');
});
it('normalizes matching wildcard configured dev origins to the daemon origin', () => {
const previous = process.env.OD_ALLOWED_DEV_ORIGINS;
process.env.OD_ALLOWED_DEV_ORIGINS = '*.local-origin.dev';
try {
expect(
normalizeDaemonProxyOriginHeader({
daemonOrigin: 'http://127.0.0.1:7456',
origin: 'http://app.local-origin.dev:8085',
requestHost: 'app.local-origin.dev:8085',
webPort: 8085,
}),
).toBe('http://127.0.0.1:7456');
} finally {
if (previous == null) delete process.env.OD_ALLOWED_DEV_ORIGINS;
else process.env.OD_ALLOWED_DEV_ORIGINS = previous;
}
});
it('does not rewrite unrelated browser origins', () => {
expect(
normalizeDaemonProxyOriginHeader({