From b3914e69e778a18d1ccd46eb7f5687a50cdcb742 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Fri, 14 Aug 2026 02:34:52 +0200 Subject: [PATCH 1/5] chore: replace lvh.me and veryfront.dev dev hostnames with localhost lvh.me and veryfront.dev are public DNS names that resolve to 127.0.0.1. DNS rebind protection blocks that answer by default on FRITZ!Box routers, most corporate DNS, and Pi-hole, leaving the local stack unreachable with no actionable error. veryfront.dev no longer resolves at all, so paths keyed on it were already dead. localhost and *.localhost are reserved by RFC 6761, resolve without a resolver, and are W3C Secure Contexts. Most allowlists already carried a localhost entry from an earlier migration, so the correct edit was usually to delete the lvh.me / veryfront.dev entry rather than translate it. - domain-parser: LOCAL_DEV_DOMAINS collapses to a single-label root; the eTLD+1 'last two labels' branch in isLocalDevHost is removed. - local-control-request: TRUSTED_LOCAL_CONTROL_ROOTS collapses to one constant. Shape rules and loopback-peer/proxy-hop/Fetch-Metadata requirements are unchanged, so admission strictly narrows. - dev projects API: the empty-host fallback becomes localhost. - mdx http-fetcher: the local module server origin becomes {slug}.localhost. - Tests: drop cases that already had an exact localhost twin, convert the rest, and cover the removed negative class with a neutral wildcard-dns.example host. Adds anchoring and port-handling tests for the single-label suffix match. - Deletes two now-dead paths: the e2e COOP 'untrustworthy origin' console ignore (*.localhost is trustworthy) and the .veryfront.dev negative assertions guarding an old wildcard Studio origin check. Regenerates docs/api-reference for the changed JSDoc. --- docs/api-reference/veryfront/server.md | 8 +- src/cache/backends/factory.ts | 2 +- src/proxy/handler.test.ts | 8 +- src/proxy/handler.ts | 2 +- src/proxy/mode-parity.test.ts | 4 +- src/security/README.md | 2 +- .../http/local-control-request.test.ts | 25 ++- src/security/http/local-control-request.ts | 39 ++--- .../http/response/security-handler.ts | 4 +- .../http/studio-origin-policy.test.ts | 2 - src/security/sandbox/worker-script.test.ts | 2 +- src/server/context/request-context.test.ts | 24 +-- .../error-overlay/html-template.test.ts | 2 - .../dev/dashboard/access-policy.test.ts | 16 +- .../dev/local-control-admission.test.ts | 2 +- src/server/handlers/dev/projects/api.ts | 4 +- .../handlers/dev/scripts/hmr-scripts.test.ts | 1 - .../project-resolution.test.ts | 14 +- .../project-runtime-context.test.ts | 8 +- src/server/utils/domain-parser.test.ts | 155 ++++++------------ src/server/utils/domain-parser.ts | 70 ++++---- src/server/utils/error-html.test.ts | 1 - src/studio/bridge/bridge-messaging.test.ts | 1 - .../module-fetcher/http-fetcher.test.ts | 2 +- .../module-fetcher/http-fetcher.ts | 2 +- tests/e2e/helpers/assertions.ts | 2 - tests/e2e/helpers/runtime.ts | 4 +- tests/e2e/multi-project.playwright.ts | 2 +- tests/e2e/playwright.config.cjs | 4 +- tests/e2e/setup/server.ts | 4 +- tests/e2e/smoke.playwright.ts | 2 +- tests/integration/vfs-proxy-mode-e2e.test.ts | 4 +- tests/load-test-isolation.ts | 2 +- tests/server/context/request-context.test.ts | 6 +- 34 files changed, 180 insertions(+), 250 deletions(-) diff --git a/docs/api-reference/veryfront/server.md b/docs/api-reference/veryfront/server.md index c3f6102c5a..14a5b15e51 100644 --- a/docs/api-reference/veryfront/server.md +++ b/docs/api-reference/veryfront/server.md @@ -40,7 +40,7 @@ await server.fetch(new Request("https://example.com/health")); | Name | Description | Source | | -------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `HOSTED_ENVIRONMENT_NAMES` | Environment labels that `{slug}.{environment}.veryfront.com` actually routes. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/utils/domain-parser.ts#L44) | +| `HOSTED_ENVIRONMENT_NAMES` | Environment labels that `{slug}.{environment}.veryfront.com` actually routes. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/utils/domain-parser.ts#L47) | | `ReloadNotifier` | Render reload notifier. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/reload-notifier.ts#L146) | ### Functions @@ -50,8 +50,8 @@ await server.fetch(new Request("https://example.com/health")); | `createHandler` | Create a Veryfront request handler for development or production. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L238) | | `createVeryfrontServer` | Create veryfront server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/service-server.ts#L157) | | `gracefullyShutdownProductionServer` | Enter lame-duck mode, mark readiness false, drain tracked requests and SSE response bodies, and stop a production server process. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/graceful-shutdown.ts#L218) | -| `isHostedEnvironmentName` | Whether `{slug}.{name}.veryfront.com` is a host the platform can route. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/utils/domain-parser.ts#L58) | -| `parseProjectDomain` | Extract project slug and branch from domain/host header | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/utils/domain-parser.ts#L122) | +| `isHostedEnvironmentName` | Whether `{slug}.{name}.veryfront.com` is a host the platform can route. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/utils/domain-parser.ts#L61) | +| `parseProjectDomain` | Extract project slug and branch from domain/host header | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/utils/domain-parser.ts#L125) | | `startDevServer` | Starts dev server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/dev-server/index.ts#L15) | | `startNodeVeryfrontServer` | Starts node veryfront server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/service-server.ts#L575) | | `startProductionServer` | Starts production server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L181) | @@ -77,7 +77,7 @@ await server.fetch(new Request("https://example.com/health")); | `DiscoveryOptions` | Configuration for AI primitives discovery during server startup | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L119) | | `FileWatcherMetrics` | Public API contract for file watcher metrics. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/dev-server/types.ts#L33) | | `GracefulProductionShutdownOptions` | Inputs required to drain and stop a production server process. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/graceful-shutdown.ts#L25) | -| `HostedEnvironmentName` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/utils/domain-parser.ts#L46) | +| `HostedEnvironmentName` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/utils/domain-parser.ts#L49) | | `NodeVeryfrontServiceServer` | Public API contract for node veryfront service server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/service-server.ts#L83) | | `RouteDirectory` | Public API contract for route directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/dev-server/types.ts#L27) | | `ServerHandle` | Public API contract for server handle. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L156) | diff --git a/src/cache/backends/factory.ts b/src/cache/backends/factory.ts index 78dc616f9d..1c1b8b0669 100644 --- a/src/cache/backends/factory.ts +++ b/src/cache/backends/factory.ts @@ -35,7 +35,7 @@ export function isApiCacheAvailable(): boolean { const isProduction = proxyMode === "1" || nodeEnv === "production" || - !!(apiUrl && !apiUrl.includes("localhost") && !apiUrl.includes("lvh.me")); + !!(apiUrl && !apiUrl.includes("localhost")); return isProduction && !!apiUrl; } diff --git a/src/proxy/handler.test.ts b/src/proxy/handler.test.ts index 5afda9242b..b026b850b4 100644 --- a/src/proxy/handler.test.ts +++ b/src/proxy/handler.test.ts @@ -1662,11 +1662,9 @@ describe("Proxy Handler", () => { for ( const host of [ - "lvh.me", "localhost", - "veryfront.dev", - "preview.lvh.me", - "staging.lvh.me", + "preview.localhost", + "staging.localhost", ] ) { const ctx = await handler.processRequest( @@ -2085,7 +2083,7 @@ describe("Proxy Handler", () => { }, }); - const req = new Request("http://my-project.preview.lvh.me:3001/page"); + const req = new Request("http://my-project.preview.localhost:3001/page"); const ctx = await handler.processRequest(req); diff --git a/src/proxy/handler.ts b/src/proxy/handler.ts index a1a239ebe1..0d55b2b3e0 100644 --- a/src/proxy/handler.ts +++ b/src/proxy/handler.ts @@ -826,7 +826,7 @@ export function createProxyHandler(options: ProxyHandlerOptions) { // configuration gap reported as an upstream failure. A custom domain in // that state already answers 404. // - // Locally the same shape means something else: on lvh.me and friends a + // Locally the same shape means something else: on localhost a // project-less host is how the project chooser is reached, so those keep // forwarding. See ProjectsHandler, enabled for exactly this state. if (isHostedVeryfrontDomain(host)) { diff --git a/src/proxy/mode-parity.test.ts b/src/proxy/mode-parity.test.ts index 307d3e57cc..dbcd1f9bab 100644 --- a/src/proxy/mode-parity.test.ts +++ b/src/proxy/mode-parity.test.ts @@ -110,7 +110,7 @@ describe("Proxy-Renderer Mode Parity", () => { environment: "preview", contentSourceId: "local-main", localPath: "/Users/dev/projects/local-project", - host: "local-project.lvh.me:8080", + host: "local-project.localhost:8080", parsedDomain: { slug: "local-project", isVeryfrontDomain: true, @@ -123,7 +123,7 @@ describe("Proxy-Renderer Mode Parity", () => { }; const injected = injectContextHeaders( - new Request("http://local-project.lvh.me:8080/page"), + new Request("http://local-project.localhost:8080/page"), ctx, ); diff --git a/src/security/README.md b/src/security/README.md index fced0e14da..baa7587a85 100644 --- a/src/security/README.md +++ b/src/security/README.md @@ -82,7 +82,7 @@ the trusted shell. When Fetch Metadata is present, only `sec-fetch-site: none` (address bar or bookmark navigation) and `same-origin` requests pass. A link from a rendered -project site such as `project.lvh.me:3000` to `lvh.me:3000/_dev` is same-site +project site such as `project.localhost:3000` to `localhost:3000/_dev` is same-site but cross-origin and is rejected with `403` by design: sibling local origins execute untrusted project code and must not be able to drive any privileged local control. Open the dashboard directly instead; this is intended behavior, diff --git a/src/security/http/local-control-request.test.ts b/src/security/http/local-control-request.test.ts index fb8ff0cef5..20748c117d 100644 --- a/src/security/http/local-control-request.test.ts +++ b/src/security/http/local-control-request.test.ts @@ -119,9 +119,6 @@ describe("local control request admission", () => { "http://[::ffff:7f00:1]:3000/_dev", "http://project.localhost:3000/_dev", "http://project.preview.localhost:3000/_dev", - "http://lvh.me:3000/_dev", - "http://project.lvh.me:3000/_dev", - "http://project.preview.lvh.me:3000/_dev", ] ) { const parsed = new URL(url); @@ -136,19 +133,11 @@ describe("local control request admission", () => { for ( const url of [ - "http://lvh.me.attacker.example:3000/_dev", - "http://veryfront.dev:3000/_dev", - "http://project.veryfront.dev:3000/_dev", - "http://production.lvh.me:3000/_dev", - "http://project.production.lvh.me:3000/_dev", - "http://project.staging.lvh.me:3000/_dev", - "http://example.com.prod.lvh.me:3000/_dev", - "http://project.unknown.lvh.me:3000/_dev", // `localhost` is a single label, so it has no registrable domain to key - // the shape check on. It still gets the same shape check as the - // two-label roots: moving the printed dev hostname onto it must not - // promote production, staging, custom-domain simulation, unknown - // namespaces, or arbitrarily deep names into control authorities. + // the shape check on. It still gets the same shape check the two-label + // roots got: the printed dev hostname living on it must not promote + // production, staging, custom-domain simulation, unknown namespaces, or + // arbitrarily deep names into control authorities. "http://production.localhost:3000/_dev", "http://staging.localhost:3000/_dev", "http://project.production.localhost:3000/_dev", @@ -158,6 +147,12 @@ describe("local control request admission", () => { "http://a.b.c.localhost:3000/_dev", "http://localhost.attacker.example:3000/_dev", "http://attacker.example:3000/_dev", + // Public wildcard-DNS roots that resolve to 127.0.0.1 are ordinary + // registrable domains, never control authorities. `localhost` is the + // only named root; anything else is denied on the name alone. + "http://wildcard-dns.example:3000/_dev", + "http://project.wildcard-dns.example:3000/_dev", + "http://project.preview.wildcard-dns.example:3000/_dev", ] ) { const parsed = new URL(url); diff --git a/src/security/http/local-control-request.ts b/src/security/http/local-control-request.ts index d06bf40b60..3ffbcd7e37 100644 --- a/src/security/http/local-control-request.ts +++ b/src/security/http/local-control-request.ts @@ -84,23 +84,21 @@ function isCanonicalDnsHostname(hostname: string): boolean { } /** - * Trusted local-control roots, longest-suffix-first. + * The single trusted local-control root. * * `localhost` is a single label and therefore has no registrable domain in the * eTLD+1 sense, so the shape check cannot be expressed as "keep the last two - * labels". Each root is matched as a whole suffix instead and the labels in + * labels". The root is matched as a whole suffix instead and the labels in * front of it are what the shape rules below constrain. */ -const TRUSTED_LOCAL_CONTROL_ROOTS = Object.freeze(["localhost", "lvh.me"] as const); +const TRUSTED_LOCAL_CONTROL_ROOT = "localhost"; -/** Labels in front of a trusted root, or null when the host is not on one. */ +/** Labels in front of the trusted root, or null when the host is not on it. */ function localControlSubLabels(hostname: string): string[] | null { - for (const root of TRUSTED_LOCAL_CONTROL_ROOTS) { - if (hostname === root) return []; - const suffix = `.${root}`; - if (hostname.endsWith(suffix)) { - return hostname.slice(0, -suffix.length).split("."); - } + if (hostname === TRUSTED_LOCAL_CONTROL_ROOT) return []; + const suffix = `.${TRUSTED_LOCAL_CONTROL_ROOT}`; + if (hostname.endsWith(suffix)) { + return hostname.slice(0, -suffix.length).split("."); } return null; } @@ -129,19 +127,18 @@ function hasTrustedFetchSite(request: Request): boolean { * Dedicated authority allowlist for privileged local controls. * * `localhost` is reserved by RFC 6761, never leaves the machine, and is the - * hostname printed by the local CLI. Veryfront admits `lvh.me` because the - * documented local-development workflow reaches projects through it; the hostname alone never grants - * access because `isTrustedLocalControlRequest` still requires an - * authenticated loopback transport peer and no proxy hop. Other third-party - * wildcard DNS and development test domains are not control authorities even - * when normal application routing accepts them. Named roots admit only the - * bare host, one project label, or one project below `preview`; production, - * staging, custom-domain simulation, and unknown namespaces stay denied. + * hostname printed by the local CLI. It is the only named root; the hostname + * alone never grants access because `isTrustedLocalControlRequest` still + * requires an authenticated loopback transport peer and no proxy hop. + * Third-party wildcard DNS and public development domains that resolve to + * 127.0.0.1 are not control authorities even when normal application routing + * accepts them. The named root admits only the bare host, + * one project label, or one project below `preview`; production, staging, + * custom-domain simulation, and unknown namespaces stay denied. * * `*.localhost` gets that same shape check rather than a blanket allow. Trust - * must not widen just because the printed dev hostname became a single-label - * root: `project.production.localhost` and `a.b.c.localhost` are denied exactly - * as `project.production.lvh.me` is. + * must not widen just because the printed dev hostname is a single-label root: + * `project.production.localhost` and `a.b.c.localhost` are denied. */ export function isTrustedLocalControlHostname(hostname: string): boolean { const address = hostname.startsWith("[") && hostname.endsWith("]") diff --git a/src/security/http/response/security-handler.ts b/src/security/http/response/security-handler.ts index bd60d0fff5..d920b0c656 100644 --- a/src/security/http/response/security-handler.ts +++ b/src/security/http/response/security-handler.ts @@ -84,8 +84,8 @@ export function generateNonce(): string { * are intentionally excluded because tenant project domains * (`{slug}.preview.veryfront.com`, etc.) live under the same suffix and * would otherwise be allowed to iframe each other (tenant-vs-tenant - * clickjacking). Dev hosts (`veryfront.dev`) are omitted because dev mode - * skips the default CSP entirely. + * clickjacking). Local development hosts (`*.localhost`) are omitted because + * dev mode skips the default CSP entirely. */ const VERYFRONT_FRAME_ANCESTORS = ["'self'", ...HOSTED_STUDIO_ORIGINS]; diff --git a/src/security/http/studio-origin-policy.test.ts b/src/security/http/studio-origin-policy.test.ts index a74f3ca7cb..460de39e75 100644 --- a/src/security/http/studio-origin-policy.test.ts +++ b/src/security/http/studio-origin-policy.test.ts @@ -18,7 +18,6 @@ describe("security/http/studio-origin-policy", () => { // studio.* subdomains are not deployed and are no longer trusted origins. assertEquals(resolveTrustedStudioOrigin("https://studio.veryfront.com"), null); assertEquals(resolveTrustedStudioOrigin("https://studio.veryfront.org"), null); - assertEquals(resolveTrustedStudioOrigin("https://studio.veryfront.dev"), null); assertEquals(resolveTrustedStudioOrigin("http://studio.veryfront.com"), null); assertEquals(resolveTrustedStudioOrigin("https://studio.veryfront.com:8443"), null); }); @@ -35,7 +34,6 @@ describe("security/http/studio-origin-policy", () => { assertEquals(source.includes('"https://veryfront.com"'), true); assertEquals(source.includes('"https://studio.veryfront.com"'), false); assertEquals(source.includes("endsWith"), false); - assertEquals(source.includes(".veryfront.dev"), false); const resolveTarget = new Function( "document", diff --git a/src/security/sandbox/worker-script.test.ts b/src/security/sandbox/worker-script.test.ts index a5269b8993..84c4fc6014 100644 --- a/src/security/sandbox/worker-script.test.ts +++ b/src/security/sandbox/worker-script.test.ts @@ -180,7 +180,7 @@ describe("worker-script serializeError", () => { it("does not trust RFC 9457 fields attached to a plain project error", () => { const err = Object.assign(new Error("not found"), { - type: "https://veryfront.dev/errors/not-found", + type: "https://veryfront.com/errors/not-found", status: 404, detail: "Resource was not located", }); diff --git a/src/server/context/request-context.test.ts b/src/server/context/request-context.test.ts index a0c8959a81..d17e3ece46 100644 --- a/src/server/context/request-context.test.ts +++ b/src/server/context/request-context.test.ts @@ -164,7 +164,7 @@ describe("createRequestContext", () => { it("ignores x-forwarded-host by default (untrusted) and uses the host header", () => { const req = makeRequest("https://127.0.0.1/page", { "x-forwarded-host": "evil.preview.veryfront.com", - host: "my-app.lvh.me", + host: "my-app.localhost", }); const ctx = createRequestContext(req); // Untrusted default: a client-supplied x-forwarded-host must not flip mode @@ -177,8 +177,8 @@ describe("createRequestContext", () => { Deno.env.set("VERYFRONT_TRUST_FORWARDED_HEADERS", "1"); try { const req = makeRequest("https://127.0.0.1/page", { - "x-forwarded-host": "my-app.preview.lvh.me", - host: "other.lvh.me", + "x-forwarded-host": "my-app.preview.localhost", + host: "other.localhost", }); const ctx = createRequestContext(req); assertEquals(ctx.slug, "my-app"); @@ -190,8 +190,8 @@ describe("createRequestContext", () => { it("honours x-forwarded-host after request-scoped proxy verification", () => { const req = makeRequest("https://127.0.0.1/page", { - "x-forwarded-host": "my-app.preview.lvh.me", - host: "other.lvh.me", + "x-forwarded-host": "my-app.preview.localhost", + host: "other.localhost", }); const ctx = createRequestContext(req, { proxyTrusted: true }); assertEquals(ctx.slug, "my-app"); @@ -200,7 +200,7 @@ describe("createRequestContext", () => { it("host header takes priority over URL hostname", () => { const req = makeRequest("https://127.0.0.1/page", { - host: "my-app.lvh.me", + host: "my-app.localhost", }); const ctx = createRequestContext(req); assertEquals(ctx.slug, "my-app"); @@ -209,7 +209,7 @@ describe("createRequestContext", () => { it("falls back to URL hostname when no host headers", () => { // Deno's Request does not auto-set a host header, so hostname from URL is used - const req = makeRequest("https://my-app.lvh.me/page"); + const req = makeRequest("https://my-app.localhost/page"); assertEquals(req.headers.get("host"), null); const ctx = createRequestContext(req); assertEquals(ctx.slug, "my-app"); @@ -237,7 +237,7 @@ describe("createRequestContext", () => { it("x-project-slug takes priority over domain-parsed slug", () => { const req = makeRequest("https://127.0.0.1/page", { - host: "my-app.lvh.me", + host: "my-app.localhost", "x-project-slug": "override-slug", }); const ctx = createRequestContext(req); @@ -246,7 +246,7 @@ describe("createRequestContext", () => { it("falls back to the parsed domain slug when x-project-slug is blank", () => { const req = makeRequest("https://127.0.0.1/page", { - host: "my-app.preview.lvh.me", + host: "my-app.preview.localhost", "x-project-slug": " ", }); const ctx = createRequestContext(req); @@ -255,7 +255,7 @@ describe("createRequestContext", () => { it("falls back to the parsed domain slug when x-project-slug is empty string", () => { const req = makeRequest("https://127.0.0.1/page", { - host: "my-app.preview.lvh.me", + host: "my-app.preview.localhost", "x-project-slug": "", }); const ctx = createRequestContext(req); @@ -266,7 +266,7 @@ describe("createRequestContext", () => { Deno.env.set("VERYFRONT_TRUST_FORWARDED_HEADERS", "1"); try { const req = makeRequest("https://127.0.0.1/page", { - "x-forwarded-host": "my-app.preview.lvh.me, proxy2.internal", + "x-forwarded-host": "my-app.preview.localhost, proxy2.internal", }); const ctx = createRequestContext(req); assertEquals(ctx.slug, "my-app"); @@ -319,7 +319,7 @@ describe("createRequestContext", () => { it("returns null branch when no branch in domain", () => { const req = makeRequest("https://127.0.0.1/", { - host: "my-app.lvh.me", + host: "my-app.localhost", }); const ctx = createRequestContext(req); assertEquals(ctx.branch, null); diff --git a/src/server/dev-server/error-overlay/html-template.test.ts b/src/server/dev-server/error-overlay/html-template.test.ts index cac6df9473..e766b7cce1 100644 --- a/src/server/dev-server/error-overlay/html-template.test.ts +++ b/src/server/dev-server/error-overlay/html-template.test.ts @@ -57,7 +57,6 @@ describe("server/dev-server/error-overlay/html-template", () => { assertEquals(script.includes('"https://veryfront.org"'), true); assertEquals(script.includes('"https://studio.veryfront.com"'), false); assertEquals(script.includes("endsWith"), false); - assertEquals(script.includes(".veryfront.dev"), false); assertEquals(script.includes("return window.location.origin"), true); }); }); @@ -238,7 +237,6 @@ describe("server/dev-server/error-overlay/html-template", () => { assertEquals(html.includes('"https://veryfront.org"'), true); assertEquals(html.includes('"https://studio.veryfront.com"'), false); assertEquals(html.includes("endsWith"), false); - assertEquals(html.includes(".veryfront.dev"), false); assertEquals(html.includes("return window.location.origin"), true); }); diff --git a/src/server/handlers/dev/dashboard/access-policy.test.ts b/src/server/handlers/dev/dashboard/access-policy.test.ts index 3d39b26315..af129c9306 100644 --- a/src/server/handlers/dev/dashboard/access-policy.test.ts +++ b/src/server/handlers/dev/dashboard/access-policy.test.ts @@ -36,9 +36,6 @@ describe("dashboard access policy", () => { "http://[::1]:8000/_dev/ui/index.js", "http://my-project.localhost:8000/_dev", "http://my-project.preview.localhost:8000/_dev/ui/index.js", - "http://lvh.me:8000/_dev", - "http://my-project.lvh.me:8000/_dev/api/stats", - "http://my-project.preview.lvh.me:8000/_dev/ui/index.js", ] ) { assertEquals(isTrustedDashboardRequest(dashboardRequest(url)), true, url); @@ -56,15 +53,12 @@ describe("dashboard access policy", () => { "http://my-project.unknown.localhost:8000/_dev", "http://example.com.prod.localhost:8000/_dev", "http://a.b.c.localhost:8000/_dev", - "http://production.lvh.me:8000/_dev", - "http://my-project.production.lvh.me:8000/_dev", - "http://my-project.staging.lvh.me:8000/_dev", - "http://example.com.prod.lvh.me:8000/_dev", - "http://my-project.unknown.lvh.me:8000/_dev", "http://localhost.attacker.example:8000/_dev", - "http://lvh.me.attacker.example:8000/_dev", - "http://veryfront.dev:8000/_dev", - "http://my-project.veryfront.dev:8000/_dev", + // Public wildcard-DNS roots resolving to 127.0.0.1 are ordinary + // registrable domains, not dashboard authorities. + "http://wildcard-dns.example:8000/_dev", + "http://my-project.wildcard-dns.example:8000/_dev", + "http://my-project.preview.wildcard-dns.example:8000/_dev", ] ) { assertEquals(isTrustedDashboardRequest(dashboardRequest(url)), false, url); diff --git a/src/server/handlers/dev/local-control-admission.test.ts b/src/server/handlers/dev/local-control-admission.test.ts index 8dcf243e42..3fb0f482f8 100644 --- a/src/server/handlers/dev/local-control-admission.test.ts +++ b/src/server/handlers/dev/local-control-admission.test.ts @@ -98,7 +98,7 @@ describe("privileged local-control admission", () => { control, createControlRequest(control, { peer: "192.0.2.10", - hostname: "project.lvh.me", + hostname: "project.localhost", }), ); }); diff --git a/src/server/handlers/dev/projects/api.ts b/src/server/handlers/dev/projects/api.ts index 5052f5756a..e31eba1956 100644 --- a/src/server/handlers/dev/projects/api.ts +++ b/src/server/handlers/dev/projects/api.ts @@ -13,9 +13,9 @@ export function handleProjectsAPI(req: Request, ctx: HandlerContext): Response | function handleGetConfig(req: Request, ctx: HandlerContext): Response { const url = new URL(req.url); - const host = getEffectiveRequestHost(req, url) || "lvh.me"; + const host = getEffectiveRequestHost(req, url) || "localhost"; - const hostWithoutPort = host.replace(/:\d+$/, "") || "lvh.me"; + const hostWithoutPort = host.replace(/:\d+$/, "") || "localhost"; const port = host.includes(":") ? host.split(":")[1] ?? "" : ""; return jsonResponse({ diff --git a/src/server/handlers/dev/scripts/hmr-scripts.test.ts b/src/server/handlers/dev/scripts/hmr-scripts.test.ts index 254a2c91b9..28de5276e8 100644 --- a/src/server/handlers/dev/scripts/hmr-scripts.test.ts +++ b/src/server/handlers/dev/scripts/hmr-scripts.test.ts @@ -143,7 +143,6 @@ describe("server/handlers/dev/scripts/hmr-scripts", () => { assertStringIncludes(script, '"https://veryfront.com"'); assertEquals(script.includes('"https://studio.veryfront.com"'), false); assertEquals(script.includes("endsWith('.veryfront"), false); - assertEquals(script.includes(".veryfront.dev"), false); assertEquals(script.includes("}, '*')"), false); }); }); diff --git a/src/server/runtime-handler/project-resolution.test.ts b/src/server/runtime-handler/project-resolution.test.ts index 20595e5959..781d7f7e27 100644 --- a/src/server/runtime-handler/project-resolution.test.ts +++ b/src/server/runtime-handler/project-resolution.test.ts @@ -137,7 +137,7 @@ describe("server/runtime-handler/project-resolution", () => { const req = new Request("http://127.0.0.1:3001/", { headers: { "x-environment": "preview", - "x-forwarded-host": "my-project.preview.lvh.me", + "x-forwarded-host": "my-project.preview.localhost", }, }); const headers = extractRequestHeaders(req, new URL(req.url), true); @@ -195,7 +195,7 @@ describe("server/runtime-handler/project-resolution", () => { Deno.env.set("VERYFRONT_TRUST_FORWARDED_HEADERS", "1"); try { const req = new Request("http://127.0.0.1:3001/", { - headers: { "x-forwarded-host": "my-project.preview.lvh.me" }, + headers: { "x-forwarded-host": "my-project.preview.localhost" }, }); const headers = extractRequestHeaders(req, new URL(req.url)); assertEquals(headers.projectSlug, "my-project"); @@ -209,7 +209,7 @@ describe("server/runtime-handler/project-resolution", () => { try { const req = new Request("http://127.0.0.1:3001/", { headers: { - "x-forwarded-host": "my-project.preview.lvh.me", + "x-forwarded-host": "my-project.preview.localhost", "x-project-slug": " ", }, }); @@ -225,7 +225,7 @@ describe("server/runtime-handler/project-resolution", () => { try { const req = new Request("http://127.0.0.1:3001/", { headers: { - "x-forwarded-host": "my-project.preview.lvh.me", + "x-forwarded-host": "my-project.preview.localhost", "x-project-slug": "", }, }); @@ -241,7 +241,7 @@ describe("server/runtime-handler/project-resolution", () => { try { const req = new Request("http://127.0.0.1:3001/", { headers: { - "x-forwarded-host": "my-project.preview.lvh.me, proxy2.internal", + "x-forwarded-host": "my-project.preview.localhost, proxy2.internal", }, }); const headers = extractRequestHeaders(req, new URL(req.url)); @@ -256,7 +256,7 @@ describe("server/runtime-handler/project-resolution", () => { // the project slug via x-forwarded-host; resolution falls back to the Host // header (127.0.0.1 here), which does not parse to the spoofed slug. const req = new Request("http://127.0.0.1:3001/", { - headers: { "x-forwarded-host": "my-project.preview.lvh.me" }, + headers: { "x-forwarded-host": "my-project.preview.localhost" }, }); const headers = extractRequestHeaders(req, new URL(req.url)); assertEquals(headers.projectSlug !== "my-project", true); @@ -632,7 +632,7 @@ describe("server/runtime-handler/project-resolution", () => { getEnvironmentType: () => undefined, }); - const req = new Request("http://my-project.preview.veryfront.dev/"); + const req = new Request("http://my-project.preview.localhost/"); const url = new URL(req.url); const headers = extractRequestHeaders(req, url); const result = await resolveProject(req, url, headers, { diff --git a/src/server/runtime-handler/project-runtime-context.test.ts b/src/server/runtime-handler/project-runtime-context.test.ts index 93168539be..330cab0c81 100644 --- a/src/server/runtime-handler/project-runtime-context.test.ts +++ b/src/server/runtime-handler/project-runtime-context.test.ts @@ -134,7 +134,7 @@ function createHostedConfigAdapter(source: string): RuntimeAdapter { function makeRuntimeContextInput( overrides: Record = {}, ): Parameters[0] { - const req = new Request("http://remote-project.preview.lvh.me/page", { + const req = new Request("http://remote-project.preview.localhost/page", { headers: { "x-project-slug": "remote-project", "x-project-id": "proj-remote", @@ -210,7 +210,7 @@ describe("prepareProjectRequest", () => { const req = new Request("http://localhost/page", { headers: { host: "localhost", - "x-forwarded-host": "forwarded-project.preview.lvh.me", + "x-forwarded-host": "forwarded-project.preview.localhost", "x-project-slug": "header-project", "x-token": "proxy-token", "x-release-id": "rel_123", @@ -574,7 +574,7 @@ describe("resolveProjectIdentity", () => { it("derives identity from forwarded host only when proxy trust is explicit true", async () => { const req = new Request("http://localhost/", { - headers: { "x-forwarded-host": "forwarded-project.preview.lvh.me" }, + headers: { "x-forwarded-host": "forwarded-project.preview.localhost" }, }); const url = new URL(req.url); @@ -1070,7 +1070,7 @@ describe("resolveProjectRuntimeContext", () => { }, })); `); - const req = new Request(`http://${projectSlug}.preview.lvh.me/page`, { + const req = new Request(`http://${projectSlug}.preview.localhost/page`, { headers: { "x-project-slug": projectSlug, "x-project-id": projectId, diff --git a/src/server/utils/domain-parser.test.ts b/src/server/utils/domain-parser.test.ts index 136923d59b..775bed97a5 100644 --- a/src/server/utils/domain-parser.test.ts +++ b/src/server/utils/domain-parser.test.ts @@ -59,49 +59,22 @@ describe("domain-parser", () => { assertEquals(result.isDraft, true); }); - it("lvh.me preview", () => { - const result = parseProjectDomain("myproject.preview.lvh.me:3001"); - assertEquals(result.slug, "myproject"); - assertEquals(result.branch, null); - assertEquals(result.environment, "preview"); - assertEquals(result.isVeryfrontDomain, true); - assertEquals(result.isDraft, true); - }); - - it("lvh.me preview with branch", () => { - const result = parseProjectDomain("myproject--feature-x.preview.lvh.me"); - assertEquals(result.slug, "myproject"); - assertEquals(result.branch, "feature-x"); - assertEquals(result.environment, "preview"); - }); - - it("lvh.me base (mirrors production)", () => { - const result = parseProjectDomain("myproject.lvh.me:3001"); - assertEquals(result.slug, "myproject"); - assertEquals(result.environment, "production"); - assertEquals(result.isDraft, false); - }); - - it("lvh.me prod (custom domain simulation)", () => { - const result = parseProjectDomain("example.com.prod.lvh.me"); - assertEquals(result.slug, null); - assertEquals(result.environment, "production"); - assertEquals(result.isVeryfrontDomain, false); - }); - - it("plain lvh.me", () => { - const result = parseProjectDomain("lvh.me"); - assertEquals(result.slug, null); - assertEquals(result.environment, "development"); - assertEquals(result.isVeryfrontDomain, true); - }); - - it("local preview environment root (lvh.me)", () => { - const result = parseProjectDomain("preview.lvh.me"); - assertEquals(result.slug, null); - assertEquals(result.environment, "preview"); - assertEquals(result.isVeryfrontDomain, true); - assertEquals(result.isDraft, true); + it("public loopback-resolving wildcard DNS roots are not local dev domains", () => { + // `localhost` is the only local dev root. Public names that happen to + // resolve to 127.0.0.1 are ordinary registrable domains and must fall + // through to the custom-domain path. + for ( + const host of [ + "wildcard-dns.example", + "myproject.wildcard-dns.example", + "myproject.preview.wildcard-dns.example", + ] + ) { + const result = parseProjectDomain(host); + assertEquals(result.slug, null, host); + assertEquals(result.environment, null, host); + assertEquals(result.isVeryfrontDomain, false, host); + } }); it("veryfront.com preview", () => { @@ -138,14 +111,6 @@ describe("domain-parser", () => { assertEquals(result.isVeryfrontDomain, false); }); - it("local dev explicit production: {slug}.production.lvh.me", () => { - const result = parseProjectDomain("myproject.production.lvh.me:3001"); - assertEquals(result.slug, "myproject"); - assertEquals(result.environment, "production"); - assertEquals(result.isVeryfrontDomain, true); - assertEquals(result.isDraft, false); - }); - it("local dev explicit production: {slug}.production.localhost", () => { const result = parseProjectDomain("myproject.production.localhost:8080"); assertEquals(result.slug, "myproject"); @@ -169,22 +134,6 @@ describe("domain-parser", () => { assertEquals(result.isVeryfrontDomain, true); }); - it("local staging environment root (lvh.me)", () => { - const result = parseProjectDomain("staging.lvh.me"); - assertEquals(result.slug, null); - assertEquals(result.environment, "staging"); - assertEquals(result.isVeryfrontDomain, true); - assertEquals(result.isDraft, false); - }); - - it("local production environment root (lvh.me)", () => { - const result = parseProjectDomain("production.lvh.me"); - assertEquals(result.slug, null); - assertEquals(result.environment, "production"); - assertEquals(result.isVeryfrontDomain, true); - assertEquals(result.isDraft, false); - }); - it("local staging environment root (localhost)", () => { const result = parseProjectDomain("staging.localhost"); assertEquals(result.slug, null); @@ -232,7 +181,7 @@ describe("domain-parser", () => { }); it("handles mixed case domains", () => { - const result = parseProjectDomain("MyProject.preview.lvh.me"); + const result = parseProjectDomain("MyProject.preview.localhost"); assertEquals(result.slug, "MyProject"); assertEquals(result.environment, "preview"); }); @@ -249,19 +198,19 @@ describe("domain-parser", () => { assertEquals(isVeryfrontDomain("localhost"), true); }); - it("recognizes lvh.me", () => { - assertEquals(isVeryfrontDomain("myproject.lvh.me:3001"), true); - assertEquals(isVeryfrontDomain("lvh.me"), true); - }); - it("rejects custom domains", () => { assertEquals(isVeryfrontDomain("example.com"), false); }); + + it("rejects public loopback-resolving wildcard DNS roots", () => { + assertEquals(isVeryfrontDomain("wildcard-dns.example"), false); + assertEquals(isVeryfrontDomain("myproject.wildcard-dns.example:3001"), false); + }); }); describe("getEffectiveProjectSlug", () => { it("prefers slug from host", () => { - const result = getEffectiveProjectSlug("myproject.preview.lvh.me", "default"); + const result = getEffectiveProjectSlug("myproject.preview.localhost", "default"); assertEquals(result.slug, "myproject"); assertEquals(result.fromHost, true); }); @@ -283,8 +232,8 @@ describe("domain-parser", () => { assertEquals(result.isVeryfrontDomain, true); }); - it("extracts branch from lvh.me preview URL", () => { - const result = parseProjectDomain("myproject--feature-branch.preview.lvh.me:8080"); + it("extracts branch from localhost preview URL", () => { + const result = parseProjectDomain("myproject--feature-branch.preview.localhost:8080"); assertEquals(result.slug, "myproject"); assertEquals(result.branch, "feature-branch"); assertEquals(result.environment, "preview"); @@ -305,7 +254,7 @@ describe("domain-parser", () => { }); it("handles branch from base domain (mirrors production)", () => { - const result = parseProjectDomain("myproject--experiment.lvh.me:3001"); + const result = parseProjectDomain("myproject--experiment.localhost:3001"); assertEquals(result.slug, "myproject"); assertEquals(result.branch, "experiment"); assertEquals(result.environment, "production"); @@ -328,55 +277,60 @@ describe("domain-parser", () => { assertEquals(isLocalDevHost("myproject.localhost:3000"), true); }); - it("recognizes bare local dev domains", () => { + it("recognizes the bare local dev root", () => { assertEquals(isLocalDevHost("localhost"), true); - assertEquals(isLocalDevHost("lvh.me"), true); - assertEquals(isLocalDevHost("veryfront.dev"), true); assertEquals(isLocalDevHost("localhost:8080"), true); }); it("recognizes slug-only local dev domains", () => { assertEquals(isLocalDevHost("myproject.localhost"), true); - assertEquals(isLocalDevHost("myproject.lvh.me:3001"), true); - assertEquals(isLocalDevHost("myproject.veryfront.dev"), true); + assertEquals(isLocalDevHost("myproject.localhost:3001"), true); }); it("recognizes preview local dev domains", () => { assertEquals(isLocalDevHost("myproject.preview.localhost"), true); - assertEquals(isLocalDevHost("myproject.preview.lvh.me:3001"), true); + assertEquals(isLocalDevHost("myproject.preview.localhost:3001"), true); assertEquals(isLocalDevHost("preview.localhost"), true); - assertEquals(isLocalDevHost("preview.lvh.me"), true); }); it("rejects explicit production local dev domains", () => { assertEquals(isLocalDevHost("myproject.production.localhost"), false); - assertEquals(isLocalDevHost("myproject.production.lvh.me"), false); }); it("rejects explicit staging local dev domains", () => { assertEquals(isLocalDevHost("myproject.staging.localhost"), false); - assertEquals(isLocalDevHost("myproject.staging.lvh.me"), false); }); it("rejects custom domain simulation", () => { - assertEquals(isLocalDevHost("example.com.prod.lvh.me"), false); assertEquals(isLocalDevHost("example.com.prod.localhost"), false); }); // `localhost` is a single label with no registrable domain, so `*.localhost` - // cannot be admitted by a blanket suffix test the way a two-label root can. + // cannot be admitted by a blanket suffix test the way a two-label root could. // It goes through the same parse, which keeps the non-dev namespaces out. - it("classifies *.localhost by the same rules as the two-label local roots", () => { - for (const root of ["localhost", "lvh.me"]) { - assertEquals(isLocalDevHost(`myproject.production.${root}:3000`), false, root); - assertEquals(isLocalDevHost(`myproject.staging.${root}:3000`), false, root); - assertEquals(isLocalDevHost(`staging.${root}`), false, root); - assertEquals(isLocalDevHost(`myproject.foobar.${root}`), false, root); - assertEquals(isLocalDevHost(`example.com.prod.${root}`), false, root); - assertEquals(isLocalDevHost(`a.b.c.${root}`), false, root); - assertEquals(isLocalDevHost(`myproject.${root}`), true, root); - assertEquals(isLocalDevHost(`myproject.preview.${root}`), true, root); - } + it("classifies *.localhost by parse, not by a blanket suffix allow", () => { + assertEquals(isLocalDevHost("myproject.production.localhost:3000"), false); + assertEquals(isLocalDevHost("myproject.staging.localhost:3000"), false); + assertEquals(isLocalDevHost("staging.localhost"), false); + assertEquals(isLocalDevHost("myproject.foobar.localhost"), false); + assertEquals(isLocalDevHost("example.com.prod.localhost"), false); + assertEquals(isLocalDevHost("a.b.c.localhost"), false); + assertEquals(isLocalDevHost("myproject.localhost"), true); + assertEquals(isLocalDevHost("myproject.preview.localhost"), true); + }); + + it("rejects hosts that merely contain or prefix the local root", () => { + // A suffix test must anchor: `notlocalhost` and `localhost.attacker.example` + // both end with or start with the root's characters without being under it. + assertEquals(isLocalDevHost("notlocalhost"), false); + assertEquals(isLocalDevHost("localhost.attacker.example"), false); + assertEquals(isLocalDevHost("myproject.localhost.attacker.example"), false); + }); + + it("rejects public loopback-resolving wildcard DNS roots", () => { + assertEquals(isLocalDevHost("wildcard-dns.example"), false); + assertEquals(isLocalDevHost("myproject.wildcard-dns.example:3001"), false); + assertEquals(isLocalDevHost("myproject.preview.wildcard-dns.example"), false); }); it("rejects custom domains", () => { @@ -398,7 +352,6 @@ describe("domain-parser", () => { it("allows embed for veryfront domains", () => { assertEquals(parseProjectDomain("myproject.production.veryfront.com").allowIframeEmbed, true); assertEquals(parseProjectDomain("myproject.preview.veryfront.com").allowIframeEmbed, true); - assertEquals(parseProjectDomain("myproject.lvh.me").allowIframeEmbed, true); assertEquals(parseProjectDomain("myproject.localhost").allowIframeEmbed, true); }); @@ -418,7 +371,7 @@ describe("domain-parser", () => { }); it("disallows embed for prod custom domain simulation", () => { - assertEquals(parseProjectDomain("example.com.prod.lvh.me").allowIframeEmbed, false); + assertEquals(parseProjectDomain("example.com.prod.localhost").allowIframeEmbed, false); }); }); diff --git a/src/server/utils/domain-parser.ts b/src/server/utils/domain-parser.ts index d544b7b62e..5a8de281f7 100644 --- a/src/server/utils/domain-parser.ts +++ b/src/server/utils/domain-parser.ts @@ -10,13 +10,16 @@ export interface ParsedDomain { type Environment = ParsedDomain["environment"]; -// Local development domains (localhost preferred, lvh.me alternative, veryfront.dev for HTTPS testing) -// `localhost` is the hostname the CLI prints: {slug}.localhost URLs work and *.localhost is a -// W3C Secure Context, enabling navigator.mediaDevices / getUserMedia in WKWebView (Tauri) and -// all browsers. Unlike lvh.me and veryfront.dev it is a *single-label* root with no registrable -// domain, so every rule below matches it as a root in its own right rather than via an eTLD+1 -// style "last two labels" split. -const LOCAL_DEV_DOMAINS = "localhost|lvh\\.me|veryfront\\.dev"; +// Local development domain. `localhost` is the only one: it is the hostname the CLI prints, +// it is reserved by RFC 6761 so it never leaves the machine, and *.localhost is a W3C Secure +// Context, enabling navigator.mediaDevices / getUserMedia in WKWebView (Tauri) and all browsers. +// +// Public wildcard-DNS roots that resolve to 127.0.0.1 were removed deliberately: they are real +// DNS names, so DNS rebind protection blocks them and the local stack becomes unreachable with +// no actionable error. `localhost` is a *single-label* root with no registrable domain, so every +// rule below matches it as a root in its own right rather than via an eTLD+1 style +// "last two labels" split. +const LOCAL_DEV_DOMAINS = "localhost"; // Production domains const PROD_DOMAINS = "veryfront\\.com|veryfront\\.org"; @@ -34,7 +37,7 @@ const IFRAME_EMBED_DOMAINS = /^(localhost|.*\.xip\.io|.*\.zip\.io)$/i; * `404 {"error":"No project configured for domain: ..."}`. * * `development` is deliberately absent. It is a valid `ParsedDomain.environment` - * for *local* roots (`lvh.me`, `localhost`, `veryfront.dev`), where it means + * for the *local* root (`localhost`), where it means * "running on this machine". No hosted rule produces it, so a hosted * `{slug}.development.veryfront.com` resolves to no project. * @@ -122,11 +125,11 @@ function matchDomain(domain: string, pattern: string): RegExpMatchArray | null { export function parseProjectDomain(host: string): ParsedDomain { const domain = stripPort(host); - // Plain local dev domains without slug. + // Plain local dev domain without slug. // Bare `localhost` is checked here, ahead of IFRAME_EMBED_DOMAINS, so that it is a full - // veryfront local-dev root like bare lvh.me: a project-less local host is how the project - // chooser is reached, and it must not fall through to the custom-domain lookup path. - if (domain === "localhost" || domain === "veryfront.dev" || domain === "lvh.me") { + // veryfront local-dev root: a project-less local host is how the project chooser is + // reached, and it must not fall through to the custom-domain lookup path. + if (domain === "localhost") { return createParsedDomain(null, null, "development", true, true); } @@ -134,7 +137,7 @@ export function parseProjectDomain(host: string): ParsedDomain { return createParsedDomain(null, null, "development", false, true, true); } - // Local development preview: {slug}.preview.{lvh.me|veryfront.dev} + // Local development preview: {slug}.preview.localhost const localPreviewMatch = matchDomain( domain, `^([A-Za-z0-9-]+)\\.preview\\.(${LOCAL_DEV_DOMAINS})$`, @@ -144,7 +147,7 @@ export function parseProjectDomain(host: string): ParsedDomain { return createParsedDomain(slug, branch, "preview", true, true); } - // Local production testing: {custom-domain}.prod.{lvh.me|veryfront.dev} + // Local production testing: {custom-domain}.prod.localhost // Treated as custom domain for JIT production rendering const localProdMatch = matchDomain( domain, @@ -154,7 +157,7 @@ export function parseProjectDomain(host: string): ParsedDomain { return createParsedDomain(null, null, "production", false, false); } - // Local development explicit production: {slug}.production.{lvh.me|veryfront.dev} + // Local development explicit production: {slug}.production.localhost const localProductionMatch = matchDomain( domain, `^([A-Za-z0-9-]+)\\.production\\.(${LOCAL_DEV_DOMAINS})$`, @@ -163,7 +166,7 @@ export function parseProjectDomain(host: string): ParsedDomain { return createParsedDomain(localProductionMatch[1], null, "production", true, false); } - // Local development explicit staging: {slug}.staging.{lvh.me|veryfront.dev} + // Local development explicit staging: {slug}.staging.localhost const localStagingMatch = matchDomain( domain, `^([A-Za-z0-9-]+)\\.staging\\.(${LOCAL_DEV_DOMAINS})$`, @@ -172,7 +175,7 @@ export function parseProjectDomain(host: string): ParsedDomain { return createParsedDomain(localStagingMatch[1], null, "staging", true, false); } - // Local environment root domains (no slug): preview|staging|production.{lvh.me|veryfront.dev} + // Local environment root domains (no slug): preview|staging|production.localhost const localEnvRootMatch = matchDomain( domain, `^(${HOSTED_ENVIRONMENTS})\\.(${LOCAL_DEV_DOMAINS})$`, @@ -182,9 +185,9 @@ export function parseProjectDomain(host: string): ParsedDomain { return createParsedDomain(null, null, env, true, env === "preview"); } - // Local development base: {slug}.{lvh.me|veryfront.dev} + // Local development base: {slug}.localhost // Mirrors production behavior: serves released content (isDraft: false) - // Use {slug}.preview.lvh.me for draft content + // Use {slug}.preview.localhost for draft content const localBaseMatch = matchDomain(domain, `^([A-Za-z0-9-]+)\\.(${LOCAL_DEV_DOMAINS})$`); if (localBaseMatch?.[1]) { const { slug, branch } = parseSlugAndBranch(localBaseMatch[1]); @@ -246,28 +249,29 @@ export function isHostedVeryfrontDomain(host: string): boolean { } /** - * Check if a domain is a valid veryfront domain (includes localhost and lvh.me for local dev) + * Check if a domain is a valid veryfront domain (includes localhost for local dev) */ export function isVeryfrontDomain(host: string): boolean { const domain = stripPort(host); - if (domain === "localhost" || domain === "veryfront.dev" || domain === "lvh.me") return true; + if (domain === "localhost") return true; return new RegExp(`^[a-zA-Z0-9-]+(\\.[a-zA-Z0-9-]+)*\\.(${ALL_DOMAINS})$`).test(domain); } /** * Check if a host is a local development host where HMR connections should be allowed. - * Recognises localhost, 127.0.0.1, 0.0.0.0, *.localhost, and local dev domains - * (lvh.me, veryfront.dev) — but excludes explicit production/staging - * subdomains ({slug}.production.{local}, {slug}.staging.{local}) since those are - * used for testing non-dev behaviour locally. + * Recognises localhost, 127.0.0.1, 0.0.0.0, and *.localhost — but excludes explicit + * production/staging subdomains ({slug}.production.localhost, + * {slug}.staging.localhost) since those are used for testing non-dev behaviour locally. * * `*.localhost` is deliberately NOT a blanket allow. It is classified by the same - * `parseProjectDomain` rules as the two-label local roots, so - * `{slug}.production.localhost` and unknown namespaces such as `{slug}.foobar.localhost` - * stay excluded exactly as their lvh.me counterparts do. A single-label root would - * otherwise widen HMR admission the moment the printed dev hostname moved to localhost. + * `parseProjectDomain` rules, so `{slug}.production.localhost` and unknown namespaces + * such as `{slug}.foobar.localhost` stay excluded. `localhost` being a single-label root + * must not widen HMR admission relative to the two-label roots it replaced. + * + * `host` may carry a port; it is stripped before matching, so `app.localhost:3000` + * is classified as `app.localhost`. */ export function isLocalDevHost(host: string): boolean { const domain = stripPort(host).toLowerCase(); @@ -275,11 +279,9 @@ export function isLocalDevHost(host: string): boolean { // Standard loopback / bind-all addresses if (domain === "localhost" || domain === "127.0.0.1" || domain === "0.0.0.0") return true; - // Must be on a local dev root — production domains (veryfront.com/org) are not dev hosts. - // `localhost` is a single-label root; the others are two-label registrable domains. - const isLocalTLD = /\.(localhost|lvh\.me|veryfront\.dev)$/i.test(domain) || - /^(lvh\.me|veryfront\.dev)$/i.test(domain); - if (!isLocalTLD) return false; + // Must be under the local dev root — production domains (veryfront.com/org) are not dev + // hosts. `localhost` is a single-label root, matched here as a whole suffix. + if (!/\.localhost$/i.test(domain)) return false; const parsed = parseProjectDomain(host); diff --git a/src/server/utils/error-html.test.ts b/src/server/utils/error-html.test.ts index ae9af9f96b..49ffea586e 100644 --- a/src/server/utils/error-html.test.ts +++ b/src/server/utils/error-html.test.ts @@ -111,7 +111,6 @@ describe("server/utils/error-html", () => { assertIncludes(html, '"https://veryfront.com"'); assertNotIncludes(html, "studio.veryfront.com"); assertNotIncludes(html, "endsWith"); - assertNotIncludes(html, ".veryfront.dev"); assertNotIncludes(html, "}, '*'"); }); diff --git a/src/studio/bridge/bridge-messaging.test.ts b/src/studio/bridge/bridge-messaging.test.ts index 0fb203a77b..13d8ac68b4 100644 --- a/src/studio/bridge/bridge-messaging.test.ts +++ b/src/studio/bridge/bridge-messaging.test.ts @@ -117,7 +117,6 @@ Deno.test("isFromStudio: rejects tenant and hosted development subdomains", () = resetAll(); assertEquals(isFromStudio(makeEvent("https://project.preview.veryfront.org")), false); assertEquals(isFromStudio(makeEvent("https://project.production.veryfront.com")), false); - assertEquals(isFromStudio(makeEvent("https://studio.veryfront.dev")), false); // studio.* subdomains are not deployed and are no longer trusted. assertEquals(isFromStudio(makeEvent("https://studio.veryfront.com")), false); assertEquals(isFromStudio(makeEvent("https://studio.veryfront.org")), false); diff --git a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts index a79ee50f93..922393793a 100644 --- a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts +++ b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts @@ -53,7 +53,7 @@ describe("module-fetcher/http-fetcher", () => { ); assertEquals( requestedUrl, - "http://docs.lvh.me:3001/_vf_modules/pages/index.js?ssr=true&pins=on%3Apins-a", + "http://docs.localhost:3001/_vf_modules/pages/index.js?ssr=true&pins=on%3Apins-a", ); } finally { globalThis.fetch = originalFetch; diff --git a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts index ac0cb089e3..8258276aab 100644 --- a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts +++ b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts @@ -56,7 +56,7 @@ function requireProjectSlug(value: string | undefined): string { ) { throw new TypeError("Project slug must be a valid DNS label"); } - return `${value}.lvh.me`; + return `${value}.localhost`; } function requireFetchTimeout(value: number): number { diff --git a/tests/e2e/helpers/assertions.ts b/tests/e2e/helpers/assertions.ts index 24b1a1585c..0df864c3ea 100644 --- a/tests/e2e/helpers/assertions.ts +++ b/tests/e2e/helpers/assertions.ts @@ -46,8 +46,6 @@ function isIgnorableError(message: string): boolean { // Font loading errors are not critical "Failed to decode downloaded font", "OTS parsing error", - // lvh.me is intentionally plain HTTP in local E2E; Chrome logs this warning for COOP headers. - "The Cross-Origin-Opener-Policy header has been ignored, because the URL's origin was untrustworthy", ]; const lower = message.toLowerCase(); diff --git a/tests/e2e/helpers/runtime.ts b/tests/e2e/helpers/runtime.ts index e665bdfd93..64afbbce4f 100644 --- a/tests/e2e/helpers/runtime.ts +++ b/tests/e2e/helpers/runtime.ts @@ -2,12 +2,12 @@ export const PLAYWRIGHT_RUNTIME_CONFIGS = [ { name: "production-host", modeName: "production", - getUrl: (subdomain: string) => `http://${subdomain}.lvh.me:8080`, + getUrl: (subdomain: string) => `http://${subdomain}.localhost:8080`, }, { name: "preview-host", modeName: "preview", - getUrl: (subdomain: string) => `http://${subdomain}.preview.lvh.me:8080`, + getUrl: (subdomain: string) => `http://${subdomain}.preview.localhost:8080`, }, ] as const; diff --git a/tests/e2e/multi-project.playwright.ts b/tests/e2e/multi-project.playwright.ts index 6373f70f54..3637a4eecf 100644 --- a/tests/e2e/multi-project.playwright.ts +++ b/tests/e2e/multi-project.playwright.ts @@ -50,7 +50,7 @@ test( ); for (const subdomain of PROJECTS) { - const response = await page.goto(`http://${subdomain}--feature.preview.lvh.me:8080/`); + const response = await page.goto(`http://${subdomain}--feature.preview.localhost:8080/`); expect(response?.ok()).toBeTruthy(); await expect(page.locator("#project-name")).toHaveText(subdomain); diff --git a/tests/e2e/playwright.config.cjs b/tests/e2e/playwright.config.cjs index e68bc93723..998d536c06 100644 --- a/tests/e2e/playwright.config.cjs +++ b/tests/e2e/playwright.config.cjs @@ -5,14 +5,14 @@ const runtimeProjects = [ name: "production-host", use: { ...devices["Desktop Chrome"], - baseURL: "http://blank.lvh.me:8080", + baseURL: "http://blank.localhost:8080", }, }, { name: "preview-host", use: { ...devices["Desktop Chrome"], - baseURL: "http://blank.preview.lvh.me:8080", + baseURL: "http://blank.preview.localhost:8080", }, }, ]; diff --git a/tests/e2e/setup/server.ts b/tests/e2e/setup/server.ts index 078ee69f09..468d2613dd 100644 --- a/tests/e2e/setup/server.ts +++ b/tests/e2e/setup/server.ts @@ -14,7 +14,7 @@ let serverProcess: ChildProcess | null = null; let runtimeServer: Awaited> | null = null; let runtimeServerAbortController: AbortController | null = null; let workspaceRoot: string | null = null; -let readinessUrl = "http://blank.lvh.me:8080/"; +let readinessUrl = "http://blank.localhost:8080/"; async function writeProjectFile( projectDir: string, @@ -271,7 +271,7 @@ export async function startServer( ? options.projectSlugs : getProjectsToProvision(); workspaceRoot = await createPlaywrightWorkspace(projectSlugs); - readinessUrl = `http://${projectSlugs[0]}.lvh.me:8080/`; + readinessUrl = `http://${projectSlugs[0]}.localhost:8080/`; await persistWorkspaceState(workspaceRoot); if (options.mode === "production") { diff --git a/tests/e2e/smoke.playwright.ts b/tests/e2e/smoke.playwright.ts index d4635d91d5..2fa78d7bef 100644 --- a/tests/e2e/smoke.playwright.ts +++ b/tests/e2e/smoke.playwright.ts @@ -139,7 +139,7 @@ for (const subdomain of PROJECTS) { "branch preview coverage only applies to preview hosts", ); - const branchPreviewUrl = `http://${subdomain}--feature.preview.lvh.me:8080`; + const branchPreviewUrl = `http://${subdomain}--feature.preview.localhost:8080`; const response = await visit(page, `${branchPreviewUrl}/`); expect(response?.ok()).toBeTruthy(); diff --git a/tests/integration/vfs-proxy-mode-e2e.test.ts b/tests/integration/vfs-proxy-mode-e2e.test.ts index f74a7bd1ce..30f4e352c1 100644 --- a/tests/integration/vfs-proxy-mode-e2e.test.ts +++ b/tests/integration/vfs-proxy-mode-e2e.test.ts @@ -301,10 +301,10 @@ describe( } } - // Use flow-ops.lvh.me (*.lvh.me resolves to 127.0.0.1) + // Use flow-ops.localhost (*.localhost resolves to 127.0.0.1) // Include proxy headers that a real proxy would set — without x-release-id // the renderer rejects production requests in proxy mode with 502. - const response = await fetch(`http://flow-ops.lvh.me:${server.port}/api/flows`, { + const response = await fetch(`http://flow-ops.localhost:${server.port}/api/flows`, { headers: { "x-release-id": releaseId, "x-environment": "production", diff --git a/tests/load-test-isolation.ts b/tests/load-test-isolation.ts index da85c8736c..85207da957 100644 --- a/tests/load-test-isolation.ts +++ b/tests/load-test-isolation.ts @@ -200,7 +200,7 @@ async function main(): Promise { console.error("Error: URL is required"); console.error("Usage: deno run --allow-all tests/load-test-isolation.ts "); console.error( - "Example: deno run --allow-all tests/load-test-isolation.ts http://myproject.lvh.me:8080", + "Example: deno run --allow-all tests/load-test-isolation.ts http://myproject.localhost:8080", ); Deno.exit(1); } diff --git a/tests/server/context/request-context.test.ts b/tests/server/context/request-context.test.ts index cdd41faabd..bf7b71ba16 100644 --- a/tests/server/context/request-context.test.ts +++ b/tests/server/context/request-context.test.ts @@ -51,9 +51,9 @@ describe("request-context", () => { assertEquals(ctx.branch, "feature"); }); - it("extracts slug from local dev domain (lvh.me)", () => { + it("extracts slug from local dev domain (localhost)", () => { const ctx = createRequestContext( - new Request("http://myapp.lvh.me:8080/page"), + new Request("http://myapp.localhost:8080/page"), ); assertEquals(ctx.slug, "myapp"); @@ -63,7 +63,7 @@ describe("request-context", () => { it("extracts slug and sets preview mode from local preview domain", () => { const ctx = createRequestContext( - new Request("http://myapp.preview.lvh.me:8080/page"), + new Request("http://myapp.preview.localhost:8080/page"), ); assertEquals(ctx.slug, "myapp"); From c4d994d05848ad29302f95777d593e5263f4af76 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Fri, 14 Aug 2026 03:07:54 +0200 Subject: [PATCH 2/5] fix: address review findings on the localhost hostname migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex (P1) — MDX HTTP fallback could become unreachable. The fallback fetches .localhost. RFC 6761 only *recommends* that resolvers map the .localhost tree to loopback; macOS, systemd-resolved and CI honour it for arbitrary subdomains, but a plain glibc NSS setup can resolve only the bare name and fail with EAI_AGAIN/ENOTFOUND. There the fallback could no longer reach the dev server at all. The suggested remedy — pin the connection to 127.0.0.1 and keep routing via a Host header — is not implementable here: Deno's fetch silently drops a Host override. Verified against a local Deno.serve: the server saw 127.0.0.1, not the supplied name. So the request would arrive without its project. Instead the fetch now retries against bare localhost when, and only when, the host is a *.localhost subdomain and the failure is a name-resolution error. Aborts (timeout/cancellation) are explicitly excluded so they are never retried. On fallback the dev server resolves the project from its configured slug, which is the correct project in single-project local dev. CodeRabbit (Major) — production.localhost admitted as a dev host. isLocalDevHost excluded production simulations with a /\.production\./ test, which requires the label to be infixed and so missed the bare production root. Now both the leading and infixed forms are excluded, with tests pinning production.localhost and staging.localhost as non-dev hosts. --- deno.lock | 1 + src/server/utils/domain-parser.test.ts | 9 +++ src/server/utils/domain-parser.ts | 14 +++-- .../module-fetcher/http-fetcher.ts | 62 ++++++++++++++++++- 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/deno.lock b/deno.lock index a7ce314d56..30ed54ac21 100644 --- a/deno.lock +++ b/deno.lock @@ -5720,6 +5720,7 @@ "https://esm.sh/yaml@2": "https://esm.sh/yaml@2.9.0" }, "remote": { + "http://127.0.0.1:49595/allowed.js": "f604d628d9f3ec91366a869741a48bc612290794f52990aba9c15641d64c4c6f", "http://127.0.0.1:57487/allowed.js": "f604d628d9f3ec91366a869741a48bc612290794f52990aba9c15641d64c4c6f", "https://deno.land/std@0.208.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", "https://deno.land/std@0.208.0/assert/_diff.ts": "58e1461cc61d8eb1eacbf2a010932bf6a05b79344b02ca38095f9b805795dc48", diff --git a/src/server/utils/domain-parser.test.ts b/src/server/utils/domain-parser.test.ts index 775bed97a5..e15e4c47c0 100644 --- a/src/server/utils/domain-parser.test.ts +++ b/src/server/utils/domain-parser.test.ts @@ -287,6 +287,15 @@ describe("domain-parser", () => { assertEquals(isLocalDevHost("myproject.localhost:3001"), true); }); + it("rejects the bare production root", () => { + // `production.localhost` parses as the production environment, so it is a + // production simulation rather than a dev host. A `.production.` substring + // test alone misses it, because the label is leading rather than infixed. + assertEquals(isLocalDevHost("production.localhost"), false); + assertEquals(isLocalDevHost("production.localhost:3000"), false); + assertEquals(isLocalDevHost("staging.localhost"), false); + }); + it("recognizes preview local dev domains", () => { assertEquals(isLocalDevHost("myproject.preview.localhost"), true); assertEquals(isLocalDevHost("myproject.preview.localhost:3001"), true); diff --git a/src/server/utils/domain-parser.ts b/src/server/utils/domain-parser.ts index 5a8de281f7..f0e34a7398 100644 --- a/src/server/utils/domain-parser.ts +++ b/src/server/utils/domain-parser.ts @@ -291,10 +291,16 @@ export function isLocalDevHost(host: string): boolean { // Explicit staging is for testing staging behaviour — not a dev host if (parsed.environment === "staging") return false; - // Explicit production ({slug}.production.{local}) is for testing production behaviour. - // Slug-only domains ({slug}.{local}) also parse as "production" but ARE dev hosts, - // so only exclude when ".production." appears in the domain. - if (parsed.environment === "production" && /\.production\./i.test(domain)) return false; + // Explicit production (`production.{local}` or `{slug}.production.{local}`) is for + // testing production behaviour. Slug-only domains ({slug}.{local}) also parse as + // "production" but ARE dev hosts, so exclude only the explicit forms: the bare + // production root, and any domain carrying a ".production." label. + if ( + parsed.environment === "production" && + (/^production\./i.test(domain) || /\.production\./i.test(domain)) + ) { + return false; + } return true; } diff --git a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts index 8258276aab..38eb8d1806 100644 --- a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts +++ b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts @@ -59,6 +59,62 @@ function requireProjectSlug(value: string | undefined): string { return `${value}.localhost`; } +function isLocalhostSubdomain(hostname: string): boolean { + return hostname !== "localhost" && hostname.endsWith(".localhost"); +} + +/** + * True for errors that mean "the hostname could not be resolved". + * + * Deliberately excludes aborts (timeout/cancellation), which must not be retried. + */ +function isNameResolutionError(error: unknown): boolean { + if (error instanceof DOMException && error.name === "AbortError") return false; + if (!(error instanceof Error)) return false; + const text = `${error.message} ${(error.cause as Error | undefined)?.message ?? ""}` + .toLowerCase(); + return text.includes("dns error") || + text.includes("failed to lookup address") || + text.includes("enotfound") || + text.includes("eai_again") || + text.includes("name or service not known"); +} + +/** + * Fetch the module, falling back to bare `localhost` when a project subdomain + * cannot be resolved. + * + * RFC 6761 only *recommends* that resolvers map the `.localhost` tree to + * loopback. macOS, systemd-resolved and CI honour it for arbitrary subdomains, + * but a plain glibc NSS setup can resolve only the bare name and fail + * `.localhost` with EAI_AGAIN/ENOTFOUND — which would make this fallback + * unable to reach the dev server at all. + * + * Pinning the connection to 127.0.0.1 while keeping subdomain routing is not an + * option: Deno's fetch silently drops a `Host` header override (verified), so + * the request would arrive with `Host: 127.0.0.1` and lose the project. Bare + * `localhost` instead lets the dev server resolve the project from its + * configured slug, which is the correct project in single-project local dev. + */ +async function fetchModuleWithLoopbackFallback( + fetchFn: typeof fetch, + url: URL, + init: RequestInit, + log: Logger, +): Promise { + try { + return await fetchFn(url.toString(), init); + } catch (error) { + if (!isLocalhostSubdomain(url.hostname) || !isNameResolutionError(error)) throw error; + const fallbackUrl = new URL(url); + fallbackUrl.hostname = "localhost"; + log.debug( + `${LOG_PREFIX_MDX_LOADER} ${url.hostname} did not resolve; retrying via ${fallbackUrl.host}`, + ); + return await fetchFn(fallbackUrl.toString(), init); + } +} + function requireFetchTimeout(value: number): number { if ( !Number.isSafeInteger(value) || @@ -135,7 +191,11 @@ export async function fetchModuleViaHTTP( try { response = await withSpan( SpanNames.HTTP_CLIENT_FETCH, - () => fetchFn(moduleUrlString, { signal: controller.signal, redirect: "error" }), + () => + fetchModuleWithLoopbackFallback(fetchFn, moduleUrl, { + signal: controller.signal, + redirect: "error", + }, log), { "http.method": "GET", "http.url": moduleUrlString, From 3dde75508b5cf8afc0cc6c125982430c5f5bc458 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Fri, 14 Aug 2026 04:11:47 +0200 Subject: [PATCH 3/5] fix: keep localhost fallback resolver-independent and tenant-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the two findings Codex raised against the previous fix. P1 — e2e readiness poll. tests/e2e/setup/server.ts polled http://.localhost:8080/ and passed it straight to Node's fetch, so on an NSS setup that resolves only the bare name it never resolves and the setup retries for 60s before Playwright starts. The earlier fix covered only the MDX fetcher. The poll now targets bare localhost and carries the project in x-project-slug. P2 — tenant identity lost on retry. The localhost retry dropped the project. resolveDefaultProjectSlug() returns undefined for a multi-project workspace, so the retry reached the module route with nothing to route on. The retry now sets x-project-slug. x-project-slug is the right carrier: the dev server already reads it inbound (server/context/request-context.ts, server/dev-server/middleware.ts, server/runtime-handler/project-resolution.ts), and unlike Host it is a header fetch is permitted to set — which is what made the originally suggested 127.0.0.1 + Host approach unworkable. Tests cover the fallback path and the abort path. Both were mutation-checked: inverting the AbortError guard and dropping the slug each turn the suite red. --- .../module-fetcher/http-fetcher.test.ts | 51 +++++++++++++++++++ .../module-fetcher/http-fetcher.ts | 20 +++++--- tests/e2e/setup/server.ts | 22 ++++++-- 3 files changed, 83 insertions(+), 10 deletions(-) diff --git a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts index 922393793a..b34d414c60 100644 --- a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts +++ b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts @@ -8,6 +8,57 @@ import { MAX_MDX_MODULE_CODE_BYTES, MAX_MDX_MODULE_TRANSFORM_CONCURRENCY } from import { HttpModuleBodyTooLargeError } from "../../../shared/http-module-response.ts"; describe("module-fetcher/http-fetcher", () => { + it("falls back to bare localhost, carrying the project slug, when the subdomain will not resolve", async () => { + const logger = { debug: () => {}, warn: () => {} } as unknown as Logger; + const adapter = { + env: { get: (k: string) => (k === "VERYFRONT_DEV_PORT" ? "3001" : undefined) }, + } as RuntimeAdapter; + + const attempts: { url: string; projectSlug: string | null }[] = []; + const fetchFn = ((input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + const projectSlug = new Headers(init?.headers).get("x-project-slug"); + attempts.push({ url, projectSlug }); + if (attempts.length === 1) { + // Shape of a Deno resolver failure on a glibc-only NSS setup. + return Promise.reject(new TypeError("error sending request: dns error: failed to lookup address")); + } + return Promise.resolve(new Response("export const ok = 1;")); + }) as unknown as typeof fetch; + + await fetchModuleViaHTTP("mod.js", adapter, async () => null, logger, "docs", true, undefined, { + fetchFn, + }); + + assertEquals(attempts.length, 2); + assertEquals(new URL(attempts[0]!.url).hostname, "docs.localhost"); + assertEquals(attempts[0]!.projectSlug, null); + // The retry must reach a name that always resolves, without losing the tenant. + assertEquals(new URL(attempts[1]!.url).hostname, "localhost"); + assertEquals(attempts[1]!.projectSlug, "docs"); + }); + + it("does not retry when the fetch was aborted", async () => { + const logger = { debug: () => {}, warn: () => {} } as unknown as Logger; + const adapter = { + env: { get: (k: string) => (k === "VERYFRONT_DEV_PORT" ? "3001" : undefined) }, + } as RuntimeAdapter; + + let calls = 0; + const fetchFn = (() => { + calls += 1; + return Promise.reject(new DOMException("Local module fetch timed out", "AbortError")); + }) as unknown as typeof fetch; + + await assertRejects(() => + fetchModuleViaHTTP("mod.js", adapter, async () => null, logger, "docs", true, undefined, { + fetchFn, + }) + ); + // A timeout must not be re-issued against localhost; that would double the wait. + assertEquals(calls, 1); + }); + it("rewrites the matched import instead of the same text in an earlier comment", async () => { const originalFetch = globalThis.fetch; const logger = { debug: () => {}, warn: () => {} } as unknown as Logger; diff --git a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts index 38eb8d1806..f65b5eedca 100644 --- a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts +++ b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts @@ -92,15 +92,20 @@ function isNameResolutionError(error: unknown): boolean { * * Pinning the connection to 127.0.0.1 while keeping subdomain routing is not an * option: Deno's fetch silently drops a `Host` header override (verified), so - * the request would arrive with `Host: 127.0.0.1` and lose the project. Bare - * `localhost` instead lets the dev server resolve the project from its - * configured slug, which is the correct project in single-project local dev. + * the request would arrive with `Host: 127.0.0.1` and lose the project. + * + * The retry therefore carries the project in `x-project-slug`, which the dev + * server reads inbound (see server/context/request-context.ts and + * server/runtime-handler/project-resolution.ts) and which fetch — unlike `Host` + * — is allowed to set. Without it a multi-project workspace would lose tenant + * identity, because resolveDefaultProjectSlug() returns undefined there. */ async function fetchModuleWithLoopbackFallback( fetchFn: typeof fetch, url: URL, init: RequestInit, log: Logger, + projectSlug?: string, ): Promise { try { return await fetchFn(url.toString(), init); @@ -108,10 +113,13 @@ async function fetchModuleWithLoopbackFallback( if (!isLocalhostSubdomain(url.hostname) || !isNameResolutionError(error)) throw error; const fallbackUrl = new URL(url); fallbackUrl.hostname = "localhost"; + const headers = new Headers(init.headers); + if (projectSlug) headers.set("x-project-slug", projectSlug); log.debug( - `${LOG_PREFIX_MDX_LOADER} ${url.hostname} did not resolve; retrying via ${fallbackUrl.host}`, + `${LOG_PREFIX_MDX_LOADER} ${url.hostname} did not resolve; retrying via ${fallbackUrl.host}` + + `${projectSlug ? ` with x-project-slug: ${projectSlug}` : ""}`, ); - return await fetchFn(fallbackUrl.toString(), init); + return await fetchFn(fallbackUrl.toString(), { ...init, headers }); } } @@ -195,7 +203,7 @@ export async function fetchModuleViaHTTP( fetchModuleWithLoopbackFallback(fetchFn, moduleUrl, { signal: controller.signal, redirect: "error", - }, log), + }, log, projectSlug), { "http.method": "GET", "http.url": moduleUrlString, diff --git a/tests/e2e/setup/server.ts b/tests/e2e/setup/server.ts index 468d2613dd..54bd2dff65 100644 --- a/tests/e2e/setup/server.ts +++ b/tests/e2e/setup/server.ts @@ -14,7 +14,13 @@ let serverProcess: ChildProcess | null = null; let runtimeServer: Awaited> | null = null; let runtimeServerAbortController: AbortController | null = null; let workspaceRoot: string | null = null; -let readinessUrl = "http://blank.localhost:8080/"; +// Readiness polls bare `localhost` and carries the project in `x-project-slug`. +// RFC 6761 only *recommends* that resolvers map the `.localhost` tree to loopback, +// so `.localhost` can fail with EAI_AGAIN on a plain glibc NSS setup; bare +// `localhost` always resolves. The dev server reads `x-project-slug` inbound +// (src/server/context/request-context.ts), so routing is preserved. +let readinessUrl = "http://localhost:8080/"; +let readinessProjectSlug = "blank"; async function writeProjectFile( projectDir: string, @@ -228,7 +234,11 @@ async function cleanupWorkspace(): Promise { } } -async function waitForReady(url: string, timeout = 30_000): Promise { +async function waitForReady( + url: string, + timeout = 30_000, + projectSlug = readinessProjectSlug, +): Promise { const start = Date.now(); const pollInterval = 500; @@ -237,7 +247,10 @@ async function waitForReady(url: string, timeout = 30_000): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 2000); - const response = await fetch(url, { signal: controller.signal }); + const response = await fetch(url, { + signal: controller.signal, + headers: { "x-project-slug": projectSlug }, + }); clearTimeout(timeoutId); @@ -271,7 +284,8 @@ export async function startServer( ? options.projectSlugs : getProjectsToProvision(); workspaceRoot = await createPlaywrightWorkspace(projectSlugs); - readinessUrl = `http://${projectSlugs[0]}.localhost:8080/`; + readinessUrl = "http://localhost:8080/"; + readinessProjectSlug = projectSlugs[0]!; await persistWorkspaceState(workspaceRoot); if (options.mode === "production") { From 96d76e3454e774bb9f5e2acda1949775234f96c9 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Fri, 14 Aug 2026 04:41:32 +0200 Subject: [PATCH 4/5] fix: make the VFS proxy-mode test resolver-independent, and format The VFS integration test fetched flow-ops.localhost directly, so on a plain glibc NSS host that resolves only the bare name it fails with EAI_AGAIN before any assertion runs. Host-based slug resolution is precisely what the test verifies, so the authority cannot simply be swapped for a header. It now issues a raw HTTP/1.1 request over a loopback TCP connection with an explicit Host header. Verified against a live Deno.serve: the connection goes to 127.0.0.1 while the server sees Host: flow-ops.localhost, custom headers pass through, and the status line and body parse correctly. Also runs deno fmt over the files touched in the previous two commits, which failed ci (format). --- .../module-fetcher/http-fetcher.test.ts | 4 +- .../module-fetcher/http-fetcher.ts | 14 ++-- tests/integration/vfs-proxy-mode-e2e.test.ts | 69 +++++++++++++++++-- 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts index b34d414c60..e060c65831 100644 --- a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts +++ b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.test.ts @@ -21,7 +21,9 @@ describe("module-fetcher/http-fetcher", () => { attempts.push({ url, projectSlug }); if (attempts.length === 1) { // Shape of a Deno resolver failure on a glibc-only NSS setup. - return Promise.reject(new TypeError("error sending request: dns error: failed to lookup address")); + return Promise.reject( + new TypeError("error sending request: dns error: failed to lookup address"), + ); } return Promise.resolve(new Response("export const ok = 1;")); }) as unknown as typeof fetch; diff --git a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts index f65b5eedca..2e8788fcf5 100644 --- a/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts +++ b/src/transforms/mdx/esm-module-loader/module-fetcher/http-fetcher.ts @@ -200,10 +200,16 @@ export async function fetchModuleViaHTTP( response = await withSpan( SpanNames.HTTP_CLIENT_FETCH, () => - fetchModuleWithLoopbackFallback(fetchFn, moduleUrl, { - signal: controller.signal, - redirect: "error", - }, log, projectSlug), + fetchModuleWithLoopbackFallback( + fetchFn, + moduleUrl, + { + signal: controller.signal, + redirect: "error", + }, + log, + projectSlug, + ), { "http.method": "GET", "http.url": moduleUrlString, diff --git a/tests/integration/vfs-proxy-mode-e2e.test.ts b/tests/integration/vfs-proxy-mode-e2e.test.ts index 30f4e352c1..60bb56d746 100644 --- a/tests/integration/vfs-proxy-mode-e2e.test.ts +++ b/tests/integration/vfs-proxy-mode-e2e.test.ts @@ -26,6 +26,57 @@ const BINARY_PATH = Deno.env.get("VERYFRONT_BINARY") ?? `/tmp/veryfront-vfs-e2e- const BINARY_HASH_PATH = `${BINARY_PATH}.srcHash`; const VERYFRONT_API_TOKEN = Deno.env.get("VERYFRONT_API_TOKEN"); +/** + * Issue a GET over a raw loopback TCP connection with an explicit `Host` header. + * + * Deno's fetch drops a `Host` override, so this is the only way to exercise + * host-based project resolution without depending on `*.localhost` resolving. + */ +async function requestViaLoopbackWithHost(options: { + port: number; + host: string; + path: string; + headers: Record; +}): Promise<{ status: number; body: string }> { + const conn = await Deno.connect({ hostname: "127.0.0.1", port: options.port }); + try { + const request = [ + `GET ${options.path} HTTP/1.1`, + `Host: ${options.host}`, + ...Object.entries(options.headers).map(([key, value]) => `${key}: ${value}`), + "Connection: close", + "", + "", + ].join("\r\n"); + await conn.write(new TextEncoder().encode(request)); + + const chunks: Uint8Array[] = []; + const buffer = new Uint8Array(4096); + while (true) { + const read = await conn.read(buffer); + if (read === null) break; + chunks.push(buffer.slice(0, read)); + } + const raw = new TextDecoder().decode( + chunks.reduce((acc, chunk) => { + const merged = new Uint8Array(acc.length + chunk.length); + merged.set(acc); + merged.set(chunk, acc.length); + return merged; + }, new Uint8Array()), + ); + const status = Number(raw.match(/^HTTP\/1\.[01] (\d{3})/)?.[1] ?? 0); + const separator = raw.indexOf("\r\n\r\n"); + return { status, body: separator === -1 ? "" : raw.slice(separator + 4) }; + } finally { + try { + conn.close(); + } catch { + // already closed by the server via `Connection: close` + } + } +} + async function getAvailablePort(): Promise { const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); const { port } = listener.addr as Deno.NetAddr; @@ -301,10 +352,19 @@ describe( } } - // Use flow-ops.localhost (*.localhost resolves to 127.0.0.1) - // Include proxy headers that a real proxy would set — without x-release-id - // the renderer rejects production requests in proxy mode with 502. - const response = await fetch(`http://flow-ops.localhost:${server.port}/api/flows`, { + // Connect straight to loopback and set the Host header by hand, rather + // than fetching `flow-ops.localhost`. RFC 6761 only *recommends* that + // resolvers map the `.localhost` tree to loopback, so a plain glibc NSS + // host can fail `flow-ops.localhost` with EAI_AGAIN before any assertion + // runs. Host-based resolution is exactly what this test verifies, so the + // authority has to be preserved — and Deno's fetch silently drops a + // `Host` override, which is why this is a raw request. + const response = await requestViaLoopbackWithHost({ + port: server.port, + host: `flow-ops.localhost:${server.port}`, + path: "/api/flows", + // Proxy headers a real proxy would set — without x-release-id the + // renderer rejects production requests in proxy mode with 502. headers: { "x-release-id": releaseId, "x-environment": "production", @@ -312,7 +372,6 @@ describe( "x-token": "test-token", }, }); - await response.text(); // Verify the server resolved the slug from the Host header, not the // local-* fallback. The logger context in the server logs shows the From 80241d4b6591eb68b36afc30c0d9ecf108a15db9 Mon Sep 17 00:00:00 2001 From: Kentaro Wakayama Date: Fri, 14 Aug 2026 05:15:06 +0200 Subject: [PATCH 5/5] fix: write the raw HTTP request in full MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deno.Conn.write is a low-level write and may consume fewer bytes than supplied. The helper discarded the returned count, so a short write under socket pressure would leave the server waiting for the rest of the headers while the helper waited for a response — a hang rather than a failure. Now loops until the whole request is on the wire. Proven with a connection rigged to accept 7 bytes per call: 14 iterations to drain 98 bytes, request completes with the Host authority intact. Before the fix only the first 7 bytes would have been sent. --- tests/integration/vfs-proxy-mode-e2e.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/integration/vfs-proxy-mode-e2e.test.ts b/tests/integration/vfs-proxy-mode-e2e.test.ts index 60bb56d746..c56cf0f0b9 100644 --- a/tests/integration/vfs-proxy-mode-e2e.test.ts +++ b/tests/integration/vfs-proxy-mode-e2e.test.ts @@ -48,7 +48,14 @@ async function requestViaLoopbackWithHost(options: { "", "", ].join("\r\n"); - await conn.write(new TextEncoder().encode(request)); + // `Deno.Conn.write` is a low-level write and may consume fewer bytes than + // supplied. Dropping the returned count would leave the server waiting for + // the rest of the headers while this helper waits for a response — a hang + // rather than a failure. Loop until the whole request is on the wire. + const payload = new TextEncoder().encode(request); + for (let written = 0; written < payload.length;) { + written += await conn.write(payload.subarray(written)); + } const chunks: Uint8Array[] = []; const buffer = new Uint8Array(4096);