mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-17 04:32:31 +03:00
fix(api): close HEAD requests immediately instead of hanging (#6400)
Next.js 16's App Router route-handler pipeline (send-response.js) already skips piping a Response body for HEAD, but its page-rendering pipeline (pipe-readable.js -> pipeToNodeResponse, used for every app-router page/layout render, including the not-found boundary any unmatched path falls through to) has no such check and always streams the full rendered body regardless of method. Combined with Node's default keep-alive framing, this left some clients unsure whether the (implicitly bodyless) HEAD response had actually finished. Add scripts/dev/head-response-guard.cjs, wired into both the dev/start custom server (run-next.mjs) and the packaged standalone server (standalone-server-ws.mjs) at the same tier as the existing http-method-guard.cjs/peer-stamp.mjs wrappers: for every inbound HEAD request it discards any body bytes the inner handler writes and forces Connection: close once .end() is called, independent of route existence or auth state. Regression guard: tests/unit/head-request-closes-6400.test.ts
This commit is contained in:
104
scripts/dev/head-response-guard.cjs
Normal file
104
scripts/dev/head-response-guard.cjs
Normal file
@@ -0,0 +1,104 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* HEAD response guard (#6400).
|
||||
*
|
||||
* RFC 9110 §9.3.2 requires a HEAD response to carry the same headers/status a
|
||||
* GET would, with ZERO body, and the connection should not leave the client
|
||||
* guessing about when the (bodyless) response is actually finished.
|
||||
*
|
||||
* Next.js 16 handles this correctly for App Router *route handlers*
|
||||
* (`route.ts` exporting `GET`) — `next/dist/server/send-response.js` explicitly
|
||||
* skips piping the `Response.body` when `req.method === 'HEAD'`. But Next's
|
||||
* *page*-rendering pipeline (`next/dist/server/pipe-readable.js` ->
|
||||
* `pipeToNodeResponse`, used for every app-router page/layout render — the
|
||||
* root page, the `not-found` boundary that unmatched paths fall through to,
|
||||
* dashboard pages, etc.) has NO such check: it always pipes the fully
|
||||
* rendered body to the HTTP response regardless of method. Combined with
|
||||
* Node's default keep-alive framing, a HEAD request to any page-rendered path
|
||||
* ends up with the socket only settling once that render finishes — on a
|
||||
* client that doesn't special-case a HEAD response's implicit zero-length
|
||||
* body (observed on Windows/curl in #6400), this reads as "headers arrive,
|
||||
* then it hangs" instead of the RFC-mandated "closes immediately".
|
||||
*
|
||||
* Fix: for every inbound HEAD request, before Next ever sees it, wrap the
|
||||
* Node `ServerResponse` so:
|
||||
* - Any body bytes written by Next (route handler OR page render) are
|
||||
* discarded — status code and headers Next computed (auth 401s, 404s,
|
||||
* 200s, etc.) are preserved untouched.
|
||||
* - The connection is force-closed right after headers flush
|
||||
* (`Connection: close`), removing any keep-alive ambiguity a client could
|
||||
* have about whether more bytes are coming.
|
||||
*
|
||||
* This applies globally (valid routes, unmatched/404 paths, authed and
|
||||
* unauthed) because it operates at the Node HTTP transport layer shared by
|
||||
* every request — the same tier as the existing `http-method-guard.cjs` /
|
||||
* `peer-stamp.mjs` wrappers — never inside Next's per-route code.
|
||||
* See: https://github.com/diegosouzapw/OmniRoute/issues/6400
|
||||
*/
|
||||
|
||||
function isHeadRequest(req) {
|
||||
return typeof req?.method === "string" && req.method.toUpperCase() === "HEAD";
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutates `res` in place so any body write is discarded and the response
|
||||
* ends (closing the connection) as soon as `.end()` is called, regardless of
|
||||
* what body argument was passed to it.
|
||||
*
|
||||
* @param {import("node:http").ServerResponse} res
|
||||
*/
|
||||
function suppressBodyAndForceClose(res) {
|
||||
try {
|
||||
// Never leave the client guessing whether the (bodyless) response has
|
||||
// more bytes coming — closing the socket is the unambiguous signal.
|
||||
res.setHeader("Connection", "close");
|
||||
} catch {
|
||||
// Headers may already be flushed in rare re-entrant cases — the write/end
|
||||
// overrides below still guarantee an empty, prompt HEAD response.
|
||||
}
|
||||
|
||||
const originalEnd = res.end.bind(res);
|
||||
let ended = false;
|
||||
|
||||
res.write = function headSuppressedWrite(_chunk, encodingOrCb, cb) {
|
||||
// Discard the body but keep the writable-stream contract: report the
|
||||
// write as flushed (no backpressure) so callers like Next's
|
||||
// `pipeToNodeResponse` never block waiting on a `drain` that would
|
||||
// otherwise never fire, and invoke whichever callback form was passed.
|
||||
if (typeof encodingOrCb === "function") encodingOrCb();
|
||||
else if (typeof cb === "function") cb();
|
||||
return true;
|
||||
};
|
||||
|
||||
res.end = function headSuppressedEnd(chunk, encoding, cb) {
|
||||
if (ended) return res;
|
||||
ended = true;
|
||||
if (typeof chunk === "function") return originalEnd(chunk);
|
||||
if (typeof encoding === "function") return originalEnd(encoding);
|
||||
if (typeof cb === "function") return originalEnd(cb);
|
||||
return originalEnd();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a Node request listener so every inbound HEAD request gets the
|
||||
* body-suppression + forced-close treatment before the wrapped listener
|
||||
* (eventually Next.js) runs.
|
||||
*
|
||||
* @param {(req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse) => unknown} listener
|
||||
*/
|
||||
function wrapRequestListenerWithHeadResponseGuard(listener) {
|
||||
return function headResponseGuardRequestHandler(req, res) {
|
||||
if (isHeadRequest(req)) {
|
||||
suppressBodyAndForceClose(res);
|
||||
}
|
||||
return listener.call(this, req, res);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isHeadRequest,
|
||||
suppressBodyAndForceClose,
|
||||
wrapRequestListenerWithHeadResponseGuard,
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import { createOmnirouteWsBridge } from "./v1-ws-bridge.mjs";
|
||||
import { createResponsesWsProxy } from "./responses-ws-proxy.mjs";
|
||||
import { ensurePeerStampToken, stampPeerIp } from "./peer-stamp.mjs";
|
||||
import methodGuard from "./http-method-guard.cjs";
|
||||
import headResponseGuard from "./head-response-guard.cjs";
|
||||
import { ensureNativeSqlite } from "./ensure-native-sqlite.mjs";
|
||||
import {
|
||||
isTurbopackCacheCorruption,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const { maybeHandleDisallowedMethod } = methodGuard;
|
||||
const { wrapRequestListenerWithHeadResponseGuard } = headResponseGuard;
|
||||
|
||||
// Pre-read DATA_DIR from local .env before bootstrap resolves paths
|
||||
if (!process.env.DATA_DIR) {
|
||||
@@ -143,13 +145,15 @@ async function start() {
|
||||
baseUrl: `http://127.0.0.1:${dashboardPort}`,
|
||||
});
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (maybeHandleDisallowedMethod(req, res)) return;
|
||||
// Stamp the real TCP peer IP before Next sees the request, so the authz
|
||||
// middleware can decide LOCAL_ONLY locality without trusting the Host header.
|
||||
stampPeerIp(req);
|
||||
return requestHandler(req, res);
|
||||
});
|
||||
const server = http.createServer(
|
||||
wrapRequestListenerWithHeadResponseGuard((req, res) => {
|
||||
if (maybeHandleDisallowedMethod(req, res)) return;
|
||||
// Stamp the real TCP peer IP before Next sees the request, so the authz
|
||||
// middleware can decide LOCAL_ONLY locality without trusting the Host header.
|
||||
stampPeerIp(req);
|
||||
return requestHandler(req, res);
|
||||
})
|
||||
);
|
||||
server.on("upgrade", async (req, socket, head) => {
|
||||
try {
|
||||
const responsesWsHandled = await responsesWsProxy.handleUpgrade(req, socket, head);
|
||||
|
||||
@@ -5,11 +5,13 @@ import { createResponsesWsProxy } from "./responses-ws-proxy.mjs";
|
||||
import { ensurePeerStampToken, wrapRequestListenerWithPeerStamp } from "./peer-stamp.mjs";
|
||||
import { maybeHandleWebdav } from "./webdav-handler.mjs";
|
||||
import methodGuard from "./http-method-guard.cjs";
|
||||
import headResponseGuard from "./head-response-guard.cjs";
|
||||
import { resolveTlsOptions, createServerListener } from "./tls-options.mjs";
|
||||
|
||||
const originalCreateServer = http.createServer.bind(http);
|
||||
const proxiesByPort = new Map();
|
||||
const { wrapRequestListenerWithMethodGuard } = methodGuard;
|
||||
const { wrapRequestListenerWithHeadResponseGuard } = headResponseGuard;
|
||||
|
||||
// Opt-in native HTTPS (#5242). Resolved once at boot: when both OMNIROUTE_TLS_CERT
|
||||
// and OMNIROUTE_TLS_KEY point at readable files we terminate TLS on the same
|
||||
@@ -114,8 +116,12 @@ http.createServer = function createServerWithResponsesWs(...args) {
|
||||
const lastFnIdx = args.map((a) => typeof a === "function").lastIndexOf(true);
|
||||
if (lastFnIdx >= 0) {
|
||||
// Method guard runs before Next because Next 16 rejects TRACE while constructing requests.
|
||||
args[lastFnIdx] = wrapRequestListenerWithMethodGuard(
|
||||
wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(args[lastFnIdx]))
|
||||
// Head-response guard wraps outermost so it sees (and can force-close) every
|
||||
// HEAD request regardless of which inner layer ends up handling it (#6400).
|
||||
args[lastFnIdx] = wrapRequestListenerWithHeadResponseGuard(
|
||||
wrapRequestListenerWithMethodGuard(
|
||||
wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(args[lastFnIdx]))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,8 +140,10 @@ http.createServer = function createServerWithResponsesWs(...args) {
|
||||
if (eventName === "request" && typeof listener === "function") {
|
||||
return originalOn(
|
||||
eventName,
|
||||
wrapRequestListenerWithMethodGuard(
|
||||
wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener))
|
||||
wrapRequestListenerWithHeadResponseGuard(
|
||||
wrapRequestListenerWithMethodGuard(
|
||||
wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener))
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -149,8 +157,10 @@ http.createServer = function createServerWithResponsesWs(...args) {
|
||||
if (eventName === "request" && typeof listener === "function") {
|
||||
return originalAddListener(
|
||||
eventName,
|
||||
wrapRequestListenerWithMethodGuard(
|
||||
wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener))
|
||||
wrapRequestListenerWithHeadResponseGuard(
|
||||
wrapRequestListenerWithMethodGuard(
|
||||
wrapRequestListenerWithWebdav(wrapRequestListenerWithPeerStamp(listener))
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user