diff --git a/docs/api-reference/veryfront/index.client.md b/docs/api-reference/veryfront/index.client.md index aa303d1bd3..b832ac4e3a 100644 --- a/docs/api-reference/veryfront/index.client.md +++ b/docs/api-reference/veryfront/index.client.md @@ -73,10 +73,10 @@ export function GET() { | `MDXFrontmatter` | Parsed frontmatter values from an MDX page. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/types/index.ts#L90) | | `PageContext` | Runtime page context passed to page components. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/types/index.ts#L107) | | `PageWithData` | Page with data fetching capabilities | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/types.ts#L16) | -| `StartServerOptions` | Server options. Defaults to development mode with HMR. Set `mode: "production"` for a production server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L143) | +| `StartServerOptions` | Server options. Defaults to development mode with HMR. Set `mode: "production"` for a production server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L145) | | `StaticPathsResult` | Return type for `getStaticPaths()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/schemas/data.schema.ts#L61) | | `ValidatedHandlerConfig` | Configuration for `createValidatedHandler()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/handler.ts#L11) | | `ValidatedHandlerFunction` | Handler signature that receives validated request data. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/handler.ts#L18) | | `VeryfrontConfig` | Project configuration. The underlying runtime schema stores `extensions` as `unknown[]`; this tightened alias surfaces the expected `ExtensionConfigEntry[]` shape to TypeScript consumers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/schemas/index.ts#L24) | -| `VeryfrontHandler` | Web API request handler with WebSocket upgrade and HMR helpers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L158) | -| `VeryfrontServer` | Running server instance with lifecycle controls. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L146) | +| `VeryfrontHandler` | Web API request handler with WebSocket upgrade and HMR helpers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L160) | +| `VeryfrontServer` | Running server instance with lifecycle controls. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L148) | diff --git a/docs/api-reference/veryfront/index.md b/docs/api-reference/veryfront/index.md index ab6c62720e..bb0b165a40 100644 --- a/docs/api-reference/veryfront/index.md +++ b/docs/api-reference/veryfront/index.md @@ -61,7 +61,7 @@ export function getServerData(ctx: DataContext) { | `apiNotFound` | Create a 404 Not Found response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L119) | | `apiRedirect` | Create an HTTP redirect response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L97) | | `badRequest` | Create a 400 Bad Request response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L124) | -| `createHandler` | Create a Veryfront request handler for development or production. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L238) | +| `createHandler` | Create a Veryfront request handler for development or production. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L241) | | `createValidatedHandler` | Create a validated API handler with bounded body/query validation. Bodies without a schema are preflighted through a clone, leaving the original request body available to the handler after its size is verified. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/handler.ts#L208) | | `createValidationError` | Create an input validation error. Convenience wrapper around INPUT_VALIDATION_FAILED.create(). | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/errors.ts#L12) | | `defineConfig` | Define a Veryfront project configuration object. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/define-config-core.ts#L4) | @@ -77,8 +77,8 @@ export function getServerData(ctx: DataContext) { | `redirect` | Redirect the request from a data loader. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/helpers.ts#L34) | | `sanitizeData` | Sanitize JSON-like data by HTML-encoding string values and removing keys that can mutate an object's prototype chain. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/sanitizers.ts#L8) | | `serverError` | Create a 500 Internal Server Error response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L139) | -| `startServer` | Start a Veryfront server in development or production mode. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L525) | -| `toNodeHandler` | Convert a Web API request handler into a Node.js HTTP listener. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/node-handler.ts#L4) | +| `startServer` | Start a Veryfront server in development or production mode. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L531) | +| `toNodeHandler` | Convert a Web API request handler into a Node.js HTTP listener. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/node-handler.ts#L5) | | `unauthorized` | Create a 401 Unauthorized response. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/platform/compat/http/responses.ts#L129) | ### Types @@ -94,10 +94,10 @@ export function getServerData(ctx: DataContext) { | `MDXFrontmatter` | Parsed frontmatter values from an MDX page. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/types/index.ts#L90) | | `PageContext` | Runtime page context passed to page components. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/types/index.ts#L107) | | `PageWithData` | Page with data fetching capabilities | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/types.ts#L16) | -| `StartServerOptions` | Server options. Defaults to development mode with HMR. Set `mode: "production"` for a production server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L143) | +| `StartServerOptions` | Server options. Defaults to development mode with HMR. Set `mode: "production"` for a production server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L145) | | `StaticPathsResult` | Return type for `getStaticPaths()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/data/schemas/data.schema.ts#L61) | | `ValidatedHandlerConfig` | Configuration for `createValidatedHandler()`. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/handler.ts#L11) | | `ValidatedHandlerFunction` | Handler signature that receives validated request data. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/security/input-validation/handler.ts#L18) | | `VeryfrontConfig` | Project configuration. The underlying runtime schema stores `extensions` as `unknown[]`; this tightened alias surfaces the expected `ExtensionConfigEntry[]` shape to TypeScript consumers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/config/schemas/index.ts#L24) | -| `VeryfrontHandler` | Web API request handler with WebSocket upgrade and HMR helpers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L158) | -| `VeryfrontServer` | Running server instance with lifecycle controls. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L146) | +| `VeryfrontHandler` | Web API request handler with WebSocket upgrade and HMR helpers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L160) | +| `VeryfrontServer` | Running server instance with lifecycle controls. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L148) | diff --git a/docs/api-reference/veryfront/server.md b/docs/api-reference/veryfront/server.md index 14a5b15e51..801b3b36e0 100644 --- a/docs/api-reference/veryfront/server.md +++ b/docs/api-reference/veryfront/server.md @@ -47,23 +47,23 @@ await server.fetch(new Request("https://example.com/health")); | Name | Description | Source | | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| `createHandler` | Create a Veryfront request handler for development or production. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L238) | +| `createHandler` | Create a Veryfront request handler for development or production. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L241) | | `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#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) | +| `startDevServer` | Starts dev server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/dev-server/index.ts#L20) | | `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) | -| `startServer` | Start a Veryfront server in development or production mode. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L525) | +| `startProductionServer` | Starts production server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L182) | +| `startServer` | Start a Veryfront server in development or production mode. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L531) | | `startVeryfrontServer` | Starts veryfront server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/service-server.ts#L555) | -| `toNodeHandler` | Convert a Web API request handler into a Node.js HTTP listener. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/node-handler.ts#L4) | +| `toNodeHandler` | Convert a Web API request handler into a Node.js HTTP listener. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/node-handler.ts#L5) | ### Classes | Name | Description | Source | | ---------------- | --------------------- | ------------------------------------------------------------------------------------------------------------ | -| `DevServer` | Implement dev server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/dev-server/server.ts#L70) | +| `DevServer` | Implement dev server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/dev-server/server.ts#L74) | | `RouteDiscovery` | | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/dev-server/route-discovery.ts#L33) | ### Types @@ -73,22 +73,23 @@ await server.fetch(new Request("https://example.com/health")); | `BuildOptions` | Build System Type Definitions Consolidated from cli/commands/build/types.ts and server/build-types.ts | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/build-types.ts#L6) | | `BuildStats` | Public API contract for build stats. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/build-types.ts#L28) | | `CreateVeryfrontServerOptions` | Options accepted by create veryfront server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/service-server.ts#L32) | -| `DevServerOptions` | Options accepted by dev server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/dev-server/types.ts#L2) | -| `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) | +| `DevServerHandler` | Public handler returned by a handler-only dev server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/dev-server/types.ts#L2) | +| `DevServerOptions` | Options accepted by dev server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/dev-server/types.ts#L5) | +| `DiscoveryOptions` | Configuration for AI primitives discovery during server startup | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L120) | +| `FileWatcherMetrics` | Public API contract for file watcher metrics. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/dev-server/types.ts#L40) | | `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#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) | -| `StartDevModeOptions` | Options accepted by start dev mode. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L118) | +| `RouteDirectory` | Public API contract for route directory. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/dev-server/types.ts#L34) | +| `ServerHandle` | Public API contract for server handle. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L157) | +| `StartDevModeOptions` | Options accepted by start dev mode. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L120) | | `StartNodeVeryfrontServerOptions` | Options accepted by start node veryfront server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/service-server.ts#L47) | -| `StartProductionModeOptions` | Options accepted by start production mode. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L127) | -| `StartProductionServerOptions` | Options accepted by start production server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L162) | -| `StartServerOptions` | Server options. Defaults to development mode with HMR. Set `mode: "production"` for a production server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L143) | +| `StartProductionModeOptions` | Options accepted by start production mode. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L129) | +| `StartProductionServerOptions` | Options accepted by start production server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/production-server.ts#L163) | +| `StartServerOptions` | Server options. Defaults to development mode with HMR. Set `mode: "production"` for a production server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L145) | | `StartVeryfrontServerOptions` | Options accepted by start veryfront server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/service-server.ts#L59) | -| `VeryfrontHandler` | Web API request handler with WebSocket upgrade and HMR helpers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L158) | -| `VeryfrontServer` | Running server instance with lifecycle controls. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L146) | +| `VeryfrontHandler` | Web API request handler with WebSocket upgrade and HMR helpers. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L160) | +| `VeryfrontServer` | Running server instance with lifecycle controls. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/index.ts#L148) | | `VeryfrontServiceServer` | Public API contract for veryfront service server. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/service-server.ts#L74) | | `VeryfrontServiceServerFetch` | Public API contract for veryfront service server fetch. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/service-server.ts#L9) | | `VeryfrontServiceServerLogger` | Public API contract for veryfront service server logger. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/server/service-server.ts#L24) | diff --git a/src/platform/adapters/runtime/deno/http-server.ts b/src/platform/adapters/runtime/deno/http-server.ts index 2a10e126f2..f01b1d6233 100644 --- a/src/platform/adapters/runtime/deno/http-server.ts +++ b/src/platform/adapters/runtime/deno/http-server.ts @@ -9,7 +9,7 @@ import { getEnvOverlayStorage } from "../../../compat/process.ts"; import { isErrorAcrossRealms } from "../../../compat/error-introspection.ts"; import { INITIALIZATION_ERROR, NOT_SUPPORTED } from "#veryfront/errors/error-registry/general.ts"; import { serverLogger } from "#veryfront/utils/logger/logger.ts"; -import { recordRequestPeerFromTransport } from "../shared/request-peer.ts"; +import { recordDenoServeRequestPeer } from "../shared/request-peer.ts"; type DenoRequestHandler = ( request: Request, @@ -180,17 +180,7 @@ export async function createDenoServerWithRuntime( signal: controller.signal, handler: async (request, info) => { try { - const remoteAddress = info?.remoteAddr; - if ( - remoteAddress?.transport === "tcp" && - typeof remoteAddress.hostname === "string" - ) { - recordRequestPeerFromTransport(request, { - runtime: "deno", - transport: "tcp", - hostname: remoteAddress.hostname, - }); - } + recordDenoServeRequestPeer(request, info); const response = await wrappedHandler(request); return toNativeResponse(response, NativeResponse); } catch (error) { diff --git a/src/platform/adapters/runtime/shared/request-peer.test.ts b/src/platform/adapters/runtime/shared/request-peer.test.ts index 18a03b3530..ba7a6d2628 100644 --- a/src/platform/adapters/runtime/shared/request-peer.test.ts +++ b/src/platform/adapters/runtime/shared/request-peer.test.ts @@ -4,6 +4,8 @@ import { getRequestPeerProvenance, inheritRequestPeerProvenance, isRequestFromLoopbackPeer, + recordDenoServeRequestPeer, + recordHandlerRequestPeer, recordRequestPeerFromTransport, type RequestPeerRuntime, } from "./request-peer.ts"; @@ -17,6 +19,68 @@ function requestFromPeer(hostname?: string, runtime: RequestPeerRuntime = "node" } describe("runtime request peer provenance", () => { + it("records a Deno serve peer before a portable handler reads the request", () => { + const request = new Request("http://localhost/_projects"); + + assertEquals( + recordDenoServeRequestPeer(request, { + remoteAddr: { + transport: "tcp", + hostname: "::1", + port: 52_000, + }, + }), + true, + ); + assertEquals(getRequestPeerProvenance(request), { + runtime: "deno", + transport: "tcp", + hostname: "::1", + }); + assertEquals(isRequestFromLoopbackPeer(request), true); + }); + + it("records a framework-hosted Node request from its native transport", () => { + const request = new Request("http://localhost/_projects"); + + assertEquals( + recordHandlerRequestPeer(request, { + socket: { remoteAddress: "127.0.0.1" }, + }), + true, + ); + assertEquals(getRequestPeerProvenance(request), { + runtime: "node", + transport: "tcp", + hostname: "127.0.0.1", + }); + assertEquals(isRequestFromLoopbackPeer(request), true); + }); + + it("records a framework-hosted Bun request from its native transport", () => { + const request = new Request("http://localhost/_projects"); + + assertEquals( + recordHandlerRequestPeer(request, { + requestIP(seenRequest: Request) { + assertStrictEquals(seenRequest, request); + return { + address: "127.0.0.1", + port: 52_000, + family: "IPv4", + }; + }, + }), + true, + ); + assertEquals(getRequestPeerProvenance(request), { + runtime: "bun", + transport: "tcp", + hostname: "127.0.0.1", + }); + assertEquals(isRequestFromLoopbackPeer(request), true); + }); + it("recognizes loopback peers across canonical transport representations", () => { for ( const hostname of [ diff --git a/src/platform/adapters/runtime/shared/request-peer.ts b/src/platform/adapters/runtime/shared/request-peer.ts index a432829f9d..d895b76f05 100644 --- a/src/platform/adapters/runtime/shared/request-peer.ts +++ b/src/platform/adapters/runtime/shared/request-peer.ts @@ -18,6 +18,7 @@ export interface RequestPeerProvenance { } const requestPeerProvenance = new WeakMap(); +const interceptorHandledRequests = new WeakSet(); const MAX_PEER_HOSTNAME_CHARACTERS = 255; const DECIMAL_OCTET_PATTERN = /^(?:0|[1-9][0-9]{0,2})$/; const IPV4_MAPPED_IPV6_PATTERN = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/; @@ -88,6 +89,99 @@ export function recordRequestPeerFromTransport( return true; } +/** @internal Record the native peer supplied to a Deno.serve handler. */ +export function recordDenoServeRequestPeer( + request: Request, + info: unknown, +): boolean { + if (typeof info !== "object" || info === null) return false; + + try { + const remoteAddress = (info as { + readonly remoteAddr?: { + readonly transport?: unknown; + readonly hostname?: unknown; + }; + }).remoteAddr; + if ( + remoteAddress?.transport !== "tcp" || + typeof remoteAddress.hostname !== "string" + ) { + return false; + } + return recordRequestPeerFromTransport(request, { + runtime: "deno", + transport: "tcp", + hostname: remoteAddress.hostname, + }); + } catch { + return false; + } +} + +/** @internal Record the native peer supplied by a Node IncomingMessage. */ +export function recordNodeIncomingRequestPeer( + request: Request, + incoming: unknown, +): boolean { + if (typeof incoming !== "object" || incoming === null) return false; + + try { + const socket = (incoming as { + readonly socket?: { readonly remoteAddress?: unknown }; + }).socket; + if (typeof socket?.remoteAddress !== "string") return false; + return recordRequestPeerFromTransport(request, { + runtime: "node", + transport: "tcp", + hostname: socket.remoteAddress, + }); + } catch { + return false; + } +} + +/** @internal Record the native peer supplied by a Bun server context. */ +export function recordBunServerRequestPeer( + request: Request, + server: unknown, +): boolean { + if (typeof server !== "object" || server === null) return false; + + try { + const requestIP = (server as { + readonly requestIP?: unknown; + }).requestIP; + if (typeof requestIP !== "function") return false; + + const peer = requestIP.call(server, request) as { + readonly address?: unknown; + } | null; + if (typeof peer?.address !== "string") return false; + return recordRequestPeerFromTransport(request, { + runtime: "bun", + transport: "tcp", + hostname: peer.address, + }); + } catch { + return false; + } +} + +/** + * @internal Record native peer context passed through a public handler bridge. + * Supports Deno.serve handler info, Node IncomingMessage values, and Bun server + * context values. + */ +export function recordHandlerRequestPeer( + request: Request, + context: unknown, +): boolean { + return recordDenoServeRequestPeer(request, context) || + recordNodeIncomingRequestPeer(request, context) || + recordBunServerRequestPeer(request, context); +} + /** @internal Read immutable transport provenance without consulting headers. */ export function getRequestPeerProvenance( request: Request, @@ -112,6 +206,24 @@ export function inheritRequestPeerProvenance( return target; } +/** @internal Run an interceptor without discarding transport peer authority. */ +export async function runRequestInterceptor( + request: Request, + interceptor: (request: Request) => Request | Promise, +): Promise { + interceptorHandledRequests.add(request); + const intercepted = await interceptor(request); + if (intercepted === request) return request; + + if (interceptorHandledRequests.has(intercepted)) { + throw new TypeError( + "Request interceptors must return a fresh replacement Request", + ); + } + interceptorHandledRequests.add(intercepted); + return inheritRequestPeerProvenance(request, intercepted); +} + /** @internal True for IPv4 127/8, IPv6 ::1, or mapped IPv4 127/8. */ export function isLoopbackAddress(hostname: string): boolean { const ipv4 = parseCanonicalIpv4(hostname); diff --git a/src/server/dev-server.ts b/src/server/dev-server.ts index 687f7518ed..e7aeca0a23 100644 --- a/src/server/dev-server.ts +++ b/src/server/dev-server.ts @@ -1,5 +1,6 @@ export { DevServer, + type DevServerHandler, type DevServerOptions, type FileWatcherMetrics, OptimizedFileWatcher, diff --git a/src/server/dev-server/handler-only.integration.test.ts b/src/server/dev-server/handler-only.integration.test.ts new file mode 100644 index 0000000000..b81cd21a3e --- /dev/null +++ b/src/server/dev-server/handler-only.integration.test.ts @@ -0,0 +1,114 @@ +import { join } from "#veryfront/compat/path"; +import { mkdir } from "#veryfront/platform/compat/fs.ts"; +import { + assertEquals, + assertStrictEquals, + assertStringIncludes, +} from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { withTestContext } from "../../../tests/_helpers/context.ts"; +import { DevServer } from "./server.ts"; + +describe("DevServer handler-only transport context", () => { + it("admits direct loopback chooser requests from Deno, Node, and Bun hosts", async () => { + await withTestContext("dev-handler-only-peer", async (context) => { + const workspace = join(context.projectDir, "workspace"); + await mkdir(join(workspace, "projects", "example", "app"), { recursive: true }); + const server = new DevServer({ + projectDir: workspace, + port: await context.allocatePort(), + enableHMR: false, + handlerOnly: true, + }); + await server.start(); + + try { + const contexts = [ + { + remoteAddr: { + transport: "tcp", + hostname: "127.0.0.1", + port: 52_000, + }, + }, + { + requestIP(request: Request) { + assertEquals(request.url, "http://localhost/"); + return { address: "127.0.0.1", family: "IPv4", port: 52_001 }; + }, + }, + { + socket: { remoteAddress: "127.0.0.1" }, + }, + ]; + + for (const nativeContext of contexts) { + const response = await server.handler( + new Request("http://localhost/", { headers: { host: "localhost" } }), + nativeContext, + ); + assertEquals(response.status, 200); + assertStringIncludes(await response.text(), ""); + } + } finally { + await server.stop(); + } + }); + }); + + it("records native peer context before request interceptors replace the request", async () => { + await withTestContext("dev-handler-only-interceptor-peer", async (context) => { + const workspace = join(context.projectDir, "workspace"); + await mkdir(join(workspace, "projects", "example", "app"), { recursive: true }); + const server = new DevServer({ + projectDir: workspace, + port: await context.allocatePort(), + enableHMR: false, + handlerOnly: true, + defaultProjectSlug: "example", + defaultProjectId: context.projectId, + requestInterceptor: (request) => new Request(request), + }); + await server.start(); + + const createRequest = () => + new Request("http://localhost/_metrics", { + headers: { host: "localhost" }, + }); + + try { + const denoLoopback = await server.handler(createRequest(), { + remoteAddr: { + transport: "tcp", + hostname: "127.0.0.1", + port: 52_002, + }, + }); + assertEquals(denoLoopback.status, 200); + await denoLoopback.body?.cancel(); + + const denoRemote = await server.handler(createRequest(), { + remoteAddr: { + transport: "tcp", + hostname: "192.168.1.25", + port: 52_003, + }, + }); + await denoRemote.body?.cancel(); + assertEquals(denoRemote.status, 403); + + const bunRequest = createRequest(); + const bunLoopback = await server.handler(bunRequest, { + requestIP(seenRequest: Request) { + assertStrictEquals(seenRequest, bunRequest); + return { address: "::1", family: "IPv6", port: 52_004 }; + }, + }); + assertEquals(bunLoopback.status, 200); + await bunLoopback.body?.cancel(); + } finally { + await server.stop(); + } + }); + }); +}); diff --git a/src/server/dev-server/index.ts b/src/server/dev-server/index.ts index a302a5e7eb..1774db9927 100644 --- a/src/server/dev-server/index.ts +++ b/src/server/dev-server/index.ts @@ -6,7 +6,12 @@ export { DevServer } from "./server.ts"; export { OptimizedFileWatcher } from "./file-watcher.ts"; -export type { DevServerOptions, FileWatcherMetrics, RouteDirectory } from "./types.ts"; +export type { + DevServerHandler, + DevServerOptions, + FileWatcherMetrics, + RouteDirectory, +} from "./types.ts"; import type { DevServerOptions } from "./types.ts"; import type { DevServer } from "./server.ts"; diff --git a/src/server/dev-server/server.ts b/src/server/dev-server/server.ts index 285d87f3e8..7769bc4141 100644 --- a/src/server/dev-server/server.ts +++ b/src/server/dev-server/server.ts @@ -13,7 +13,7 @@ import { bootstrapDev } from "../bootstrap.ts"; import { ReloadNotifier } from "../reload-notifier.ts"; import { broadcastUpdate } from "../handlers/preview/hmr-message-router.ts"; import { HMRHandler } from "../handlers/preview/hmr.handler.ts"; -import type { DevServerOptions } from "./types.ts"; +import type { DevServerHandler, DevServerOptions } from "./types.ts"; import { RequestHandler } from "./request-handler.ts"; import { setupMiddleware } from "./middleware.ts"; import { RouteDiscovery } from "./route-discovery.ts"; @@ -27,6 +27,10 @@ import { getHostEnv, setEnv } from "#veryfront/platform/compat/process.ts"; import { isTruthyEnvValue } from "#veryfront/utils/constants/env.ts"; import { initializeDistributedCaches } from "#veryfront/cache/distributed-cache-init.ts"; import { defaultDistributedCacheInitializers } from "#veryfront/server/distributed-cache-initializers.ts"; +import { + recordHandlerRequestPeer, + runRequestInterceptor, +} from "#veryfront/platform/adapters/runtime/shared/request-peer.ts"; import { isDiskCacheConfigured } from "#veryfront/cache/backend.ts"; import { clearTranspileCache, discoverAll } from "#veryfront/discovery"; import type { DiscoveryConfig } from "#veryfront/discovery"; @@ -77,7 +81,7 @@ export class DevServer { private appConfig: VeryfrontConfig | undefined; private _nodeWebSocketServerProvider?: Readonly; private requestHandler?: RequestHandler; - private _handler?: (req: Request) => Promise; + private _handler?: DevServerHandler; readonly ready: Promise; private _resolveReady!: () => void; private _isReady = false; @@ -271,15 +275,19 @@ export class DevServer { // the original request to maintain the connection. const baseHandler = (req: Request) => this.pipeline.execute(req, this.adapter.env.toObject()); const interceptor = this.options.requestInterceptor; - const handler = interceptor + const interceptedHandler = interceptor ? async (req: Request) => { const isWebSocketUpgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket"; if (isWebSocketUpgrade) return baseHandler(req); - const interceptedReq = await interceptor(req); + const interceptedReq = await runRequestInterceptor(req, interceptor); return baseHandler(interceptedReq); } : baseHandler; + const handler = async (req: Request, nativeContext?: unknown) => { + recordHandlerRequestPeer(req, nativeContext); + return await interceptedHandler(req); + }; this._handler = handler; @@ -312,8 +320,12 @@ export class DevServer { }); } - /** Return the request handler for use with external HTTP servers. */ - get handler(): (req: Request) => Promise { + /** + * Return the request handler for use with external HTTP servers. + * Pass the native Deno, Node, or Bun request context as the second argument + * so local-control routes can verify the transport peer. + */ + get handler(): DevServerHandler { if (!this._handler) { throw INITIALIZATION_ERROR.create({ detail: "DevServer not started. Call start() first." }); } diff --git a/src/server/dev-server/types.ts b/src/server/dev-server/types.ts index 03cc2ac226..45f2f80ee5 100644 --- a/src/server/dev-server/types.ts +++ b/src/server/dev-server/types.ts @@ -1,8 +1,15 @@ +/** Public handler returned by a handler-only dev server. */ +export type DevServerHandler = (request: Request, nativeContext?: unknown) => Promise; + /** Options accepted by dev server. */ export interface DevServerOptions { port: number; projectDir: string; - /** When true, skip starting the built-in HTTP server (for use with external servers like Hono). */ + /** + * When true, skip starting the built-in HTTP server for use with external + * servers such as Hono. Pass the external server's native request context as + * the second argument to `DevServer.handler`. + */ handlerOnly?: boolean; /** 0.0.0.0 = all interfaces, 127.0.0.1 = localhost only */ bindAddress?: string; diff --git a/src/server/handlers/dev/projects/index.test.ts b/src/server/handlers/dev/projects/index.test.ts index 22382c1667..c04a97bcd7 100644 --- a/src/server/handlers/dev/projects/index.test.ts +++ b/src/server/handlers/dev/projects/index.test.ts @@ -1,12 +1,25 @@ import { assertEquals, assertStringIncludes } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { createDevUiAssetProvider } from "#veryfront/extensions/dev-ui"; -import type { HandlerContext } from "../../types.ts"; +import { recordRequestPeerFromTransport } from "#veryfront/platform/adapters/runtime/shared/request-peer.ts"; +import type { HandlerContext } from "#veryfront/types"; import { ProjectsHandler } from "./index.ts"; const BUNDLE = "globalThis.__veryfrontProjectsTest = true;"; const PROVIDER = createDevUiAssetProvider(BUNDLE); +function projectsRequest(pathname: string, init: RequestInit = {}): Request { + const headers = new Headers(init.headers); + if (!headers.has("host")) headers.set("host", "localhost"); + const request = new Request(`http://localhost${pathname}`, { ...init, headers }); + recordRequestPeerFromTransport(request, { + runtime: "deno", + transport: "tcp", + hostname: "127.0.0.1", + }); + return request; +} + function projectsContext(): HandlerContext { return { projectDir: "/project", @@ -17,24 +30,40 @@ function projectsContext(): HandlerContext { } describe("ProjectsHandler", () => { + it("does not expose the project chooser to a non-loopback peer", async () => { + const request = new Request("http://localhost/_projects", { + headers: { host: "localhost" }, + }); + recordRequestPeerFromTransport(request, { + runtime: "deno", + transport: "tcp", + hostname: "192.168.1.25", + }); + + const result = await new ProjectsHandler(PROVIDER).handle(request, projectsContext()); + + assertEquals(result.response, undefined); + assertEquals(result.continue, true); + }); + it("serves its shell and exact captured bundle", async () => { const handler = new ProjectsHandler(PROVIDER); const shell = (await handler.handle( - new Request("https://veryfront.test/_projects"), + projectsRequest("/_projects"), projectsContext(), )).response!; assertEquals(shell.status, 200); assertStringIncludes(await shell.text(), 'data-veryfront-dev-ui="projects"'); const asset = (await handler.handle( - new Request("https://veryfront.test/_projects/ui/index.js"), + projectsRequest("/_projects/ui/index.js"), projectsContext(), )).response!; assertEquals(asset.status, 200); assertEquals(await asset.text(), BUNDLE); const nested = (await handler.handle( - new Request("https://veryfront.test/_projects/ui/components/App.js"), + projectsRequest("/_projects/ui/components/App.js"), projectsContext(), )).response!; assertEquals(nested.status, 404); @@ -43,7 +72,7 @@ describe("ProjectsHandler", () => { it("rejects asset mutations and fails closed without assets", async () => { const handler = new ProjectsHandler(PROVIDER); const mutation = (await handler.handle( - new Request("https://veryfront.test/_projects/ui/index.js", { method: "POST" }), + projectsRequest("/_projects/ui/index.js", { method: "POST" }), projectsContext(), )).response!; assertEquals(mutation.status, 405); @@ -51,7 +80,7 @@ describe("ProjectsHandler", () => { const unavailable = new ProjectsHandler(); const unavailableShell = (await unavailable.handle( - new Request("https://veryfront.test/_projects"), + projectsRequest("/_projects"), projectsContext(), )).response!; assertEquals(unavailableShell.status, 503); @@ -60,7 +89,7 @@ describe("ProjectsHandler", () => { assertStringIncludes(await unavailableShell.text(), "@veryfront/ext-dev-ui-react"); const unavailableBundle = (await unavailable.handle( - new Request("https://veryfront.test/_projects/ui/index.js"), + projectsRequest("/_projects/ui/index.js"), projectsContext(), )).response!; assertEquals(unavailableBundle.status, 503); diff --git a/src/server/handlers/dev/projects/index.ts b/src/server/handlers/dev/projects/index.ts index 3d4aac65db..4f1bc447b4 100644 --- a/src/server/handlers/dev/projects/index.ts +++ b/src/server/handlers/dev/projects/index.ts @@ -21,6 +21,7 @@ import { omitHeadResponseBody, } from "../shared/dev-ui-bundle-response.ts"; import type { DevUiAssetProvider } from "#veryfront/extensions/dev-ui"; +import { isTrustedLocalControlRequest } from "#veryfront/security/http/local-control-request.ts"; const PROJECTS_ALLOWED_METHODS = "GET, HEAD"; @@ -60,6 +61,7 @@ export class ProjectsHandler extends BaseHandler { protected override shouldHandle(req: Request, ctx: HandlerContext): boolean { if (!this.metadata.enabled?.(ctx)) return false; + if (!isTrustedLocalControlRequest(req)) return false; const { pathname } = new URL(req.url); return pathname === "/" || pathname.startsWith("/_projects"); diff --git a/src/server/handlers/dev/projects/method-policy.test.ts b/src/server/handlers/dev/projects/method-policy.test.ts index 537f88f7a5..11f7670aeb 100644 --- a/src/server/handlers/dev/projects/method-policy.test.ts +++ b/src/server/handlers/dev/projects/method-policy.test.ts @@ -1,11 +1,24 @@ import { assertEquals } from "#veryfront/testing/assert.ts"; import { describe, it } from "#veryfront/testing/bdd.ts"; import { createDevUiAssetProvider } from "#veryfront/extensions/dev-ui"; -import type { HandlerContext } from "../../types.ts"; +import { recordRequestPeerFromTransport } from "#veryfront/platform/adapters/runtime/shared/request-peer.ts"; +import type { HandlerContext } from "#veryfront/types"; import { ProjectsHandler } from "./index.ts"; const PROVIDER = createDevUiAssetProvider("globalThis.__projectsMethodPolicy = true;"); +function projectsRequest(pathname: string, init: RequestInit = {}): Request { + const headers = new Headers(init.headers); + if (!headers.has("host")) headers.set("host", "localhost"); + const request = new Request(`http://localhost${pathname}`, { ...init, headers }); + recordRequestPeerFromTransport(request, { + runtime: "deno", + transport: "tcp", + hostname: "127.0.0.1", + }); + return request; +} + function projectsContext(): HandlerContext { return { projectDir: "/project", @@ -32,7 +45,7 @@ describe("ProjectsHandler method policy", () => { }), duplex: "half", }; - const request = new Request("http://localhost/_projects", init); + const request = projectsRequest("/_projects", init); let responseSettled = false; const responsePromise = new ProjectsHandler(PROVIDER).handle(request, projectsContext()); void responsePromise.then(() => { @@ -53,11 +66,11 @@ describe("ProjectsHandler method policy", () => { const handler = new ProjectsHandler(PROVIDER); for (const path of ["/_projects", "/_projects/ui/index.js", "/_projects/api/config"]) { const getResponse = (await handler.handle( - new Request(`http://localhost${path}`), + projectsRequest(path), projectsContext(), )).response!; const headResponse = (await handler.handle( - new Request(`http://localhost${path}`, { method: "HEAD" }), + projectsRequest(path, { method: "HEAD" }), projectsContext(), )).response!; @@ -71,7 +84,7 @@ describe("ProjectsHandler method policy", () => { } const unavailable = (await new ProjectsHandler().handle( - new Request("http://localhost/_projects", { method: "HEAD" }), + projectsRequest("/_projects", { method: "HEAD" }), projectsContext(), )).response!; assertEquals(unavailable.status, 503); diff --git a/src/server/index.ts b/src/server/index.ts index 8e507efcd0..55eba8e64c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -23,6 +23,7 @@ import { DevServer, + type DevServerHandler, type DevServerOptions, type FileWatcherMetrics, type RouteDirectory, @@ -36,6 +37,7 @@ import { } from "./production-server.ts"; import { runtime } from "#veryfront/platform/adapters/detect.ts"; import { isWebSocketUpgradeResponse } from "#veryfront/platform/adapters/base.ts"; +import { recordHandlerRequestPeer } from "#veryfront/platform/adapters/runtime/shared/request-peer.ts"; import { cwd } from "#veryfront/platform/compat/process.ts"; import { bootstrapProd } from "./bootstrap.ts"; import { createVeryfrontHandler } from "./runtime-handler/index.ts"; @@ -54,7 +56,7 @@ import { type NodeUpgradeEventSource, NodeUpgradeLifecycle } from "./node-upgrad /** Default server port when no port is specified */ const DEFAULT_SERVER_PORT = 3_000; -export { DevServer, startDevServer, startProductionServer }; +export { DevServer, type DevServerHandler, startDevServer, startProductionServer }; export { gracefullyShutdownProductionServer, type GracefulProductionShutdownOptions, @@ -155,7 +157,7 @@ export interface VeryfrontServer { } /** Web API request handler with WebSocket upgrade and HMR helpers. */ -export type VeryfrontHandler = ((req: Request) => Promise) & { +export type VeryfrontHandler = ((req: Request, nativeContext?: unknown) => Promise) & { /** * Attach WebSocket upgrade handling to a Node.js HTTP server. * Required for HMR live reload when using an external server like Hono, Express, etc. @@ -180,11 +182,12 @@ export type VeryfrontHandler = ((req: Request) => Promise) & { * ```ts * import { Hono } from "hono" * import { serve } from "@hono/node-server" + * import type { HttpBindings } from "@hono/node-server" * import { createHandler } from "veryfront" * - * const app = new Hono() + * const app = new Hono<{ Bindings: HttpBindings }>() * const handler = await createHandler() - * app.all("*", (c) => handler(c.req.raw)) + * app.all("*", (c) => handler(c.req.raw, c.env.incoming)) * const server = serve({ fetch: app.fetch, port: 3000 }) * handler.upgrade(server) * ``` @@ -244,7 +247,10 @@ export async function createHandler( const adapter = await runtime.get(); const bootstrap = await bootstrapProd(projectDir, adapter); const internalHandler = createVeryfrontHandler(projectDir, bootstrap.adapter, { projectDir }); - const handler = async (req: Request) => toNativeResponse(await internalHandler(req)); + const handler = async (req: Request, info?: unknown) => { + recordHandlerRequestPeer(req, info); + return toNativeResponse(await internalHandler(req)); + }; const dispose = createRetryableHandlerDisposer(async () => { await bootstrap.dispose?.(); }); @@ -272,14 +278,14 @@ export async function createHandler( const internalFetch = devServer.handler; const nodeWebSocketServerProvider = devServer.nodeWebSocketServerProvider; let disposalStarted = false; - const fetch = async (req: Request) => { + const fetch = async (req: Request, info?: unknown) => { if (disposalStarted) { return new _NativeResponse("Handler is shutting down", { status: 503, headers: { "cache-control": "no-store" }, }); } - return toNativeResponse(await internalFetch(req)); + return toNativeResponse(await internalFetch(req, info)); }; const hmrRateLimiter = new RateLimiter(HMR_MAX_MESSAGES_PER_MINUTE); const nodeUpgradeLifecycle = new NodeUpgradeLifecycle(); diff --git a/src/server/node-handler.test.ts b/src/server/node-handler.test.ts index e3a20c3bca..0c558defad 100644 --- a/src/server/node-handler.test.ts +++ b/src/server/node-handler.test.ts @@ -1,4 +1,6 @@ import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { isRequestFromLoopbackPeer } from "#veryfront/platform/adapters/runtime/shared/request-peer.ts"; import { toNodeHandler } from "./node-handler.ts"; type FakeRes = { @@ -47,12 +49,18 @@ function createFakeRes(): FakeRes { } function createFakeReq( - init: { method?: string; url?: string; headers?: Record }, + init: { + method?: string; + url?: string; + headers?: Record; + remoteAddress?: string; + }, ): import("node:http").IncomingMessage { return { method: init.method ?? "GET", url: init.url ?? "/", headers: { host: "localhost", ...(init.headers ?? {}) }, + socket: { remoteAddress: init.remoteAddress }, } as unknown as import("node:http").IncomingMessage; } @@ -79,88 +87,107 @@ function collectSetCookies(res: FakeRes): string[] { return cookies; } -Deno.test("toNodeHandler preserves multiple Set-Cookie headers as distinct values", async () => { - const handler = () => { - const headers = new Headers(); - headers.append("Set-Cookie", "a=1; Path=/"); - headers.append("Set-Cookie", "b=2; Path=/"); - return new Response("ok", { status: 200, headers }); - }; - - const nodeHandler = toNodeHandler(handler); - const res = createFakeRes(); - await nodeHandler( - createFakeReq({ url: "/" }), - res as unknown as import("node:http").ServerResponse, - ); - - const cookies = collectSetCookies(res); - assertEquals(cookies.length, 2); - assertEquals(cookies.includes("a=1; Path=/"), true); - assertEquals(cookies.includes("b=2; Path=/"), true); -}); - -Deno.test("toNodeHandler does not throw when a Headers adapter omits getSetCookie", async () => { - // Simulate a compatible Headers adapter that omits getSetCookie. We wrap a - // real Headers in a Proxy that hides getSetCookie - // while still exposing an iterator that yields each Set-Cookie as a distinct - // entry (matching undici's iteration behaviour). A real Response is returned - // but with its `headers` accessor pointed at the legacy-like object. - const realHeaders = new Headers(); - realHeaders.append("Set-Cookie", "a=1; Path=/"); - realHeaders.append("Set-Cookie", "b=2; Path=/"); - realHeaders.set("content-type", "text/plain"); - - const legacyHeaders = new Proxy(realHeaders, { - get(target, prop, receiver) { - // Pretend getSetCookie does not exist on this runtime. - if (prop === "getSetCookie") return undefined; - const value = Reflect.get(target, prop, receiver); - return typeof value === "function" ? value.bind(target) : value; - }, - }) as unknown as Headers; - - const handler = () => { - const response = new Response("ok", { status: 200 }); - Object.defineProperty(response, "headers", { - get: () => legacyHeaders, - configurable: true, - }); - return response; - }; - - const nodeHandler = toNodeHandler(handler); - const res = createFakeRes(); - await nodeHandler( - createFakeReq({ url: "/" }), - res as unknown as import("node:http").ServerResponse, - ); - - // Must not have fallen into the catch block and emitted a 500. - assertEquals(res.statusCode, 200); - assertEquals(res.ended, true); - - // Fallback preserves both cookies when the iterator exposes them separately. - const cookies = collectSetCookies(res); - assertEquals(cookies.length, 2); - assertEquals(cookies.includes("a=1; Path=/"), true); - assertEquals(cookies.includes("b=2; Path=/"), true); -}); - -Deno.test("toNodeHandler passes array-valued request headers through to the Request", async () => { - let seen: string | null = null; - const handler = (req: Request) => { - seen = req.headers.get("x-multi"); - return new Response("ok", { status: 200 }); - }; - - const nodeHandler = toNodeHandler(handler); - const res = createFakeRes(); - await nodeHandler( - createFakeReq({ url: "/", headers: { "x-multi": ["one", "two"] } }), - res as unknown as import("node:http").ServerResponse, - ); - - // A collapsed-to-first-element bug would yield only "one". - assertEquals(seen, "one, two"); +describe("toNodeHandler", () => { + it("preserves multiple Set-Cookie headers as distinct values", async () => { + const handler = () => { + const headers = new Headers(); + headers.append("Set-Cookie", "a=1; Path=/"); + headers.append("Set-Cookie", "b=2; Path=/"); + return new Response("ok", { status: 200, headers }); + }; + + const nodeHandler = toNodeHandler(handler); + const res = createFakeRes(); + await nodeHandler( + createFakeReq({ url: "/" }), + res as unknown as import("node:http").ServerResponse, + ); + + const cookies = collectSetCookies(res); + assertEquals(cookies.length, 2); + assertEquals(cookies.includes("a=1; Path=/"), true); + assertEquals(cookies.includes("b=2; Path=/"), true); + }); + + it("does not throw when a Headers adapter omits getSetCookie", async () => { + // Simulate a compatible Headers adapter that omits getSetCookie. We wrap a + // real Headers in a Proxy that hides getSetCookie + // while still exposing an iterator that yields each Set-Cookie as a distinct + // entry (matching undici's iteration behaviour). A real Response is returned + // but with its `headers` accessor pointed at the legacy-like object. + const realHeaders = new Headers(); + realHeaders.append("Set-Cookie", "a=1; Path=/"); + realHeaders.append("Set-Cookie", "b=2; Path=/"); + realHeaders.set("content-type", "text/plain"); + + const legacyHeaders = new Proxy(realHeaders, { + get(target, prop, receiver) { + // Pretend getSetCookie does not exist on this runtime. + if (prop === "getSetCookie") return undefined; + const value = Reflect.get(target, prop, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }) as unknown as Headers; + + const handler = () => { + const response = new Response("ok", { status: 200 }); + Object.defineProperty(response, "headers", { + get: () => legacyHeaders, + configurable: true, + }); + return response; + }; + + const nodeHandler = toNodeHandler(handler); + const res = createFakeRes(); + await nodeHandler( + createFakeReq({ url: "/" }), + res as unknown as import("node:http").ServerResponse, + ); + + // Must not have fallen into the catch block and emitted a 500. + assertEquals(res.statusCode, 200); + assertEquals(res.ended, true); + + // Fallback preserves both cookies when the iterator exposes them separately. + const cookies = collectSetCookies(res); + assertEquals(cookies.length, 2); + assertEquals(cookies.includes("a=1; Path=/"), true); + assertEquals(cookies.includes("b=2; Path=/"), true); + }); + + it("passes array-valued request headers through to the Request", async () => { + let seen: string | null = null; + const handler = (req: Request) => { + seen = req.headers.get("x-multi"); + return new Response("ok", { status: 200 }); + }; + + const nodeHandler = toNodeHandler(handler); + const res = createFakeRes(); + await nodeHandler( + createFakeReq({ url: "/", headers: { "x-multi": ["one", "two"] } }), + res as unknown as import("node:http").ServerResponse, + ); + + // A collapsed-to-first-element bug would yield only "one". + assertEquals(seen, "one, two"); + }); + + it("records the native socket peer on the Web Request", async () => { + let sawLoopbackPeer = false; + const handler = (req: Request) => { + sawLoopbackPeer = isRequestFromLoopbackPeer(req); + return new Response("ok", { status: 200 }); + }; + + const nodeHandler = toNodeHandler(handler); + const res = createFakeRes(); + await nodeHandler( + createFakeReq({ url: "/_projects", remoteAddress: "127.0.0.1" }), + res as unknown as import("node:http").ServerResponse, + ); + + assertEquals(sawLoopbackPeer, true); + }); }); diff --git a/src/server/node-handler.ts b/src/server/node-handler.ts index 6e128b39e5..b41150f854 100644 --- a/src/server/node-handler.ts +++ b/src/server/node-handler.ts @@ -1,4 +1,5 @@ import { serverLogger } from "#veryfront/utils"; +import { recordNodeIncomingRequestPeer } from "#veryfront/platform/adapters/runtime/shared/request-peer.ts"; /** Convert a Web API request handler into a Node.js HTTP listener. */ export function toNodeHandler( @@ -23,7 +24,10 @@ export function toNodeHandler( }; if (body) init.duplex = "half"; - const response = await handler(new Request(url.toString(), init)); + const request = new Request(url.toString(), init); + recordNodeIncomingRequestPeer(request, req); + + const response = await handler(request); if (response.status === 101) return; const outHeaders: Record = {}; diff --git a/src/server/production-server.ts b/src/server/production-server.ts index fadd9d209c..3025bfdbb0 100644 --- a/src/server/production-server.ts +++ b/src/server/production-server.ts @@ -40,6 +40,7 @@ import { import { isSharedProjectRuntime } from "#veryfront/security/project-locality.ts"; import { getIsolationPosture } from "#veryfront/security/sandbox/worker-pool.ts"; import { runStartupDiscovery } from "./startup-discovery.ts"; +import { runRequestInterceptor } from "#veryfront/platform/adapters/runtime/shared/request-peer.ts"; const serverLog = logger.component("server"); const globalLog = logger.component("global"); @@ -320,7 +321,7 @@ export function startProductionServer( async (req: Request) => { const isWebSocketUpgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket"; if (isWebSocketUpgrade) return coreHandler(req); - return coreHandler(await requestInterceptor(req)); + return coreHandler(await runRequestInterceptor(req, requestInterceptor)); }, { ready: coreHandler.ready }, ) diff --git a/src/server/request-interceptor.test.ts b/src/server/request-interceptor.test.ts new file mode 100644 index 0000000000..39036650eb --- /dev/null +++ b/src/server/request-interceptor.test.ts @@ -0,0 +1,123 @@ +import { + assert, + assertEquals, + assertRejects, + assertStrictEquals, +} from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { + isRequestFromLoopbackPeer, + recordRequestPeerFromTransport, + runRequestInterceptor, +} from "#veryfront/platform/adapters/runtime/shared/request-peer.ts"; + +describe("runRequestInterceptor", () => { + it("preserves transport peer provenance when an interceptor replaces the request", async () => { + const request = new Request("http://localhost/"); + recordRequestPeerFromTransport(request, { + runtime: "deno", + transport: "tcp", + hostname: "127.0.0.1", + }); + + const intercepted = await runRequestInterceptor( + request, + (incoming) => new Request(incoming, { headers: { host: "localhost" } }), + ); + + assert(intercepted !== request); + assertEquals(isRequestFromLoopbackPeer(intercepted), true); + }); + + it("does not tee a one-owner streaming replacement body", async () => { + const request = new Request("http://localhost/"); + recordRequestPeerFromTransport(request, { + runtime: "deno", + transport: "tcp", + hostname: "127.0.0.1", + }); + const replacement = new Request( + "http://localhost/", + { + method: "POST", + duplex: "half", + body: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("payload")); + controller.close(); + }, + }), + } as RequestInit & { duplex: "half" }, + ); + + const intercepted = await runRequestInterceptor(request, () => replacement); + + assertStrictEquals(intercepted, replacement); + assertEquals(await intercepted.text(), "payload"); + }); + + it("rejects reuse before a replacement request can change provenance", async () => { + const remoteRequest = new Request("http://localhost/"); + recordRequestPeerFromTransport(remoteRequest, { + runtime: "deno", + transport: "tcp", + hostname: "192.0.2.10", + }); + const localRequest = new Request("http://localhost/"); + recordRequestPeerFromTransport(localRequest, { + runtime: "deno", + transport: "tcp", + hostname: "127.0.0.1", + }); + const sharedReplacement = new Request("http://localhost/", { + method: "POST", + body: "payload", + }); + const interceptor = () => sharedReplacement; + + const remoteIntercepted = await runRequestInterceptor(remoteRequest, interceptor); + + assertStrictEquals(remoteIntercepted, sharedReplacement); + assertEquals(isRequestFromLoopbackPeer(remoteIntercepted), false); + assertEquals(isRequestFromLoopbackPeer(sharedReplacement), false); + await assertRejects( + () => runRequestInterceptor(localRequest, interceptor), + TypeError, + "Request interceptors must return a fresh replacement Request", + ); + assertEquals(isRequestFromLoopbackPeer(remoteIntercepted), false); + assertEquals(await remoteIntercepted.text(), "payload"); + }); + + it("rejects reuse of a prior unchanged input request", async () => { + const remoteRequest = new Request("http://localhost/"); + recordRequestPeerFromTransport(remoteRequest, { + runtime: "deno", + transport: "tcp", + hostname: "192.0.2.10", + }); + const localRequest = new Request("http://localhost/"); + recordRequestPeerFromTransport(localRequest, { + runtime: "deno", + transport: "tcp", + hostname: "127.0.0.1", + }); + let firstInput: Request | undefined; + const interceptor = (request: Request) => { + if (firstInput) return firstInput; + firstInput = request; + return request; + }; + + const remoteIntercepted = await runRequestInterceptor(remoteRequest, interceptor); + + assertStrictEquals(remoteIntercepted, remoteRequest); + assertEquals(isRequestFromLoopbackPeer(remoteIntercepted), false); + await assertRejects( + () => runRequestInterceptor(localRequest, interceptor), + TypeError, + "Request interceptors must return a fresh replacement Request", + ); + assertEquals(isRequestFromLoopbackPeer(remoteIntercepted), false); + }); +}); diff --git a/src/server/runtime-handler/index.ts b/src/server/runtime-handler/index.ts index d1024a9c3f..7003bfc1f7 100644 --- a/src/server/runtime-handler/index.ts +++ b/src/server/runtime-handler/index.ts @@ -550,7 +550,12 @@ export function createVeryfrontHandler( // Handle projects discovery UI if ( - shouldHandleProjectsUI(url.pathname, projectRes.projectSlug, projectRes.parsedDomain) + shouldHandleProjectsUI( + request, + url.pathname, + projectRes.projectSlug, + projectRes.parsedDomain, + ) ) { const response = await handleProjectsRequest( request, diff --git a/src/server/runtime-handler/projects-handler.test.ts b/src/server/runtime-handler/projects-handler.test.ts new file mode 100644 index 0000000000..3b6c51bf8b --- /dev/null +++ b/src/server/runtime-handler/projects-handler.test.ts @@ -0,0 +1,49 @@ +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { describe, it } from "#veryfront/testing/bdd.ts"; +import { recordRequestPeerFromTransport } from "#veryfront/platform/adapters/runtime/shared/request-peer.ts"; +import { parseProjectDomain } from "#veryfront/server/utils/domain-parser.ts"; +import { shouldHandleProjectsUI } from "./projects-handler.ts"; + +function requestFromPeer(hostname: string): Request { + const request = new Request("http://localhost/", { + headers: { host: "localhost" }, + }); + recordRequestPeerFromTransport(request, { + runtime: "deno", + transport: "tcp", + hostname, + }); + return request; +} + +describe("shouldHandleProjectsUI", () => { + it("admits the project chooser only from a direct loopback peer", () => { + const parsedDomain = parseProjectDomain("localhost"); + + assertEquals( + shouldHandleProjectsUI(requestFromPeer("127.0.0.1"), "/", undefined, parsedDomain), + true, + ); + assertEquals( + shouldHandleProjectsUI(requestFromPeer("192.168.1.25"), "/", undefined, parsedDomain), + false, + ); + }); + + it("rejects proxy-marked local requests", () => { + const request = requestFromPeer("127.0.0.1"); + request.headers.set("x-forwarded-for", "203.0.113.8"); + + assertEquals( + shouldHandleProjectsUI( + request, + "/_projects/api/config", + undefined, + parseProjectDomain( + "localhost", + ), + ), + false, + ); + }); +}); diff --git a/src/server/runtime-handler/projects-handler.ts b/src/server/runtime-handler/projects-handler.ts index f5f68d0d17..7968f7a9ad 100644 --- a/src/server/runtime-handler/projects-handler.ts +++ b/src/server/runtime-handler/projects-handler.ts @@ -10,6 +10,7 @@ import { createFileSystem } from "#veryfront/platform/compat/fs.ts"; import { cwd } from "#veryfront/platform/compat/process.ts"; import { serverLogger } from "#veryfront/utils"; +import { isTrustedLocalControlRequest } from "#veryfront/security/http/local-control-request.ts"; import type { HandlerContext } from "../handlers/types.ts"; import { defaultDiscoveryCache, standardProjectDirs } from "./local-project-discovery.ts"; import type { ParsedDomain } from "../utils/domain-parser.ts"; @@ -20,6 +21,7 @@ const logger = serverLogger.component("projects-handler"); * Check if the request should be handled by the projects discovery UI. */ export function shouldHandleProjectsUI( + req: Request, pathname: string, projectSlug: string | undefined, parsedDomain: ParsedDomain, @@ -29,6 +31,7 @@ export function shouldHandleProjectsUI( pathname === "/_vf/api/projects"; return ( + isTrustedLocalControlRequest(req) && !projectSlug && !parsedDomain.slug && parsedDomain.isVeryfrontDomain && diff --git a/tests/deno-only-tests.mjs b/tests/deno-only-tests.mjs index c3659238e4..404eed23e2 100644 --- a/tests/deno-only-tests.mjs +++ b/tests/deno-only-tests.mjs @@ -8,10 +8,12 @@ * and it says nothing about files that are Deno-only by *subject* rather than * by which API they happen to call. * - * The pair below is both. It asserts a property of `deno test --parallel` - * itself: that test files sharing one process do not share a working directory. - * Node and Bun give each file its own process, so there is no property there to - * assert even if the APIs existed. + * The working-directory pair below is both. It asserts a property of + * `deno test --parallel` itself: that test files sharing one process do not + * share a working directory. Node and Bun give each file its own process, so + * there is no property there to assert even if the APIs existed. The dev-server + * integration imports Deno-only extension discovery through its module graph, + * which the source-file heuristic cannot see. * * Kept here rather than duplicated in each runner so the two cannot drift, and * so it can be tested -- see ./runtime-test-filters.test.ts. @@ -21,5 +23,6 @@ /** Glob patterns for tests that must not run outside Deno. */ export const DENO_ONLY_TESTS = [ + "src/server/dev-server/handler-only.integration.test.ts", "src/testing/cwd-exclusion-*.test.ts", ]; diff --git a/tests/integration/server/public-entrypoint.test.ts b/tests/integration/server/public-entrypoint.test.ts index 7679e25398..d3e28b8196 100644 --- a/tests/integration/server/public-entrypoint.test.ts +++ b/tests/integration/server/public-entrypoint.test.ts @@ -1,6 +1,6 @@ -import { assert, assertEquals } from "#veryfront/testing/assert"; +import { assert, assertEquals, assertNotEquals } from "#veryfront/testing/assert.ts"; import { join } from "#veryfront/compat/path"; -import { describe, it } from "#veryfront/testing/bdd"; +import { describe, it } from "#veryfront/testing/bdd.ts"; import { writeTextFile } from "#veryfront/compat/fs.ts"; import { withTestContext } from "../../_helpers/context.ts"; @@ -53,6 +53,117 @@ describe("Server Public Entrypoints", { sanitizeResources: false, sanitizeOps: f }); }); + it("records Deno serve peer provenance in the public handler path", async () => { + const { createHandler } = await import("veryfront"); + + await withTestContext("public-handler-deno-peer-provenance", async (context) => { + const handler = await createHandler({ + projectDir: context.projectDir, + port: await context.allocatePort(), + }); + + try { + const localResponse = await handler( + new Request("http://localhost/_metrics", { + headers: { host: "localhost" }, + }), + { + remoteAddr: { + transport: "tcp", + hostname: "127.0.0.1", + port: 52_000, + }, + }, + ); + assertEquals(localResponse.status, 200); + + const remoteResponse = await handler( + new Request("http://localhost/_metrics", { + headers: { + host: "localhost", + "x-forwarded-for": "127.0.0.1", + }, + }), + { + remoteAddr: { + transport: "tcp", + hostname: "192.168.1.25", + port: 52_001, + }, + }, + ); + assertNotEquals(remoteResponse.status, 200); + + const spoofedResponse = await handler( + new Request("http://localhost/_metrics", { + headers: { + host: "localhost", + "x-real-ip": "127.0.0.1", + }, + }), + { + remoteAddr: { + transport: "tcp", + hostname: "127.0.0.1", + port: 52_002, + }, + }, + ); + assertNotEquals(spoofedResponse.status, 200); + } finally { + await handler.dispose(); + } + }); + }); + + it("records Bun serve peer provenance in the public handler path", async () => { + const { createHandler } = await import("veryfront"); + + await withTestContext("public-handler-bun-peer-provenance", async (context) => { + const handler = await createHandler({ + projectDir: context.projectDir, + port: await context.allocatePort(), + }); + + try { + const localRequest = new Request("http://localhost/_metrics", { + headers: { host: "localhost" }, + }); + const localResponse = await handler(localRequest, { + requestIP(seenRequest: Request) { + assertEquals(seenRequest, localRequest); + return { + address: "127.0.0.1", + port: 52_000, + family: "IPv4", + }; + }, + }); + assertEquals(localResponse.status, 200); + + const remoteRequest = new Request("http://localhost/_metrics", { + headers: { + host: "localhost", + "x-forwarded-for": "127.0.0.1", + }, + }); + const remoteResponse = await handler(remoteRequest, { + requestIP(seenRequest: Request) { + assertEquals(seenRequest, remoteRequest); + return { + address: "192.168.1.25", + port: 52_001, + family: "IPv4", + }; + }, + }); + assertNotEquals(remoteResponse.status, 200); + } finally { + await handler.dispose(); + } + }); + }); + it("starts production server via package server subpath", async () => { const { startProductionServer } = await import("veryfront/server"); diff --git a/tests/runtime-test-filters.test.ts b/tests/runtime-test-filters.test.ts index 7c152b02fa..de646ba7a4 100644 --- a/tests/runtime-test-filters.test.ts +++ b/tests/runtime-test-filters.test.ts @@ -2,10 +2,9 @@ * The Node and Bun runners must keep the Deno-only tests out, and everything * else in. * - * Both halves matter. A filter that excludes too little lets - * `src/testing/cwd-exclusion-*.test.ts` run on a runtime without `Deno.chdir`, - * which is the regression this list was added for. A filter that excludes too - * much silently shrinks the suite, which nothing else would notice. + * Both halves matter. A filter that excludes too little lets a Deno-only test + * run on an incompatible runtime. A filter that excludes too much silently + * shrinks the suite, which nothing else would notice. * * The list is easy to break by accident: renaming those files, or moving them * out of `src/testing/`, leaves a pattern matching nothing and the runner fails @@ -22,6 +21,7 @@ import { filterTestFiles } from "./test-file-utils.mjs"; /** The files the shared list exists to exclude. */ const DENO_ONLY_FILES = [ + "src/server/dev-server/handler-only.integration.test.ts", "src/testing/cwd-exclusion-a.test.ts", "src/testing/cwd-exclusion-b.test.ts", ]; @@ -38,7 +38,7 @@ describe("runtime test filters", () => { it("excludes the Deno-only tests from non-Deno runners", () => { const kept = filterTestFiles(DENO_ONLY_FILES, { exclude: DENO_ONLY_TESTS }); - assertEquals(kept, [], "these cannot run without Deno.chdir"); + assertEquals(kept, [], "these cannot run outside Deno"); }); it("keeps every other test eligible", () => {