diff --git a/README.md b/README.md index 5e1dd44..e5fffe6 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,10 @@ import { serve } from "@askrjs/node"; const running = await serve(app, { port: 3000, - assets: { root: "./dist/client" }, + assets: { + root: "./dist/client", + exclude: (pathname) => pathname.startsWith("/files/"), + }, }); await running.close(); @@ -103,7 +106,9 @@ await running.close(); `serve` handles static assets and closes both the HTTP server and the application during shutdown. When an asset root is configured, extension-bearing `GET` and `HEAD` paths are reserved for static files: missing files return `404` without falling through to application routing, source maps are not -served, and resolved files must remain inside the configured root. Fingerprinted files under +served, and resolved files must remain inside the configured root. Use `assets.exclude(pathname)` to +bypass static interception for dotted application routes; the predicate receives the decoded URL +pathname. Fingerprinted files under `/assets/` receive immutable caching; other files receive `no-cache`. Both `listen` and `serve` bind to `127.0.0.1` by default. A non-loopback `host` also requires `allowPublicBind: true` so public exposure is explicit. Public listeners should diff --git a/src/contracts.ts b/src/contracts.ts index 93fd256..499b8fe 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -48,7 +48,11 @@ export interface ListenOptions extends NodeHandlerOptions { /** Options for {@link serve}, extending {@link ListenOptions} with static asset serving and shutdown behavior. */ export interface ServeOptions extends ListenOptions { /** Serves static files from this directory before falling back to the application. */ - readonly assets?: { readonly root: string }; + readonly assets?: { + readonly root: string; + /** Returns true when an extension-bearing path must bypass static serving. */ + readonly exclude?: (pathname: string) => boolean; + }; /** OS signals that trigger a graceful shutdown; pass `false` to disable automatic shutdown handling. */ readonly signals?: false | readonly NodeJS.Signals[]; } diff --git a/src/serve.ts b/src/serve.ts index dac4c35..ee03873 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -5,6 +5,7 @@ import { createServer } from "node:http"; import { extname, resolve, sep } from "node:path"; import { pipeline } from "node:stream"; import type { ServerApp } from "@askrjs/server"; +import * as serverHttp from "@askrjs/server/http"; import type { ServeOptions, ServedApplication } from "./contracts.js"; import { formatHostForUrl, handlerOptionsForHost, resolveBindHost } from "./bind.js"; import { createNodeHandler } from "./handler.js"; @@ -36,6 +37,15 @@ const mimeTypes: Readonly> = { ".woff2": "font/woff2", }; +const parseContentType = + (serverHttp as { contentType?: (value: string | null) => string | undefined }).contentType ?? + ((value: string | null): string | undefined => { + if (!value) return undefined; + const separator = value.indexOf(";"); + const type = (separator === -1 ? value : value.slice(0, separator)).trim(); + return type ? type.toLowerCase() : undefined; + }); + function isAssetPath(pathname: string): boolean { return extname(pathname) !== ""; } @@ -78,12 +88,8 @@ export async function serve( { async fetch(request, dispatchOptions) { const result = await app.fetch(request, dispatchOptions); - const contentType = result.headers.get("content-type"); - if ( - !result.headers.has("cache-control") && - contentType !== null && - /^text\/html(?:;|$)/iu.test(contentType) - ) { + const responseContentType = parseContentType(result.headers.get("content-type")); + if (!result.headers.has("cache-control") && responseContentType === "text/html") { const headers = new Headers(result.headers); headers.set("cache-control", "no-cache"); return new Response(result.body, { @@ -112,7 +118,11 @@ export async function serve( return; } const method = request.method ?? "GET"; - if ((method === "GET" || method === "HEAD") && isAssetPath(pathname)) { + if ( + (method === "GET" || method === "HEAD") && + isAssetPath(pathname) && + !options.assets?.exclude?.(pathname) + ) { const extension = extname(pathname).toLowerCase(); const unresolvedCandidate = resolve(root, `.${pathname}`); const inside = isWithinRoot(root, unresolvedCandidate); diff --git a/tests/node.test.ts b/tests/node.test.ts index 3c6aca9..6992c85 100644 --- a/tests/node.test.ts +++ b/tests/node.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { get, request as nodeRequest, type ServerResponse } from "node:http"; import { createConnection } from "node:net"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { createRouter, createServerApp } from "@askrjs/server"; import { runAdapterConformance } from "@askrjs/server/testing"; import { describe, expect, it } from "vitest"; @@ -242,6 +242,40 @@ describe("Node adapter", () => { expect(() => listen(app, { headersTimeout: 1.5 })).toThrow("non-negative safe integer"); }); + it("should reject malformed and oversized raw HTTP input before application dispatch", async () => { + let dispatches = 0; + const server = await listen({ + fetch: async () => { + dispatches += 1; + return new Response(); + }, + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected TCP address"); + const sendRaw = (payload: string) => + new Promise((resolve, reject) => { + const socket = createConnection({ host: "127.0.0.1", port: address.port }); + let response = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => (response += chunk)); + socket.once("connect", () => socket.end(payload)); + socket.once("end", () => resolve(response)); + socket.once("error", reject); + }); + + try { + await expect(sendRaw("GET / HTTP/1.1\r\nMalformed Header\r\n\r\n")).resolves.toMatch( + /^HTTP\/1\.1 400 /, + ); + await expect( + sendRaw(`GET / HTTP/1.1\r\nHost: localhost\r\nX-Oversized: ${"x".repeat(20_000)}\r\n\r\n`), + ).resolves.toMatch(/^HTTP\/1\.1 431 /); + expect(dispatches).toBe(0); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it("should exchange text and binary WebSocket messages", async () => { const router = createRouter(); router.ws("/echo/{room}", (socket, context) => { @@ -1031,6 +1065,50 @@ describe("serve", () => { } }); + it("should let excluded dotted routes bypass static serving under concurrency", async () => { + const root = await mkdtemp(join(tmpdir(), "askr-node-excluded-assets-")); + await writeFile(join(root, "app.js"), "asset"); + let applicationRequests = 0; + const served = await serve( + { + fetch: async (request) => { + applicationRequests += 1; + return new Response(new URL(request.url).pathname); + }, + }, + { + assets: { + root, + exclude: (pathname) => pathname.startsWith("/files/"), + }, + signals: false, + }, + ); + + try { + const responses = await Promise.all( + Array.from({ length: 30 }, (_, index) => + fetch( + index % 3 === 0 + ? `${served.url}/app.js` + : index % 3 === 1 + ? `${served.url}/files/report.json` + : `${served.url}/files/report%2ejson`, + ), + ), + ); + const bodies = await Promise.all(responses.map((response) => response.text())); + + expect(bodies.filter((body) => body === "asset")).toHaveLength(10); + expect(bodies.filter((body) => body === "/files/report.json")).toHaveLength(10); + expect(bodies.filter((body) => body === "/files/report%2ejson")).toHaveLength(10); + expect(applicationRequests).toBe(20); + } finally { + await served.close(); + await rm(root, { recursive: true, force: true }); + } + }); + it("should disable caching given an HTML application response when no policy is authored", async () => { const served = await serve( { @@ -1072,6 +1150,51 @@ describe("serve", () => { }, ); + it("should contain percent-encoded parent traversal inside the static root", async () => { + const root = await mkdtemp(join(tmpdir(), "askr-node-traversal-root-")); + const outside = await mkdtemp(join(tmpdir(), "askr-node-traversal-outside-")); + await writeFile(join(outside, "secret.txt"), "secret"); + const served = await serve( + { fetch: async () => new Response("application") }, + { assets: { root }, signals: false }, + ); + const address = served.server.address(); + if (!address || typeof address === "string") throw new Error("Expected TCP address"); + const requestPath = (path: string) => + new Promise<{ status: number; body: string }>((resolve, reject) => { + const request = nodeRequest({ host: "127.0.0.1", port: address.port, path }, (response) => { + const chunks: Buffer[] = []; + response.on("data", (chunk: Buffer) => chunks.push(chunk)); + response.on("end", () => + resolve({ + status: response.statusCode ?? 0, + body: Buffer.concat(chunks).toString(), + }), + ); + }); + request.once("error", reject); + request.end(); + }); + + try { + const outsideName = basename(outside); + await expect(requestPath(`/%2e%2e/${outsideName}/secret.txt`)).resolves.toEqual({ + status: 404, + body: "Not Found", + }); + await expect(requestPath(`/%2e%2e%2f${outsideName}/secret.txt`)).resolves.toEqual({ + status: 404, + body: "Not Found", + }); + } finally { + await served.close(); + await Promise.all([ + rm(root, { recursive: true, force: true }), + rm(outside, { recursive: true, force: true }), + ]); + } + }); + it("should close the application exactly once across concurrent shutdown", async () => { let closes = 0; const served = await serve( @@ -1082,6 +1205,36 @@ describe("serve", () => { expect(closes).toBe(1); }); + it("should wait for an in-flight request before completing shutdown", async () => { + let markStarted!: () => void; + let release!: () => void; + const started = new Promise((resolve) => (markStarted = resolve)); + const released = new Promise((resolve) => (release = resolve)); + const served = await serve( + { + async fetch() { + markStarted(); + await released; + return new Response("done"); + }, + }, + { signals: false }, + ); + const response = fetch(served.url); + await started; + let closed = false; + const closing = served.close().then(() => { + closed = true; + }); + await Promise.resolve(); + expect(closed).toBe(false); + + release(); + expect(await (await response).text()).toBe("done"); + await closing; + expect(closed).toBe(true); + }); + it("should close the application given a server when it was already stopped externally", async () => { let closes = 0; const served = await serve(