mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
Enable LAN web dev access (#1947)
This commit is contained in:
+60
-1
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user