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/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..e15e4c47c0 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,69 @@ 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("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.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 +361,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 +380,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..f0e34a7398 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); @@ -289,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/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..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 @@ -8,6 +8,59 @@ 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; @@ -53,7 +106,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..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 @@ -56,7 +56,71 @@ 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 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. + * + * 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); + } catch (error) { + 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}` + + `${projectSlug ? ` with x-project-slug: ${projectSlug}` : ""}`, + ); + return await fetchFn(fallbackUrl.toString(), { ...init, headers }); + } } function requireFetchTimeout(value: number): number { @@ -135,7 +199,17 @@ 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, + projectSlug, + ), { "http.method": "GET", "http.url": moduleUrlString, 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..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.lvh.me: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]}.lvh.me:8080/`; + readinessUrl = "http://localhost:8080/"; + readinessProjectSlug = projectSlugs[0]!; 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..c56cf0f0b9 100644 --- a/tests/integration/vfs-proxy-mode-e2e.test.ts +++ b/tests/integration/vfs-proxy-mode-e2e.test.ts @@ -26,6 +26,64 @@ 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"); + // `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); + 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 +359,19 @@ describe( } } - // Use flow-ops.lvh.me (*.lvh.me 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`, { + // 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 +379,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 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");