fix(frontend): allow dev-server access from non-localhost hosts (#4471)

Opening the dev stack on a LAN address or a proxied hostname serves the
SSR HTML but never hydrates: Next.js answers /_next/*, /__nextjs_font/*,
and HMR with 403 for any host it was not started on. The page renders, so
it looks up — but no client handler is attached, and the login form's
onSubmit never fires. It reads as "login is broken" rather than as an
asset problem, and the only clue is a warning in the dev-server log.

Wire Next's allowedDevOrigins to a new DEER_FLOW_DEV_ALLOWED_ORIGINS env
var. Unset by default, so the localhost-only default is unchanged; it is
also dev-only, as Next ignores allowedDevOrigins in production builds.

Entries are reduced to the bare host that allowedDevOrigins matches
against, since an entry pasted from the address bar as
"http://192.168.1.10:2026" would otherwise match nothing and leave the
operator with the same 403 they were trying to fix.

Reported in #54 and #203.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DeepCold 2026-07-26 21:34:50 +08:00 committed by GitHub
parent 55c2153080
commit e17aff57a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 141 additions and 0 deletions

View File

@ -20,3 +20,12 @@
# Defaults to localhost — only override for non-local deployments.
# DEER_FLOW_INTERNAL_GATEWAY_BASE_URL="http://localhost:8001"
# DEER_FLOW_TRUSTED_ORIGINS="http://localhost:3000,http://localhost:2026"
# Extra hosts allowed to load Next.js dev-server resources (`/_next/*`, fonts,
# HMR). Development only — production builds ignore it.
# Needed to open `pnpm dev` / `make docker-start` on anything other than
# localhost, such as a LAN address or a proxied hostname: without it Next.js
# answers those requests with 403, so the page renders server-side but never
# hydrates and no interaction (including the login form) works.
# Comma-separated; a full URL is reduced to its host.
# DEER_FLOW_DEV_ALLOWED_ORIGINS="192.168.1.10,*.local"

View File

@ -128,6 +128,8 @@ NEXT_PUBLIC_LANGGRAPH_BASE_URL=http://localhost:8001/api
Leave these unset for the standard `make dev` / Docker flow, where nginx serves the public `/api/langgraph/*` prefix and rewrites it to Gateway's native `/api/*` routes.
To reach a dev server on anything other than localhost — a LAN address, or a proxied hostname — list the host in `DEER_FLOW_DEV_ALLOWED_ORIGINS` (comma-separated; a full URL is reduced to its host). It feeds Next's `allowedDevOrigins`, which gates `/_next/*`, fonts, and HMR. Without it those requests get a 403 and the page renders server-side but never hydrates, so nothing on it — including the login form — responds. Development only; production builds ignore it.
## Resources
- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/)

View File

@ -3,6 +3,7 @@
* for Docker builds.
*/
import "./src/env.js";
import { getAllowedDevOrigins } from "./src/dev-origins.js";
function getInternalServiceURL(envKey, fallbackURL) {
const configured = process.env[envKey]?.trim();
@ -25,6 +26,7 @@ const config = {
defaultLocale: "en",
},
devIndicators: false,
allowedDevOrigins: getAllowedDevOrigins(),
async rewrites() {
const rewrites = [];
const gatewayURL = getInternalServiceURL(

View File

@ -0,0 +1,59 @@
/**
* Hosts allowed to load Next.js dev-server resources (`/_next/*`,
* `/__nextjs_font/*`, HMR) from an origin other than the one `pnpm dev` was
* started on.
*
* Next.js answers those requests with 403 unless the host is listed, so a dev
* stack opened on a LAN address or a proxied hostname serves the SSR HTML but
* never hydrates: the page renders and nothing on it responds.
*
* Dev-only Next ignores `allowedDevOrigins` in production builds.
*/
/**
* Reduce one entry to the bare host `allowedDevOrigins` matches against.
*
* Next matches on host alone, so an entry that still carries a scheme, port, or
* path matches nothing and leaves the caller with the same 403 they were trying
* to fix. Accept the URL people naturally copy out of the address bar.
*
* @param {string} value
* @returns {string} bare host, or `""` if the entry was empty
*/
function normalizeHost(value) {
let host = value.trim();
if (!host) return "";
host = host.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "");
host = host.replace(/[/?#].*$/, "");
const bracketedIpv6 = /^\[([^\]]+)\](?::\d+)?$/.exec(host);
if (bracketedIpv6) return bracketedIpv6[1];
// A bare IPv6 literal has several colons and no port to strip; only a single
// colon can be a `host:port` separator.
if ((host.match(/:/g) ?? []).length === 1) {
host = host.replace(/:\d+$/, "");
}
return host;
}
/**
* Parse a comma-separated host list into the shape `allowedDevOrigins` expects.
*
* @param {string | undefined} raw
* @returns {string[]}
*/
export function parseAllowedDevOrigins(raw) {
return (raw ?? "").split(",").map(normalizeHost).filter(Boolean);
}
/**
* Read the configured hosts from the environment.
*
* @param {Record<string, string | undefined>} [env]
* @returns {string[]}
*/
export function getAllowedDevOrigins(env = process.env) {
return parseAllowedDevOrigins(env.DEER_FLOW_DEV_ALLOWED_ORIGINS);
}

View File

@ -0,0 +1,69 @@
import { describe, expect, test } from "@rstest/core";
import { getAllowedDevOrigins, parseAllowedDevOrigins } from "@/dev-origins";
describe("parseAllowedDevOrigins", () => {
test("returns an empty list when unset or empty", () => {
expect(parseAllowedDevOrigins(undefined)).toEqual([]);
expect(parseAllowedDevOrigins("")).toEqual([]);
expect(parseAllowedDevOrigins(" ")).toEqual([]);
});
test("splits a comma-separated list and trims each entry", () => {
expect(parseAllowedDevOrigins(" 192.168.1.10 , dev.example.com ")).toEqual([
"192.168.1.10",
"dev.example.com",
]);
});
test("drops empty entries from trailing or doubled commas", () => {
expect(parseAllowedDevOrigins("a.example,,b.example,")).toEqual([
"a.example",
"b.example",
]);
});
test("reduces a pasted URL to the bare host Next matches on", () => {
expect(parseAllowedDevOrigins("http://192.168.1.10:2026")).toEqual([
"192.168.1.10",
]);
expect(parseAllowedDevOrigins("https://dev.example.com/")).toEqual([
"dev.example.com",
]);
expect(parseAllowedDevOrigins("http://dev.example.com/login?x=1")).toEqual([
"dev.example.com",
]);
});
test("preserves wildcard patterns", () => {
expect(parseAllowedDevOrigins("*.local, *.example.com")).toEqual([
"*.local",
"*.example.com",
]);
});
test("strips the port from a bracketed IPv6 host without mangling the address", () => {
expect(parseAllowedDevOrigins("[::1]:2026")).toEqual(["::1"]);
expect(parseAllowedDevOrigins("http://[fe80::1]:3000")).toEqual([
"fe80::1",
]);
});
test("leaves a bare IPv6 literal intact", () => {
// Several colons and no port to strip — treating the last group as a port
// would corrupt the address.
expect(parseAllowedDevOrigins("fe80::1")).toEqual(["fe80::1"]);
});
});
describe("getAllowedDevOrigins", () => {
test("reads DEER_FLOW_DEV_ALLOWED_ORIGINS", () => {
expect(
getAllowedDevOrigins({ DEER_FLOW_DEV_ALLOWED_ORIGINS: "192.168.1.10" }),
).toEqual(["192.168.1.10"]);
});
test("defaults to an empty list, keeping localhost-only the default", () => {
expect(getAllowedDevOrigins({})).toEqual([]);
});
});