diff --git a/CHANGES.md b/CHANGES.md index 83546760..2f2d29ea 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -146,6 +146,23 @@ To be released. 14 adds post-quantum ML-DSA passkey support on runtimes that provide the algorithms. \[[GHSA-2g3p-m8c9-hhwh], [GHSA-j3h4-m3m2-7p7j]] + - Changed the HTTP server to avoid buffering entire uploads and incoming + activities before handling them behind a reverse proxy. Shutdown now waits + for active HTTP requests alongside background workers within the existing + 30-second deadline. + + Responses now avoid creating a web stream for strings and byte buffers + until one is needed. + + Static files now support conditional requests with `ETag` and + `Last-Modified` validators. Hidden files and symlinks pointing outside the + configured storage directory are no longer served. + + Proxy mode now uses the first nonempty forwarded host and protocol in a + comma-separated list, and ignores invalid forwarded hosts in favor of the + original `Host` header. Trusted proxies must overwrite client-supplied + forwarded headers. [[#672], [#673]] + [FEP-044f]: https://w3id.org/fep/044f [FEP-c0e0]: https://w3id.org/fep/c0e0 [FEP-f228]: https://w3id.org/fep/f228 @@ -175,6 +192,8 @@ To be released. [#661]: https://github.com/fedify-dev/hollo/issues/661 [#663]: https://github.com/fedify-dev/hollo/pull/663 [#664]: https://github.com/fedify-dev/hollo/pull/664 +[#672]: https://github.com/fedify-dev/hollo/issues/672 +[#673]: https://github.com/fedify-dev/hollo/pull/673 Version 0.9.24 diff --git a/bin/server.ts b/bin/server.ts index d713c62d..50e2e268 100644 --- a/bin/server.ts +++ b/bin/server.ts @@ -1,12 +1,9 @@ import { isIP } from "node:net"; -import type { ServerType } from "@hono/node-server"; -import { serve } from "@hono/node-server"; -import { behindProxy } from "x-forwarded-fetch"; - import "../src/logging"; import { checkHandleHostConsistency } from "../src/handle-host-check"; import { configureSentry } from "../src/sentry"; +import { createServerLifecycle } from "../src/server-lifecycle"; // oxlint-disable-next-line typescript/dot-notation configureSentry(process.env["SENTRY_DSN"]); @@ -42,95 +39,109 @@ if (!["all", "web", "worker"].includes(NODE_TYPE)) { process.exit(1); } -// Warn if the configured HANDLE_HOST disagrees with an existing account. -await checkHandleHostConsistency(); - -let webServer: ServerType | undefined; - -// Start web server if running as web or all node -if (NODE_TYPE === "web" || NODE_TYPE === "all") { - const { default: app } = await import("../src/index"); - webServer = serve( - { - fetch: BEHIND_PROXY - ? behindProxy(app.fetch.bind(app)) - : app.fetch.bind(app), +const lifecycle = createServerLifecycle([ + async () => { + // Warn if HANDLE_HOST disagrees with an existing account. + await checkHandleHostConsistency(); + }, + async (registerStop) => { + if (NODE_TYPE === "worker") return; + // Initialize Sentry before loading the HTTP adapter and application. + const { createHttpServer, enableFastResponse } = + await import("../src/http-server"); + enableFastResponse(); + const { default: app } = await import("../src/index"); + if (lifecycle.stopping) return; + const server = createHttpServer(app.fetch.bind(app), { port: PORT, hostname: BIND, - }, - (info) => { - let host = info.address; - // We override it here to show localhost instead of what it resolves to: - if (BIND === "localhost") { - host = "localhost"; - } else if (info.family === "IPv6") { - host = `[${info.address}]`; + behindProxy: BEHIND_PROXY, + }); + registerStop(() => server.close()); + await server.ready(); + if (lifecycle.stopping) return; + const url = new URL(server.url!); + if (BIND === "localhost") url.hostname = "localhost"; + console.log(`Listening on ${url.href}`); + }, + async (registerStop) => { + if (NODE_TYPE === "web") return; + const [ + { federation }, + { backgroundJobs, replyScrapes, pollNotifications }, + { itemLeases }, + { WEB_ORIGIN }, + ] = await Promise.all([ + import("../src/federation"), + import("../src/federation/federation"), + import("../src/background/lease"), + import("../src/env"), + ]); + if (lifecycle.stopping) return; + + const controller = new AbortController(); + let queue: Promise | undefined; + let stopRecovery: (() => Promise) | undefined; + // Publish cleanup before any fallible worker initialization. + registerStop(async () => { + controller.abort(); + try { + await stopRecovery?.(); + await queue; + } finally { + await itemLeases.close(); } - - console.log(`Listening on http://${host}:${info.port}/`); - }, - ); -} - -// Start workers if running as worker or all node -let stopWorkers: (() => Promise) | undefined; -if (NODE_TYPE === "worker" || NODE_TYPE === "all") { - const [ - { federation }, - { backgroundJobs, replyScrapes, pollNotifications }, - { itemLeases }, - ] = await Promise.all([ - import("../src/federation"), - import("../src/federation/federation"), - import("../src/background/lease"), - ]); - - // Start the Fedify message queue - const controller = new AbortController(); - backgroundJobs.setSignal(controller.signal); - replyScrapes.setSignal(controller.signal); - pollNotifications.setSignal(controller.signal); - const queue = federation - .startQueue(undefined, { signal: controller.signal }) - .catch((error) => { - console.error("Error starting Fedify queue:", error); - process.exit(1); }); - - // Start the workers for background job processing - const { WEB_ORIGIN } = await import("../src/env"); - const stopRecovery = backgroundJobs.startRecovery( - federation.createContext( - new URL(WEB_ORIGIN ?? "http://localhost"), - undefined, - ), - controller.signal, - ); - - stopWorkers = async () => { - controller.abort(); - await stopRecovery(); - await queue; - await itemLeases.close(); - }; - console.log("Worker started (Fedify activity/task queues + Job recovery)"); -} - -// Graceful shutdown handling -let shuttingDown = false; -const shutdown = async () => { - if (shuttingDown) return; - shuttingDown = true; + backgroundJobs.setSignal(controller.signal); + replyScrapes.setSignal(controller.signal); + pollNotifications.setSignal(controller.signal); + queue = federation + .startQueue(undefined, { signal: controller.signal }) + .catch((error) => { + console.error("Error starting Fedify queue:", error); + void shutdown(1); + }); + stopRecovery = backgroundJobs.startRecovery( + federation.createContext( + new URL(WEB_ORIGIN ?? "http://localhost"), + undefined, + ), + controller.signal, + ); + console.log("Worker started (Fedify activity/task queues + Job recovery)"); + }, +]); + +let exitCode = 0; +let shutdownPromise: Promise | undefined; +/** Stop acquired resources once, forcing exit if cleanup exceeds 30 seconds. */ +function shutdown(code = 0): Promise { + exitCode = Math.max(exitCode, code); + if (shutdownPromise) return shutdownPromise; // Forced termination releases DB locks; durable unfinished items recover on // restart. Do not reuse a slot while its external operation is still alive. - const deadline = setTimeout(() => process.exit(0), 30_000); + const deadline = setTimeout(() => process.exit(exitCode), 30_000); deadline.unref(); - webServer?.close(); - await stopWorkers?.(); - // The legacy federation queue does not drain its handlers. Keep the main - // client available until process exit rather than rejecting their queries. - process.exit(0); -}; + shutdownPromise = lifecycle.stop().then( + () => { + // The legacy federation queue does not drain its handlers. Keep the main + // client available until process exit rather than rejecting their queries. + process.exit(exitCode); + }, + (error) => { + console.error("Error stopping server:", error); + process.exit(1); + }, + ); + return shutdownPromise; +} -process.on("SIGTERM", shutdown); -process.on("SIGINT", shutdown); +// Handle signals even during asynchronous startup. +process.on("SIGTERM", () => void shutdown()); +process.on("SIGINT", () => void shutdown()); +try { + await lifecycle.start(); +} catch (error) { + console.error("Error starting server:", error); + await shutdown(1); +} diff --git a/changes.d/streaming-http-server.md b/changes.d/streaming-http-server.md new file mode 100644 index 00000000..38eacc49 --- /dev/null +++ b/changes.d/streaming-http-server.md @@ -0,0 +1,21 @@ +--- +links: + '#672': https://github.com/fedify-dev/hollo/issues/672 + '#673': https://github.com/fedify-dev/hollo/pull/673 +--- + - Changed the HTTP server to avoid buffering entire uploads and incoming + activities before handling them behind a reverse proxy. Shutdown now waits + for active HTTP requests alongside background workers within the existing + 30-second deadline. + + Responses now avoid creating a web stream for strings and byte buffers + until one is needed. + + Static files now support conditional requests with `ETag` and + `Last-Modified` validators. Hidden files and symlinks pointing outside the + configured storage directory are no longer served. + + Proxy mode now uses the first nonempty forwarded host and protocol in a + comma-separated list, and ignores invalid forwarded hosts in favor of the + original `Host` header. Trusted proxies must overwrite client-supplied + forwarded headers. [[#672], [#673]] diff --git a/docs/src/content/docs/install/env.mdx b/docs/src/content/docs/install/env.mdx index c8d340b0..94d07fa9 100644 --- a/docs/src/content/docs/install/env.mdx +++ b/docs/src/content/docs/install/env.mdx @@ -84,6 +84,12 @@ Turned off by default. security reasons. +When enabled, Hollo uses the first nonempty value in comma-separated +`X-Forwarded-Host` and `X-Forwarded-Proto` headers. Invalid forwarded hosts +are ignored in favor of the original `Host` header. Configure your proxy to +replace client-supplied forwarded headers and allow only trusted proxies to +connect directly to Hollo. + ### `HANDLE_HOST` The fediverse handle domain when running a [split-domain WebFinger diff --git a/docs/src/content/docs/ja/install/env.mdx b/docs/src/content/docs/ja/install/env.mdx index b3039cf2..ee4a2065 100644 --- a/docs/src/content/docs/ja/install/env.mdx +++ b/docs/src/content/docs/ja/install/env.mdx @@ -82,6 +82,11 @@ HolloがL7ロードバランサーの後ろにある場合(通常はそうす この動作はセキュリティ上注意が必要です。 +有効にすると、カンマ区切りの `X-Forwarded-Host` と `X-Forwarded-Proto` +ヘッダーの最初の空でない値を使用します。無効な転送ホストは無視され、元の +`Host` ヘッダーが使用されます。クライアントが送信した転送ヘッダーをプロキシで +上書きし、信頼できるプロキシだけが Hollo に直接接続できるようにしてください。 + ### `HANDLE_HOST` [ドメイン分離 WebFinger 設定][split-domain]で使用するフェディバースの diff --git a/docs/src/content/docs/ko/install/env.mdx b/docs/src/content/docs/ko/install/env.mdx index 78b896cb..f20cc03d 100644 --- a/docs/src/content/docs/ko/install/env.mdx +++ b/docs/src/content/docs/ko/install/env.mdx @@ -80,6 +80,11 @@ Hollo가 L7 로드 밸런서 뒤에 위치할 경우 (일반적으로 그래야 이 동작은 보안상 주의를 기울여야 합니다. +이 옵션을 켜면, 쉼표로 구분된 `X-Forwarded-Host`와 `X-Forwarded-Proto` +헤더에서 첫 번째 비어 있지 않은 값을 사용합니다. 유효하지 않은 전달 호스트는 +무시하고 원래 `Host` 헤더를 사용합니다. 프록시가 클라이언트가 보낸 전달 헤더를 +덮어쓰도록 설정하고, 신뢰할 수 있는 프록시만 Hollo에 직접 연결할 수 있게 하세요. + ### `HANDLE_HOST` [도메인 분리 WebFinger 설정][split-domain]에서 사용할 페디버스 핸들 diff --git a/docs/src/content/docs/zh-cn/install/env.mdx b/docs/src/content/docs/zh-cn/install/env.mdx index afc1762a..baac7a5b 100644 --- a/docs/src/content/docs/zh-cn/install/env.mdx +++ b/docs/src/content/docs/zh-cn/install/env.mdx @@ -73,6 +73,10 @@ openssl rand -hex 32 启用此选项后,Hollo将信任来自反向代理的`X-Forwarded-For`、`X-Forwarded-Proto`和`X-Forwarded-Host`头。这对于安全来说非常重要。 +启用后,Hollo 使用逗号分隔的 `X-Forwarded-Host` 和 `X-Forwarded-Proto` +头中的第一个非空值。无效的转发主机将被忽略,并使用原始 `Host` 头。 +请配置代理以覆盖客户端提供的转发头,并仅允许受信任的代理直接连接到 Hollo。 + ### `HANDLE_HOST` 在[分域 WebFinger 配置][split-domain]中用作联邦宇宙账号句柄的域名。 diff --git a/docs/src/content/docs/zh-tw/install/env.mdx b/docs/src/content/docs/zh-tw/install/env.mdx index 2ceec6c9..99f56083 100644 --- a/docs/src/content/docs/zh-tw/install/env.mdx +++ b/docs/src/content/docs/zh-tw/install/env.mdx @@ -73,6 +73,10 @@ openssl rand -hex 32 啟用此選項後,Hollo 將信任來自反向代理的 `X-Forwarded-For`、`X-Forwarded-Proto` 和 `X-Forwarded-Host` 標頭。這對於安全來說非常重要。 +啟用後,Hollo 使用以逗號分隔的 `X-Forwarded-Host` 和 `X-Forwarded-Proto` +標頭中的第一個非空值。無效的轉送主機會被忽略,並使用原始 `Host` 標頭。 +請設定代理以覆寫用戶端提供的轉送標頭,並只允許受信任的代理直接連線至 Hollo。 + ### `HANDLE_HOST` 在[分域 WebFinger 設定][split-domain]中用作聯邦宇宙帳號句柄的網域名稱。 diff --git a/package.json b/package.json index 785a9d4a..ec17d4ec 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,6 @@ "@gukhanmun/opendict-fst": "^0.2.0", "@gukhanmun/stdict-fst": "^0.2.0", "@hexagon/base64": "^2.0.4", - "@hono/node-server": "^2.1.4", "@hono/zod-validator": "^0.9.1", "@js-temporal/polyfill": "^0.5.0", "@logtape/drizzle-orm": "~2.3.10", @@ -84,10 +83,10 @@ "qrcode": "^1.5.4", "semver": "^7.8.1", "sharp": "^0.35.5", + "srvx": "^1.0.6", "ssrfcheck": "^1.4.0", "tsx": "^4.23.15", "uuidv7-js": "^1.1.4", - "x-forwarded-fetch": "^0.2.0", "xss": "^1.0.15", "zod": "^4.6.5" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62501c1c..62f08ced 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -218,9 +218,6 @@ importers: '@hexagon/base64': specifier: ^2.0.4 version: 2.0.4 - '@hono/node-server': - specifier: ^2.1.4 - version: 2.1.4(hono@4.13.12) '@hono/zod-validator': specifier: ^0.9.1 version: 0.9.1(hono@4.13.12)(zod@4.6.5) @@ -332,6 +329,9 @@ importers: sharp: specifier: ^0.35.5 version: 0.35.5(@types/node@26.1.1) + srvx: + specifier: ^1.0.6 + version: 1.0.6 ssrfcheck: specifier: ^1.4.0 version: 1.4.0 @@ -341,9 +341,6 @@ importers: uuidv7-js: specifier: ^1.1.4 version: 1.1.4 - x-forwarded-fetch: - specifier: ^0.2.0 - version: 0.2.0 xss: specifier: ^1.0.15 version: 1.0.15 @@ -410,7 +407,7 @@ importers: version: '@typescript/typescript6@6.0.2' unocss: specifier: ^66.10.5 - version: 66.10.5(cac@7.0.0)(rolldown@1.2.0)(rollup@4.62.2)(srvx@1.0.5)(vite@8.2.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.0)) + version: 66.10.5(cac@7.0.0)(rolldown@1.2.0)(rollup@4.62.2)(srvx@1.0.6)(vite@8.2.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.0)) vitest: specifier: ^5.0.2 version: 5.0.2(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-v8@5.0.2)(vite@8.2.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.0)) @@ -1469,12 +1466,6 @@ packages: '@hexagon/base64@2.0.4': resolution: {integrity: sha512-H/ZY6rGyaEuk0mwQgZ3BVi9hMjFTYpBNFbmtOuec/pPibuGhCMXd8fGtwBaO0h44FkWMurysMsDrpkJsBRmoWQ==} - '@hono/node-server@2.1.4': - resolution: {integrity: sha512-2g1qeHl4BduwDn6L8nkAvDrPUWPMlkQ//a5NhP/zIyaXKMjZNRV+cG9Csu+BiJFf6+68pqzjf4Hi5sJGKHuS1g==} - engines: {node: '>=20'} - peerDependencies: - hono: ^4 || ^5.0.0-0 - '@hono/zod-validator@0.9.1': resolution: {integrity: sha512-iiv6w0qrIc0arfvCtUqBWsvl4fXjzaTcQcCJTTtCnkawF9HHGE+KjEl0ox3gQJ6rKZEgE3mLExlQCF72M+mNuw==} peerDependencies: @@ -5744,8 +5735,8 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} - srvx@1.0.5: - resolution: {integrity: sha512-KvSKRpgPG/oaq3cyT614OQ2bAa7DynuGamivCZeoZzIUULOdbQz6wUtnnVuC4+DCx2Zglzo8v5gBYmWIWYhDxA==} + srvx@1.0.6: + resolution: {integrity: sha512-QeTKgAhk8OSsfeq60bl3wuVs9PmBftzmk7owBInA/F1YQne+TRw0UcPymNpURldAmx8hfmM2PNNGH/zgo3GpLA==} engines: {node: '>=20.16.0'} hasBin: true @@ -6399,9 +6390,6 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} - x-forwarded-fetch@0.2.0: - resolution: {integrity: sha512-2qsguQrLHFvP3/rnvxSSaw3DlK9eOOx9iyL+Qv+1YH8jzq6nbRk+P6gyFFNdWanyusyCHuE3/CnX3+gmLeYEeg==} - xss@1.0.15: resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==} engines: {node: '>= 0.10.0'} @@ -7958,10 +7946,6 @@ snapshots: '@hexagon/base64@2.0.4': {} - '@hono/node-server@2.1.4(hono@4.13.12)': - dependencies: - hono: 4.13.12 - '@hono/zod-validator@0.9.1(hono@4.13.12)(zod@4.6.5)': dependencies: hono: 4.13.12 @@ -9777,12 +9761,12 @@ snapshots: dependencies: '@unocss/core': 66.10.5 - '@unocss/inspector@66.10.5(cac@7.0.0)(srvx@1.0.5)': + '@unocss/inspector@66.10.5(cac@7.0.0)(srvx@1.0.6)': dependencies: '@unocss/config': 66.10.5 '@unocss/core': 66.10.5 '@unocss/rule-utils': 66.10.5 - devframe: 1.1.0(cac@7.0.0)(srvx@1.0.5) + devframe: 1.1.0(cac@7.0.0)(srvx@1.0.6) tinyglobby: 0.2.17 unplugin-utils: 0.3.2 transitivePeerDependencies: @@ -9882,12 +9866,12 @@ snapshots: dependencies: '@unocss/core': 66.10.5 - '@unocss/vite@66.10.5(cac@7.0.0)(srvx@1.0.5)(vite@8.2.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.0))': + '@unocss/vite@66.10.5(cac@7.0.0)(srvx@1.0.6)(vite@8.2.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.0))': dependencies: '@jridgewell/remapping': 2.3.5 '@unocss/config': 66.10.5 '@unocss/core': 66.10.5 - '@unocss/inspector': 66.10.5(cac@7.0.0)(srvx@1.0.5) + '@unocss/inspector': 66.10.5(cac@7.0.0)(srvx@1.0.6) chokidar: 5.0.0 magic-string: 1.4.2 pathe: 2.0.3 @@ -10398,9 +10382,9 @@ snapshots: dependencies: uncrypto: 0.1.3 - crossws@0.4.12(srvx@1.0.5): + crossws@0.4.12(srvx@1.0.6): optionalDependencies: - srvx: 1.0.5 + srvx: 1.0.6 css-select@5.2.2: dependencies: @@ -10498,10 +10482,10 @@ snapshots: devalue@5.9.2: {} - devframe@1.1.0(cac@7.0.0)(srvx@1.0.5): + devframe@1.1.0(cac@7.0.0)(srvx@1.0.6): dependencies: - crossws: 0.4.12(srvx@1.0.5) - h3: 2.0.1-rc.32(crossws@0.4.12(srvx@1.0.5)) + crossws: 0.4.12(srvx@1.0.6) + h3: 2.0.1-rc.32(crossws@0.4.12(srvx@1.0.6)) nostics: 1.3.0 pathe: 2.0.3 optionalDependencies: @@ -10907,12 +10891,12 @@ snapshots: ufo: 1.6.4 uncrypto: 0.1.3 - h3@2.0.1-rc.32(crossws@0.4.12(srvx@1.0.5)): + h3@2.0.1-rc.32(crossws@0.4.12(srvx@1.0.6)): dependencies: rou3: 0.9.2 - srvx: 1.0.5 + srvx: 1.0.6 optionalDependencies: - crossws: 0.4.12(srvx@1.0.5) + crossws: 0.4.12(srvx@1.0.6) has-flag@4.0.0: {} @@ -12223,7 +12207,7 @@ snapshots: sprintf-js@1.1.3: {} - srvx@1.0.5: {} + srvx@1.0.6: {} ssrfcheck@1.4.0: {} @@ -12511,7 +12495,7 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 - unocss@66.10.5(cac@7.0.0)(rolldown@1.2.0)(rollup@4.62.2)(srvx@1.0.5)(vite@8.2.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.0)): + unocss@66.10.5(cac@7.0.0)(rolldown@1.2.0)(rollup@4.62.2)(srvx@1.0.6)(vite@8.2.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.0)): dependencies: '@unocss/cli': 66.10.5 '@unocss/core': 66.10.5 @@ -12530,7 +12514,7 @@ snapshots: '@unocss/transformer-compile-class': 66.10.5 '@unocss/transformer-directives': 66.10.5 '@unocss/transformer-variant-group': 66.10.5 - '@unocss/vite': 66.10.5(cac@7.0.0)(srvx@1.0.5)(vite@8.2.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.0)) + '@unocss/vite': 66.10.5(cac@7.0.0)(srvx@1.0.6)(vite@8.2.2(@types/node@26.1.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.0)) transitivePeerDependencies: - '@devframes/agentic' - cac @@ -12792,8 +12776,6 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 - x-forwarded-fetch@0.2.0: {} - xss@1.0.15: dependencies: commander: 2.20.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 992049ed..29044110 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,6 +13,7 @@ overrides: minimumReleaseAgeExclude: - '@fedify/*' +- srvx@1.0.6 permissions: '@fedify/fedify': diff --git a/src/fast-response.test.ts b/src/fast-response.test.ts new file mode 100644 index 00000000..858bc6a3 --- /dev/null +++ b/src/fast-response.test.ts @@ -0,0 +1,132 @@ +import { Hono } from "hono"; +import { compress } from "hono/compress"; +import { cors } from "hono/cors"; +import type { Server } from "srvx"; +import { FastResponse } from "srvx/node"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { createHttpServer, enableFastResponse } from "./http-server"; + +const nativeRequest = globalThis.Request; +const nativeResponse = globalThis.Response; +let server: Server; + +beforeAll(async () => { + enableFastResponse(); + const app = new Hono(); + app.use("/status/*", compress()); + app.use("/status/*", cors()); + for (const code of [201, 401, 403, 404] as const) { + app.get(`/status/${code}`, (c) => c.json({ code }, code)); + } + app.get("/json", (c) => c.json({ message: "안녕하세요" })); + app.get("/cookies", (c) => { + c.header("Set-Cookie", "a=1", { append: true }); + c.header("Set-Cookie", "b=2", { append: true }); + return c.text("cookies"); + }); + app.get("/redirect", (c) => c.redirect("/json", 302)); + app.get("/native", () => new nativeResponse("native")); + app.get( + "/stream", + () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("first")); + setTimeout(() => { + controller.enqueue(new TextEncoder().encode("second")); + controller.close(); + }, 25); + }, + }), + ), + ); + app.get("/headers", async (c) => { + c.res = c.text("body"); + c.header("Cache-Control", "private, no-store"); + return c.res; + }); + server = createHttpServer(app.fetch.bind(app), { + port: 0, + hostname: "127.0.0.1", + behindProxy: false, + }); + await server.ready(); +}); + +afterAll(async () => { + await server?.close(true); + globalThis.Response = nativeResponse; +}); + +describe("FastResponse", () => { + it.each([201, 401, 403, 404])( + "preserves status %i through API middleware", + async (code) => { + const response = await fetch(new URL(`/status/${code}`, server.url)); + expect(response.status).toBe(code); + expect(await response.json()).toEqual({ code }); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + }, + ); + + it("preserves response-valued initializers when materializing the body", async () => { + const original = new nativeResponse("denied", { + status: 401, + statusText: "Denied", + }); + const copy = new Response(original.body, original); + copy.headers.set("X-Test", "yes"); + expect(await copy.text()).toBe("denied"); + expect(copy.status).toBe(401); + expect(copy.statusText).toBe("Denied"); + expect(copy.headers.get("X-Test")).toBe("yes"); + }); + + it("replaces only Response and preserves native response interoperability", async () => { + expect(Object.getPrototypeOf(globalThis.Response)).toBe(FastResponse); + expect(globalThis.Request).toBe(nativeRequest); + expect(Response.error().type).toBe("error"); + expect( + Response.redirect("https://example.com/").headers.get("Location"), + ).toBe("https://example.com/"); + const response = new Response("hello"); + expect(response).toBeInstanceOf(nativeResponse); + expect(await response.clone().text()).toBe("hello"); + expect(await response.text()).toBe("hello"); + expect(await (await fetch(new URL("/native", server.url))).text()).toBe( + "native", + ); + }); + + it("sends Hono JSON with the UTF-8 byte length and supports HEAD", async () => { + const response = await fetch(new URL("/json", server.url)); + const body = await response.text(); + expect(JSON.parse(body)).toEqual({ message: "안녕하세요" }); + expect(response.headers.get("Content-Length")).toBe( + String(Buffer.byteLength(body)), + ); + const head = await fetch(new URL("/json", server.url), { method: "HEAD" }); + expect(head.status).toBe(200); + expect(await head.text()).toBe(""); + }); + + it("keeps multiple cookies and redirects", async () => { + const cookies = await fetch(new URL("/cookies", server.url)); + expect(cookies.headers.getSetCookie()).toEqual(["a=1", "b=2"]); + const redirect = await fetch(new URL("/redirect", server.url), { + redirect: "manual", + }); + expect(redirect.status).toBe(302); + expect(redirect.headers.get("Location")).toBe("/json"); + }); + + it("streams responses and preserves Hono header changes", async () => { + const response = await fetch(new URL("/stream", server.url)); + expect(await response.text()).toBe("firstsecond"); + const headers = await fetch(new URL("/headers", server.url)); + expect(headers.headers.get("Cache-Control")).toBe("private, no-store"); + expect(await headers.text()).toBe("body"); + }); +}); diff --git a/src/http-server.db.test.ts b/src/http-server.db.test.ts new file mode 100644 index 00000000..9043468d --- /dev/null +++ b/src/http-server.db.test.ts @@ -0,0 +1,72 @@ +import { request as httpRequest } from "node:http"; + +import { Hono } from "hono"; +import postgres from "postgres"; +import { expect, it } from "vitest"; + +import { + isPostgresQueryCancelError, + postgresQueryCancellationMiddleware, + wrapPostgresClient, +} from "./db-cancel"; +import { createHttpServer } from "./http-server"; + +it("cancels an active SQL query when the HTTP client disconnects", async () => { + const applicationName = `hollo-http-cancel-${crypto.randomUUID()}`; + const client = wrapPostgresClient( + postgres(process.env.DATABASE_URL!, { + max: 1, + connection: { application_name: applicationName }, + }), + ); + const observer = postgres(process.env.DATABASE_URL!, { max: 1 }); + const cancelled = Promise.withResolvers(); + const app = new Hono(); + // Match Hollo: let the cancellation middleware normalize a query error. + app.onError((error) => { + throw error; + }); + app.use(postgresQueryCancellationMiddleware()); + app.get("/", async (c) => { + try { + await client.unsafe("select pg_sleep(30)"); + return c.text("finished"); + } catch (error) { + cancelled.resolve(error); + throw error; + } + }); + const server = createHttpServer(app.fetch, { + port: 0, + hostname: "127.0.0.1", + behindProxy: true, + }); + await server.ready(); + const request = httpRequest(server.url!, { + headers: { "X-Forwarded-Proto": "https" }, + agent: false, + }); + request.on("error", () => {}); + try { + request.end(); + // Observe execution, rather than racing disconnect against lazy SQL dispatch. + await expect + .poll(async () => { + const rows = await observer` + select 1 from pg_stat_activity + where application_name = ${applicationName} and state = 'active' + `; + return rows.length; + }) + .toBe(1); + request.destroy(); + expect(isPostgresQueryCancelError(await cancelled.promise)).toBe(true); + } finally { + request.destroy(); + await server.close(true); + await Promise.all([ + client.end({ timeout: 1 }), + observer.end({ timeout: 1 }), + ]); + } +}); diff --git a/src/http-server.test.ts b/src/http-server.test.ts new file mode 100644 index 00000000..01295553 --- /dev/null +++ b/src/http-server.test.ts @@ -0,0 +1,486 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { Agent } from "node:http"; +import { request as httpRequest, type IncomingHttpHeaders } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { text } from "node:stream/consumers"; + +import { + generateCryptoKeyPair, + signRequest, + verifyRequest, +} from "@fedify/fedify/sig"; +import { CryptographicKey } from "@fedify/vocab"; +import { Hono } from "hono"; +import type { Server } from "srvx"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createHttpServer } from "./http-server"; +import { serveStatic } from "./serve-static"; + +const servers: Server[] = []; + +/** Start a tracked server on an ephemeral port for transport tests. */ +async function start( + handler: (request: Request) => Response | Promise, + behindProxy = false, + hostname: string | null = "127.0.0.1", +) { + const server = createHttpServer(handler, { + port: 0, + hostname: hostname ?? undefined, + behindProxy, + }); + servers.push(server); + await server.ready(); + return server; +} + +/** Send a raw HTTP request without URL normalization or connection pooling. */ +function send( + server: Server, + { + method = "GET", + headers = {}, + body, + path = "/", + }: { + method?: string; + headers?: Record; + body?: string; + path?: string; + } = {}, +): Promise<{ + status: number; + headers: IncomingHttpHeaders; + body: string; +}> { + return new Promise((resolve, reject) => { + const request = httpRequest( + server.url!, + { method, path, headers, agent: false }, + (response) => { + text(response).then( + (body) => + resolve({ + status: response.statusCode!, + headers: response.headers, + body, + }), + reject, + ); + }, + ); + request.on("error", reject); + request.end(body); + }); +} + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.close(true))); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +describe("HTTP server proxy boundary", () => { + it("ignores forwarded origins unless BEHIND_PROXY is enabled", async () => { + const server = await start((request) => + Response.json({ + url: request.url, + host: request.headers.get("Host"), + forwarded: request.headers.get("X-Forwarded-Host"), + }), + ); + const response = await send(server, { + headers: { + Host: "internal.example:3000", + "X-Forwarded-Host": "public.example", + "X-Forwarded-Proto": "https", + }, + }); + expect(JSON.parse(response.body)).toEqual({ + url: "http://internal.example:3000/", + host: "internal.example:3000", + forwarded: "public.example", + }); + }); + + it.each([ + ["Public.Example:443", "Public.Example:443"], + ["public.example:8443", "public.example:8443"], + ["[2001:db8::1]:443", "[2001:db8::1]:443"], + ["public.example, internal.example", "public.example"], + [", public.example", "public.example"], + [" , , public.example, ", "public.example"], + ["public.example,", "public.example"], + ["evil.example/path", "internal.example:3000"], + ["user@evil.example", "internal.example:3000"], + ["evil.example:999999", "internal.example:3000"], + ["evil.example?x", "internal.example:3000"], + ["evil.example#x", "internal.example:3000"], + ])("keeps Host consistent with srvx for %s", async (forwarded, host) => { + const server = await start( + (request) => + Response.json({ + url: request.url, + host: request.headers.get("Host"), + proto: request.headers.get("X-Forwarded-Proto"), + forwarded: request.headers.get("X-Forwarded-Host"), + for: request.headers.get("X-Forwarded-For"), + }), + true, + ); + // Dot segments force native URL normalization inside srvx's FastURL. + const response = await send(server, { + path: "/a/../inbox?x=1", + headers: { + Host: "internal.example:3000", + "X-Forwarded-Host": forwarded, + "X-Forwarded-Proto": ", https, http", + "X-Forwarded-For": "192.0.2.1", + }, + }); + const result = JSON.parse(response.body); + expect(result.host).toBe(host); + expect(new URL(result.url).href).toBe( + new URL(`https://${host}/inbox?x=1`).href, + ); + expect(result.proto).toBeNull(); + expect(result.forwarded).toBeNull(); + expect(result.for).toBe("192.0.2.1"); + }); + + it("preserves Host when the proxy only supplies the public scheme", async () => { + const server = await start( + (request) => + Response.json({ url: request.url, host: request.headers.get("Host") }), + true, + ); + const response = await send(server, { + headers: { Host: "Public.Example:443", "X-Forwarded-Proto": "https" }, + }); + expect(JSON.parse(response.body)).toEqual({ + url: "https://Public.Example:443/", + host: "Public.Example:443", + }); + }); + + it.each(["draft-cavage-http-signatures-12", "rfc9421"] as const)( + "verifies %s signed bodies through a trusted proxy", + async (spec) => { + const pair = await generateCryptoKeyPair(); + const keyId = new URL("https://remote.example/actor#key"); + const key = new CryptographicKey({ + id: keyId, + owner: new URL("https://remote.example/actor"), + publicKey: pair.publicKey, + }); + const body = '{"content":"한글 🐻","type":"Create"}'; + const signed = await signRequest( + new Request("https://public.example/inbox", { + method: "POST", + body, + headers: { + "Content-Type": "application/activity+json", + Host: "Public.Example:443", + }, + }), + pair.privateKey, + keyId, + { spec }, + ); + const server = await start(async (request) => { + const verified = await verifyRequest(request, { + keyCache: { get: async () => key, set: async () => {} }, + documentLoader: async () => { + throw new Error("Signature test must not use the network"); + }, + }); + return Response.json({ + verified: verified != null, + clone: await request.clone().text(), + body: await request.text(), + }); + }, true); + const response = await send(server, { + path: "/inbox", + method: "POST", + body: await signed.text(), + headers: { + ...Object.fromEntries(signed.headers), + host: "internal.example:3000", + "x-forwarded-host": "Public.Example:443", + "x-forwarded-proto": "https", + }, + }); + expect(response.status).toBe(200); + expect(JSON.parse(response.body)).toEqual({ + verified: true, + clone: body, + body, + }); + }, + ); +}); + +describe("HTTP transport", () => { + it("passes a chunk to the application before the upload completes", async () => { + const received = Promise.withResolvers(); + const server = await start(async (request) => { + const reader = request.body!.getReader(); + const first = await reader.read(); + received.resolve(new TextDecoder().decode(first.value)); + let rest = ""; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + rest += new TextDecoder().decode(value); + } + return new Response(rest); + }, true); + const request = httpRequest(server.url!, { + method: "POST", + headers: { "X-Forwarded-Proto": "https" }, + agent: false, + }); + const response = new Promise((resolve, reject) => { + request.on("error", reject); + request.on("response", (response) => + text(response).then(resolve, reject), + ); + }); + try { + request.flushHeaders(); + request.write("first"); + expect(await received.promise).toBe("first"); + request.end("last"); + expect(await response).toBe("last"); + } finally { + request.destroy(); + } + }); + + it("parses multipart uploads through the Hono application", async () => { + const app = new Hono(); + app.post("/", async (c) => { + const data = await c.req.formData(); + const file = data.get("file") as File; + return c.json({ title: data.get("title"), file: await file.text() }); + }); + const server = await start(app.fetch, true); + const data = new FormData(); + data.set("title", "hello"); + data.set("file", new File(["한글"], "file.txt", { type: "text/plain" })); + const response = await fetch(server.url!, { + method: "POST", + body: data, + headers: { "X-Forwarded-Proto": "https" }, + }); + expect(await response.json()).toEqual({ title: "hello", file: "한글" }); + }); + + it("aborts the application signal when an incomplete client disconnects", async () => { + const entered = Promise.withResolvers(); + const aborted = Promise.withResolvers(); + const server = await start(async (request) => { + const signal = request.signal; + signal.addEventListener("abort", () => aborted.resolve(signal.aborted)); + entered.resolve(); + await aborted.promise; + return new Response(null, { status: 499 }); + }, true); + const request = httpRequest(server.url!, { + method: "POST", + headers: { "Content-Length": "1000000", "X-Forwarded-Proto": "https" }, + }); + request.on("error", () => {}); + try { + request.flushHeaders(); + request.write("partial"); + await entered.promise; + request.destroy(); + expect(await aborted.promise).toBe(true); + } finally { + request.destroy(); + } + }); + + it("responds to an unread incomplete body without waiting for its end", async () => { + const server = await start( + () => new Response("rejected", { status: 403 }), + true, + ); + const agent = new Agent({ keepAlive: true }); + const request = httpRequest(server.url!, { + method: "POST", + headers: { "Content-Length": "1000000" }, + agent, + }); + const closed = new Promise((resolve) => { + request.on("socket", (socket) => socket.once("close", resolve)); + }); + const response = new Promise((resolve, reject) => { + request.on("error", reject); + request.on("response", (response) => { + response.resume(); + resolve(response.statusCode); + }); + }); + try { + request.flushHeaders(); + request.write("partial"); + expect(await response).toBe(403); + await closed; + expect(request.socket?.destroyed).toBe(true); + } finally { + request.destroy(); + agent.destroy(); + } + expect((await send(server)).status).toBe(403); + }); + + it("preserves native constructors, cookies, redirects and response streams", async () => { + const NativeRequest = globalThis.Request; + const NativeResponse = globalThis.Response; + const app = new Hono(); + app.get("/cookies", (c) => { + c.header("Set-Cookie", "a=1", { append: true }); + c.header("Set-Cookie", "b=2", { append: true }); + return c.redirect("/stream"); + }); + app.get( + "/stream", + () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("한글")); + controller.close(); + }, + }), + ), + ); + const server = await start(app.fetch); + const response = await send(server, { path: "/cookies" }); + expect(response.status).toBe(302); + expect(response.headers.location).toBe("/stream"); + expect(response.headers["set-cookie"]).toEqual(["a=1", "b=2"]); + expect((await send(server, { path: "/stream" })).body).toBe("한글"); + expect(globalThis.Request).toBe(NativeRequest); + expect(globalThis.Response).toBe(NativeResponse); + }); + + it("retains the existing static file helper's HEAD and range behavior", async () => { + const dir = await mkdtemp(join(tmpdir(), "hollo-http-static-")); + try { + await writeFile(join(dir, "hello.txt"), "hello world"); + const app = new Hono(); + app.use("/*", serveStatic({ root: dir, prefix: "" })); + const server = await start(app.fetch); + const get = await send(server, { path: "/hello.txt" }); + expect(get.body).toBe("hello world"); + const head = await send(server, { path: "/hello.txt", method: "HEAD" }); + expect(head.body).toBe(""); + expect(head.headers["content-length"]).toBe("11"); + const range = await send(server, { + path: "/hello.txt", + headers: { Range: "bytes=1-3" }, + }); + expect(range.status).toBe(206); + expect(range.body).toBe("ell"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("does not expose thrown errors to HTTP clients", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const server = await start(() => { + throw new Error("private credentials"); + }); + const response = await send(server); + expect(response.status).toBe(500); + expect(response.body).toBe("Internal Server Error"); + }); + + it.each([undefined, "127.0.0.2"])( + "ignores HOST=%s when BIND is unset", + async (host) => { + vi.stubEnv("HOST", host); + const server = await start(() => new Response("ok"), false, null); + const address = server.node!.server!.address(); + expect(address && typeof address !== "string" && address.address).toMatch( + /^(::|0\.0\.0\.0)$/, + ); + }, + ); + + it("rejects readiness on a port conflict", async () => { + const listening = await start(() => new Response("ok")); + const server = createHttpServer(() => new Response("unreachable"), { + hostname: "127.0.0.1", + port: Number(new URL(listening.url!).port), + behindProxy: false, + }); + await expect(server.ready()).rejects.toHaveProperty("code", "EADDRINUSE"); + }); + + it("waits for an active response before close completes", async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const server = await start(async () => { + entered.resolve(); + await release.promise; + return new Response("finished"); + }); + const response = send(server); + await entered.promise; + let closed = false; + const close = server.close().then(() => { + closed = true; + }); + expect(closed).toBe(false); + release.resolve(); + expect((await response).body).toBe("finished"); + await close; + expect(closed).toBe(true); + }); + + it("finishes shutdown while a proxy tries to reuse a keep-alive connection", async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + let handled = 0; + const server = await start(async () => { + handled++; + entered.resolve(); + await release.promise; + return new Response("finished"); + }); + const url = server.url!; + const agent = new Agent({ keepAlive: true, maxSockets: 1 }); + const get = () => + new Promise((resolve, reject) => { + const request = httpRequest(url, { agent }, (response) => { + text(response).then(resolve, reject); + }); + request.on("error", reject); + request.end(); + }); + const response = get(); + await entered.promise; + const close = server.close(); + // A busy proxy must not keep this connection alive until the 30s deadline. + const reuse = setInterval(() => { + void get().catch(() => {}); + }, 100); + try { + release.resolve(); + expect(await response).toBe("finished"); + await close; + expect(handled).toBe(1); + } finally { + clearInterval(reuse); + agent.destroy(); + } + }); +}); diff --git a/src/http-server.ts b/src/http-server.ts new file mode 100644 index 00000000..7f32c6d1 --- /dev/null +++ b/src/http-server.ts @@ -0,0 +1,87 @@ +import { FastResponse, type Server, type ServerRequest } from "srvx"; +import { serve } from "srvx/node"; + +/** Install before loading the web application, after configuring Sentry. */ +export function enableFastResponse(): void { + globalThis.Response = class Response extends FastResponse { + constructor(body?: BodyInit | null, init?: ResponseInit) { + // Hono passes a Response as its initializer when merging headers. srvx + // 1.0.6 later spreads that initializer, losing inherited status getters. + super( + body, + init instanceof FastResponse + ? { + status: init.status, + statusText: init.statusText, + headers: init.headers, + } + : init, + ); + } + }; +} + +// Match srvx's authority validation so a rejected forwarded host cannot be +// restored into the header used by HTTP signature verification. +// https://github.com/h3js/srvx/blob/v1.0.6/src/_trust-proxy.ts +const forwardedHostPattern = + /^(\[(?:[A-Fa-f0-9:.]+)\]|(?:[A-Za-z0-9_-]+\.)*[A-Za-z0-9_-]+|(?:\d{1,3}\.){3}\d{1,3})(:\d{1,5})?$/; + +/** Run a Fetch application without buffering or replacing incoming requests. */ +export function createHttpServer( + fetch: (request: Request) => Response | Promise, + { + port, + hostname, + behindProxy, + }: { port: number; hostname?: string; behindProxy: boolean }, +): Server { + const server = serve({ + port, + hostname, + // Override srvx's HOST fallback even when BIND is undefined. + node: { host: hostname }, + trustProxy: behindProxy, + gracefulShutdown: false, + silent: true, + error(error) { + console.error("Error handling HTTP request:", error); + return new Response("Internal Server Error", { status: 500 }); + }, + fetch(request: ServerRequest) { + const node = request.runtime?.node; + if (node?.res) { + // An early response must not retain a slow, incomplete upload socket. + // Wait until the response has been sent before closing the connection. + node.res.once("finish", () => { + if (!node.req.complete) node.req.destroy(); + // close() only closes connections that were idle at its call time. + // Also close those becoming idle as in-flight responses finish. + const http = server.node?.server; + if (http && !http.listening && "closeIdleConnections" in http) { + http.closeIdleConnections(); + } + }); + } + if (behindProxy) { + // srvx caches its URL lazily. Resolve it before removing the headers + // used for its public origin, without materializing the request body. + void request.url; + const host = request.headers + .get("X-Forwarded-Host") + ?.split(",") + .map((value) => value.trim()) + .find((value) => value !== ""); + if (host && forwardedHostPattern.test(host)) { + // Keep case and explicit default ports: URL.host normalizes them, + // but the Host header can be part of an HTTP signature. + request.headers.set("Host", host); + } + request.headers.delete("X-Forwarded-Host"); + request.headers.delete("X-Forwarded-Proto"); + } + return fetch(request); + }, + }); + return server; +} diff --git a/src/index.tsx b/src/index.tsx index 885c762a..1b3c0c1a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,9 +1,7 @@ -import { join, relative } from "node:path"; +import { join } from "node:path"; import "./logging"; - import { federation } from "@fedify/hono"; -import { serveStatic } from "@hono/node-server/serve-static"; import { captureException } from "@sentry/core"; import { type Context, Hono, type Next } from "hono"; import { cors } from "hono/cors"; @@ -17,6 +15,7 @@ import oauth from "./oauth"; import oauthMetadataEndpoint from "./oauth/endpoints/metadata"; import pages from "./pages"; import proxy from "./proxy"; +import { serveStatic } from "./serve-static"; import { DRIVE_DISK, FS_STORAGE_PATH } from "./storage-config"; const app = new Hono(); @@ -36,8 +35,8 @@ if (DRIVE_DISK === "fs") { app.use( "/assets/*", serveStatic({ - root: relative(process.cwd(), FS_STORAGE_PATH!), - rewriteRequestPath: (path) => path.substring("/assets".length), + root: FS_STORAGE_PATH!, + prefix: "/assets", }), ); } @@ -45,8 +44,8 @@ if (DRIVE_DISK === "fs") { app.use( "/public/*", serveStatic({ - root: relative(process.cwd(), join(import.meta.dirname, "public")), - rewriteRequestPath: (path) => path.substring("/public".length), + root: join(import.meta.dirname, "public"), + prefix: "/public", }), ); diff --git a/src/serve-static.test.ts b/src/serve-static.test.ts new file mode 100644 index 00000000..76b8d040 --- /dev/null +++ b/src/serve-static.test.ts @@ -0,0 +1,295 @@ +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { request as httpRequest, type IncomingHttpHeaders } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { text } from "node:stream/consumers"; + +import { Hono } from "hono"; +import type { Server } from "srvx"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { createHttpServer, enableFastResponse } from "./http-server"; +import { serveStatic } from "./serve-static"; + +let fixture: string; +let server: Server; +let exactFileReadable: boolean; +const nativeResponse = globalThis.Response; + +/** Preserve the raw URL path for traversal and encoding tests at the server boundary. */ +function send( + path: string, + { + method = "GET", + headers = {}, + }: { method?: string; headers?: Record } = {}, +): Promise<{ status: number; headers: IncomingHttpHeaders; body: string }> { + return new Promise((resolve, reject) => { + const request = httpRequest( + server.url!, + { path, method, headers, agent: false }, + (response) => { + text(response).then( + (body) => + resolve({ + status: response.statusCode!, + headers: response.headers, + body, + }), + reject, + ); + }, + ); + request.on("error", reject); + request.end(); + }); +} + +beforeAll(async () => { + enableFastResponse(); + fixture = await mkdtemp(join(tmpdir(), "hollo-static-")); + const root = join(fixture, "root"); + await mkdir(join(root, "nested"), { recursive: true }); + await writeFile(join(root, "hello.txt"), "0123456789"); + await writeFile(join(root, "index.html"), "

root

"); + await writeFile(join(root, "nested", "index.html"), "

nested

"); + await writeFile(join(root, ".secret"), "hidden"); + await writeFile(join(fixture, "outside.txt"), "outside secret"); + await symlink(join(fixture, "outside.txt"), join(root, "escape.txt")); + await symlink(join(root, "hello.txt"), join(root, "alias.txt")); + for (const name of [ + "movie.ogv", + "sound.oga", + "song.aac", + "font.ttf", + "style.css", + ]) { + await writeFile(join(root, name), "fixture"); + } + await mkdir(join(root, "collision")); + await writeFile(join(root, "collision.html"), "wrong alias"); + await writeFile(join(root, "collision", "index.html"), "directory index"); + await writeFile(join(root, "alias-only.html"), "unrequested alias"); + await mkdir(join(root, "v1.2.3")); + await writeFile(join(root, "v1.2.3", "index.html"), "dotted directory"); + await mkdir(join(root, "theme.css")); + await writeFile(join(root, "theme.css", "index.html"), "HTML index"); + await symlink(join(fixture, "outside.txt"), join(root, "escape")); + await writeFile(join(root, "escape.html"), "unwanted outside-link fallback"); + await symlink(join(root, ".secret"), join(root, "hidden-alias")); + await writeFile( + join(root, "hidden-alias.html"), + "unwanted hidden-link fallback", + ); + await writeFile(join(root, "large.txt"), "x".repeat(2048)); + await writeFile(join(root, "unreadable"), "exact file"); + await chmod(join(root, "unreadable"), 0); + exactFileReadable = await readFile(join(root, "unreadable")).then( + () => true, + () => false, + ); + await writeFile(join(root, "unreadable.html"), "wrong permission fallback"); + const app = new Hono(); + app.use("*", serveStatic({ root, prefix: "/files" })); + app.all("*", (c) => c.text("fallback", 404)); + server = createHttpServer(app.fetch.bind(app), { + port: 0, + hostname: "127.0.0.1", + behindProxy: false, + }); + await server.ready(); +}); + +afterAll(async () => { + await server?.close(true); + globalThis.Response = nativeResponse; + if (fixture) await rm(fixture, { recursive: true, force: true }); +}); + +describe("static files", () => { + it("prefers a directory index over an adjacent HTML file", async () => { + const response = await send("/files/collision"); + expect(response.status).toBe(200); + expect(response.body).toBe("directory index"); + }); + + it("does not add an HTML extension to a missing path", async () => { + const response = await send("/files/alias-only"); + expect(response.status).toBe(404); + expect(response.body).toBe("fallback"); + }); + + it("serves an index for dotted directory names without a trailing slash", async () => { + const response = await send("/files/v1.2.3"); + expect(response.status).toBe(200); + expect(response.body).toBe("dotted directory"); + }); + + it("uses the index file MIME type for directories named like files", async () => { + const response = await send("/files/theme.css"); + expect(response.status).toBe(200); + expect(response.body).toBe("HTML index"); + expect(response.headers["content-type"]).toBe("text/html; charset=utf-8"); + }); + + it.each(["escape", "hidden-alias"])( + "does not serve an HTML alias after rejecting %s", + async (name) => { + const response = await send(`/files/${name}`); + expect(response.status).toBe(404); + expect(response.body).toBe("fallback"); + }, + ); + + it("does not serve an HTML alias when the exact file cannot be read", async () => { + const response = await send("/files/unreadable"); + // Root and some platforms can read a mode-000 file; neither may select its alias. + expect(response.status).toBe(exactFileReadable ? 200 : 404); + expect(response.body).toBe(exactFileReadable ? "exact file" : "fallback"); + }); + + it("serves files without enabling compression or a cache lifetime", async () => { + const response = await send("/files/large.txt?download=true", { + headers: { "Accept-Encoding": "gzip, br" }, + }); + expect(response.status).toBe(200); + expect(response.body).toBe("x".repeat(2048)); + expect(response.headers["content-type"]).toBe("text/plain; charset=utf-8"); + expect(response.headers["content-length"]).toBe("2048"); + expect(response.headers["cache-control"]).toBeUndefined(); + expect(response.headers["content-encoding"]).toBeUndefined(); + }); + + it.each(["HEAD", "OPTIONS"])( + "preserves empty %s responses", + async (method) => { + const response = await send("/files/hello.txt", { method }); + expect(response.status).toBe(200); + expect(response.body).toBe(""); + expect(response.headers["content-length"]).toBe( + method === "HEAD" ? "10" : "0", + ); + }, + ); + + it("keeps serving files for POST as the previous helper did", async () => { + const response = await send("/files/hello.txt", { method: "POST" }); + expect(response.status).toBe(200); + expect(response.body).toBe("0123456789"); + }); + + it.each([ + ["bytes=2-4", "234", "bytes 2-4/10"], + ["bytes=-3", "789", "bytes 7-9/10"], + ["bytes=8-", "89", "bytes 8-9/10"], + ])("serves %s", async (range, body, contentRange) => { + const response = await send("/files/hello.txt", { + headers: { Range: range }, + }); + expect(response.status).toBe(206); + expect(response.body).toBe(body); + expect(response.headers["content-range"]).toBe(contentRange); + expect(response.headers["content-length"]).toBe(String(body.length)); + }); + + it("does not apply GET revalidation to legacy POST or OPTIONS", async () => { + const initial = await send("/files/hello.txt"); + for (const method of ["POST", "OPTIONS"]) { + const response = await send("/files/hello.txt", { + method, + headers: { "If-None-Match": initial.headers.etag! }, + }); + expect(response.status).toBe(200); + expect(response.body).toBe(method === "POST" ? "0123456789" : ""); + } + }); + + it("rejects an unsatisfiable range", async () => { + const response = await send("/files/hello.txt", { + headers: { Range: "bytes=20-" }, + }); + expect(response.status).toBe(416); + expect(response.headers["content-range"]).toBe("bytes */10"); + expect(response.body).toBe(""); + }); + + it("revalidates with ETag and Last-Modified", async () => { + const initial = await send("/files/hello.txt"); + expect(initial.headers.etag).toBeTypeOf("string"); + expect(initial.headers["last-modified"]).toBeTypeOf("string"); + const validators: Record[] = [ + { "If-None-Match": initial.headers.etag! }, + { "If-Modified-Since": initial.headers["last-modified"]! }, + ]; + for (const headers of validators) { + const cached = await send("/files/hello.txt", { headers }); + expect(cached.status).toBe(304); + expect(cached.body).toBe(""); + } + const stale = await send("/files/hello.txt", { + headers: { + Range: "bytes=2-4", + "If-Range": "Thu, 01 Jan 1970 00:00:00 GMT", + }, + }); + expect(stale.status).toBe(200); + expect(stale.body).toBe("0123456789"); + }); + + it.each([ + ["movie.ogv", "video/ogg"], + ["sound.oga", "audio/ogg"], + ["song.aac", "audio/aac"], + ["font.ttf", "font/ttf"], + ["style.css", "text/css; charset=utf-8"], + ])("preserves the MIME type of %s", async (name, type) => { + const response = await send(`/files/${name}`); + expect(response.status).toBe(200); + expect(response.headers["content-type"]).toBe(type); + expect(response.body).toBe("fixture"); + }); + + it.each(["/files", "/files/", "/files/nested/"])( + "serves an index at %s", + async (path) => { + const response = await send(path); + expect(response.status).toBe(200); + expect(response.headers["content-type"]).toBe("text/html; charset=utf-8"); + expect(response.body).toContain( + path.includes("nested") ? "nested" : "root", + ); + }, + ); + + it.each([ + "/files/missing.txt", + "/files/missing.oga", + "/files-other/hello.txt", + "/hello.txt", + "/files/../outside.txt", + "/files/%2e%2e/outside.txt", + "/files/%2F..%2Foutside.txt", + "/files/%", + "/files//hello.txt", + "/files/.secret", + "/files/escape.txt", + ])("falls through without exposing %s", async (path) => { + const response = await send(path); + expect(response.status).toBe(404); + expect(response.body).toBe("fallback"); + }); + + it("allows symlinks whose targets remain inside the root", async () => { + const response = await send("/files/alias.txt"); + expect(response.status).toBe(200); + expect(response.body).toBe("0123456789"); + }); +}); diff --git a/src/serve-static.ts b/src/serve-static.ts new file mode 100644 index 00000000..eddadedf --- /dev/null +++ b/src/serve-static.ts @@ -0,0 +1,102 @@ +import { constants } from "node:fs"; +import { access, realpath, stat } from "node:fs/promises"; +import { isAbsolute, join, relative, sep } from "node:path"; + +import type { MiddlewareHandler } from "hono"; +import { getMimeType } from "hono/utils/mime"; +import { staticMiddleware } from "srvx/static"; + +/** Mount srvx's asynchronous file server in Hono. */ +export function serveStatic({ + root, + prefix, +}: { + root: string; + prefix: string; +}): MiddlewareHandler { + const serve = staticMiddleware({ + dir: root, + compress: false, + encodings: false, + dotfiles: false, + }); + let realRoot: string | undefined; + return async (c, next) => { + if (c.finalized) return next(); + const path = c.req.path; + // Preserve the previous helper's rejection of encoded/ambiguous paths. + if ( + (path !== prefix && !path.startsWith(`${prefix}/`)) || + path.includes("%") || + /(?:^|[/\\])\.{1,2}(?:$|[/\\])|[/\\]{2,}|\\/.test(path) + ) { + return next(); + } + const url = new URL(c.req.url); + let filename = path.slice(prefix.length) || "/"; + // Preserve exact-file and directory-index lookup. srvx also tries .html + // aliases, which can select a different resource for extensionless paths. + const info = await stat(join(root, filename)).catch(() => undefined); + if (!info) return next(); + if (info.isDirectory()) { + filename = `${filename.replace(/\/+$/, "")}/index.html`; + } else if (!info.isFile()) { + return next(); + } + // Reject an unservable exact path before srvx can try an adjacent .html + // alias. srvx still validates the opened file against its root and inode. + realRoot ??= await realpath(root).catch(() => undefined); + if (!realRoot) return next(); + const realFile = await realpath(join(root, filename)).catch( + () => undefined, + ); + if (!realFile) return next(); + const relativeFile = relative(realRoot, realFile); + if ( + !relativeFile || + isAbsolute(relativeFile) || + relativeFile.split(sep).some((part) => part.startsWith(".")) + ) { + return next(); + } + if ( + !(await access(realFile, constants.R_OK).then( + () => true, + () => false, + )) + ) { + return next(); + } + url.pathname = filename; + // The previous helper served files for all methods, with an empty body + // for HEAD and OPTIONS. Never clone or consume an incoming upload body. + const request = new Request(url, { + method: + c.req.method === "HEAD" || c.req.method === "OPTIONS" ? "HEAD" : "GET", + headers: c.req.raw.headers, + signal: c.req.raw.signal, + }); + if (c.req.method !== "GET" && c.req.method !== "HEAD") { + // Conditional retrieval applies to GET/HEAD, not legacy file POSTs or OPTIONS. + request.headers.delete("If-None-Match"); + request.headers.delete("If-Modified-Since"); + request.headers.delete("If-Range"); + } + let found = true; + const response = await serve(request, async () => { + found = false; + await next(); + return c.res; + }); + if (found && response.headers.has("Content-Type")) { + // srvx has a small MIME table. Retain Hono's full media/font mappings. + const type = getMimeType(filename); + if (type) response.headers.set("Content-Type", type); + } + if (found && c.req.method === "OPTIONS" && response.status === 200) { + // OPTIONS has an empty response body, rather than HEAD's representation length. + response.headers.set("Content-Length", "0"); + } + return response; + }; +} diff --git a/src/server-lifecycle.test.ts b/src/server-lifecycle.test.ts new file mode 100644 index 00000000..6a6f6011 --- /dev/null +++ b/src/server-lifecycle.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createServerLifecycle } from "./server-lifecycle"; + +describe("server lifecycle", () => { + it("starts sequentially and stops services concurrently exactly once", async () => { + const draining = Promise.withResolvers(); + const closing = Promise.withResolvers(); + const stopHttp = vi.fn(() => { + closing.resolve(); + return draining.promise; + }); + const stopWorkers = vi.fn(); + const lifecycle = createServerLifecycle([ + async (register) => register(stopHttp), + async (register) => register(stopWorkers), + ]); + await lifecycle.start(); + const stop = lifecycle.stop(); + expect(lifecycle.stop()).toBe(stop); + await closing.promise; + expect(stopWorkers).toHaveBeenCalledOnce(); + draining.resolve(); + await stop; + expect(stopHttp).toHaveBeenCalledOnce(); + }); + + it("releases an acquired resource after stop interrupts startup", async () => { + const entered = Promise.withResolvers(); + const acquired = Promise.withResolvers(); + const close = vi.fn(); + const startWorkers = vi.fn(async () => {}); + const lifecycle = createServerLifecycle([ + async (register) => { + entered.resolve(); + await acquired.promise; + register(close); + }, + startWorkers, + ]); + const start = lifecycle.start(); + await entered.promise; + const stop = lifecycle.stop(); + expect(lifecycle.stopping).toBe(true); + acquired.resolve(); + await Promise.all([start, stop]); + expect(close).toHaveBeenCalledOnce(); + expect(startWorkers).not.toHaveBeenCalled(); + }); + + it("cleans up partial startup after a failure", async () => { + const close = vi.fn(); + const lifecycle = createServerLifecycle([ + async (register) => { + register(close); + throw new Error("startup failed"); + }, + ]); + await expect(lifecycle.start()).rejects.toThrow("startup failed"); + await lifecycle.stop(); + expect(close).toHaveBeenCalledOnce(); + }); + + it("waits for other services even if a close callback throws", async () => { + const drained = Promise.withResolvers(); + const closing = Promise.withResolvers(); + let stopped = false; + const lifecycle = createServerLifecycle([ + async (register) => { + register(() => { + throw new Error("HTTP close failed"); + }); + register(async () => { + closing.resolve(); + await drained.promise; + stopped = true; + }); + }, + ]); + await lifecycle.start(); + const stop = lifecycle.stop(); + const failure = stop.catch((error: unknown) => error); + await closing.promise; + expect(stopped).toBe(false); + drained.resolve(); + expect(await failure).toBeInstanceOf(AggregateError); + expect(stopped).toBe(true); + }); + + it("does not start services after an early stop", async () => { + const start = vi.fn(async () => {}); + const lifecycle = createServerLifecycle([start]); + await lifecycle.stop(); + await lifecycle.start(); + expect(start).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server-lifecycle.ts b/src/server-lifecycle.ts new file mode 100644 index 00000000..b50282fc --- /dev/null +++ b/src/server-lifecycle.ts @@ -0,0 +1,47 @@ +type Stop = () => void | Promise; +type Start = (registerStop: (stop: Stop) => void) => Promise; + +/** Coordinate startup and shutdown of the HTTP server and background workers. */ +export function createServerLifecycle(starters: Start[]) { + const stops: Stop[] = []; + let stopping = false; + let startup: Promise | undefined; + let shutdown: Promise | undefined; + + return { + /** Whether shutdown has been requested, including during startup. */ + get stopping() { + return stopping; + }, + /** Run startup phases once in order, skipping remaining phases after `stop()`. */ + start(): Promise { + return (startup ??= (async () => { + for (const start of starters) { + if (stopping) break; + await start((stop) => stops.push(stop)); + } + })()); + }, + /** + * Wait for pending startup, then run every registered cleanup concurrently. + * Repeated calls share a promise; cleanup failures reject with `AggregateError`. + */ + stop(): Promise { + stopping = true; + return (shutdown ??= (async () => { + // A stop during an import/listen must also release resources acquired + // by that pending startup. The caller reports any startup failure. + await startup?.catch(() => {}); + const results = await Promise.allSettled( + stops.map((stop) => Promise.resolve().then(stop)), + ); + const failures = results + .filter((result) => result.status === "rejected") + .map((result) => result.reason); + if (failures.length > 0) { + throw new AggregateError(failures, "Error stopping server"); + } + })()); + }, + }; +}