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:
Diego Rodrigues de Sa e Souza
2026-07-07 21:44:54 -03:00
parent c51eed786c
commit 7a64c1a684
5 changed files with 369 additions and 13 deletions

View 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,
};

View File

@@ -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);

View File

@@ -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))
)
)
);
}