From 00bdb037afd7229072640fd066bc940a26d0084f Mon Sep 17 00:00:00 2001 From: Zack Tanner <1939140+ztanner@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:41:16 -0700 Subject: [PATCH 1/3] Patch improvements into `http-proxy` lib (#91480) See: https://github.com/vercel/next.js/security/advisories/GHSA-ggv3-7p47-pfv8 --- .../next/src/compiled/http-proxy/index.js | 10 +- patches/http-proxy@1.18.1.patch | 50 +++- pnpm-lock.yaml | 10 +- .../rewrite-request-smuggling/next.config.js | 13 + .../rewrite-request-smuggling/pages/index.tsx | 3 + .../rewrite-request-smuggling.test.ts | 229 ++++++++++++++++++ 6 files changed, 302 insertions(+), 13 deletions(-) create mode 100644 test/production/rewrite-request-smuggling/next.config.js create mode 100644 test/production/rewrite-request-smuggling/pages/index.tsx create mode 100644 test/production/rewrite-request-smuggling/rewrite-request-smuggling.test.ts diff --git a/packages/next/src/compiled/http-proxy/index.js b/packages/next/src/compiled/http-proxy/index.js index d7d41b9d698b4d..2e67249ef32a14 100644 --- a/packages/next/src/compiled/http-proxy/index.js +++ b/packages/next/src/compiled/http-proxy/index.js @@ -1,4 +1,4 @@ -(()=>{var e={993:e=>{"use strict";var t=Object.prototype.hasOwnProperty,r="~";function Events(){}if(Object.create){Events.prototype=Object.create(null);if(!(new Events).__proto__)r=false}function EE(e,t,r){this.fn=e;this.context=t;this.once=r||false}function addListener(e,t,o,s,n){if(typeof o!=="function"){throw new TypeError("The listener must be a function")}var i=new EE(o,s||e,n),a=r?r+t:t;if(!e._events[a])e._events[a]=i,e._eventsCount++;else if(!e._events[a].fn)e._events[a].push(i);else e._events[a]=[e._events[a],i];return e}function clearEvent(e,t){if(--e._eventsCount===0)e._events=new Events;else delete e._events[t]}function EventEmitter(){this._events=new Events;this._eventsCount=0}EventEmitter.prototype.eventNames=function eventNames(){var e=[],o,s;if(this._eventsCount===0)return e;for(s in o=this._events){if(t.call(o,s))e.push(r?s.slice(1):s)}if(Object.getOwnPropertySymbols){return e.concat(Object.getOwnPropertySymbols(o))}return e};EventEmitter.prototype.listeners=function listeners(e){var t=r?r+e:e,o=this._events[t];if(!o)return[];if(o.fn)return[o.fn];for(var s=0,n=o.length,i=new Array(n);s{var o=r(310);var s=o.URL;var n=r(685);var i=r(687);var a=r(491);var c=r(781).Writable;var u=r(937)("follow-redirects");var f={GET:true,HEAD:true,OPTIONS:true,TRACE:true};var h=Object.create(null);["abort","aborted","connect","error","socket","timeout"].forEach((function(e){h[e]=function(t,r,o){this._redirectable.emit(e,t,r,o)}}));function RedirectableRequest(e,t){c.call(this);this._sanitizeOptions(e);this._options=e;this._ended=false;this._ending=false;this._redirectCount=0;this._redirects=[];this._requestBodyLength=0;this._requestBodyBuffers=[];if(t){this.on("response",t)}var r=this;this._onNativeResponse=function(e){r._processResponse(e)};this._performRequest()}RedirectableRequest.prototype=Object.create(c.prototype);RedirectableRequest.prototype.write=function(e,t,r){if(this._ending){throw new Error("write after end")}if(!(typeof e==="string"||typeof e==="object"&&"length"in e)){throw new Error("data should be a string, Buffer or Uint8Array")}if(typeof t==="function"){r=t;t=null}if(e.length===0){if(r){r()}return}if(this._requestBodyLength+e.length<=this._options.maxBodyLength){this._requestBodyLength+=e.length;this._requestBodyBuffers.push({data:e,encoding:t});this._currentRequest.write(e,t,r)}else{this.emit("error",new Error("Request body larger than maxBodyLength limit"));this.abort()}};RedirectableRequest.prototype.end=function(e,t,r){if(typeof e==="function"){r=e;e=t=null}else if(typeof t==="function"){r=t;t=null}if(!e){this._ended=this._ending=true;this._currentRequest.end(null,null,r)}else{var o=this;var s=this._currentRequest;this.write(e,t,(function(){o._ended=true;s.end(null,null,r)}));this._ending=true}};RedirectableRequest.prototype.setHeader=function(e,t){this._options.headers[e]=t;this._currentRequest.setHeader(e,t)};RedirectableRequest.prototype.removeHeader=function(e){delete this._options.headers[e];this._currentRequest.removeHeader(e)};RedirectableRequest.prototype.setTimeout=function(e,t){if(t){this.once("timeout",t)}if(this.socket){startTimer(this,e)}else{var r=this;this._currentRequest.once("socket",(function(){startTimer(r,e)}))}this.once("response",clearTimer);this.once("error",clearTimer);return this};function startTimer(e,t){clearTimeout(e._timeout);e._timeout=setTimeout((function(){e.emit("timeout")}),t)}function clearTimer(){clearTimeout(this._timeout)}["abort","flushHeaders","getHeader","setNoDelay","setSocketKeepAlive"].forEach((function(e){RedirectableRequest.prototype[e]=function(t,r){return this._currentRequest[e](t,r)}}));["aborted","connection","socket"].forEach((function(e){Object.defineProperty(RedirectableRequest.prototype,e,{get:function(){return this._currentRequest[e]}})}));RedirectableRequest.prototype._sanitizeOptions=function(e){if(!e.headers){e.headers={}}if(e.host){if(!e.hostname){e.hostname=e.host}delete e.host}if(!e.pathname&&e.path){var t=e.path.indexOf("?");if(t<0){e.pathname=e.path}else{e.pathname=e.path.substring(0,t);e.search=e.path.substring(t)}}};RedirectableRequest.prototype._performRequest=function(){var e=this._options.protocol;var t=this._options.nativeProtocols[e];if(!t){this.emit("error",new Error("Unsupported protocol "+e));return}if(this._options.agents){var r=e.substr(0,e.length-1);this._options.agent=this._options.agents[r]}var s=this._currentRequest=t.request(this._options,this._onNativeResponse);this._currentUrl=o.format(this._options);s._redirectable=this;for(var n in h){if(n){s.on(n,h[n])}}if(this._isRedirect){var i=0;var a=this;var c=this._requestBodyBuffers;(function writeNext(e){if(s===a._currentRequest){if(e){a.emit("error",e)}else if(i=300&&t<400){this._currentRequest.removeAllListeners();this._currentRequest.on("error",noop);this._currentRequest.abort();e.destroy();if(++this._redirectCount>this._options.maxRedirects){this.emit("error",new Error("Max redirects exceeded."));return}var s;var n=this._options.headers;if(t!==307&&!(this._options.method in f)){this._options.method="GET";this._requestBodyBuffers=[];for(s in n){if(/^content-/i.test(s)){delete n[s]}}}if(!this._isRedirect){for(s in n){if(/^host$/i.test(s)){delete n[s]}}}var i=o.resolve(this._currentUrl,r);u("redirecting to",i);Object.assign(this._options,o.parse(i));if(typeof this._options.beforeRedirect==="function"){try{this._options.beforeRedirect.call(null,this._options)}catch(e){this.emit("error",e);return}this._sanitizeOptions(this._options)}this._isRedirect=true;this._performRequest()}else{e.responseUrl=this._currentUrl;e.redirects=this._redirects;this.emit("response",e);this._requestBodyBuffers=[]}};function wrap(e){var t={maxRedirects:21,maxBodyLength:10*1024*1024};var r={};Object.keys(e).forEach((function(n){var i=n+":";var c=r[i]=e[n];var f=t[n]=Object.create(c);f.request=function(e,n,c){if(typeof e==="string"){var f=e;try{e=urlToOptions(new s(f))}catch(t){e=o.parse(f)}}else if(s&&e instanceof s){e=urlToOptions(e)}else{c=n;n=e;e={protocol:i}}if(typeof n==="function"){c=n;n=null}n=Object.assign({maxRedirects:t.maxRedirects,maxBodyLength:t.maxBodyLength},e,n);n.nativeProtocols=r;a.equal(n.protocol,i,"protocol mismatch");u("options",n);return new RedirectableRequest(n,c)};f.get=function(e,t,r){var o=f.request(e,t,r);o.end();return o}}));return t}function noop(){}function urlToOptions(e){var t={protocol:e.protocol,hostname:e.hostname.startsWith("[")?e.hostname.slice(1,-1):e.hostname,hash:e.hash,search:e.search,pathname:e.pathname,path:e.pathname+e.search,href:e.href};if(e.port!==""){t.port=Number(e.port)}return t}e.exports=wrap({http:n,https:i});e.exports.wrap=wrap},768:(e,t,r)=>{ +(()=>{var e={993:e=>{"use strict";var t=Object.prototype.hasOwnProperty,r="~";function Events(){}if(Object.create){Events.prototype=Object.create(null);if(!(new Events).__proto__)r=false}function EE(e,t,r){this.fn=e;this.context=t;this.once=r||false}function addListener(e,t,o,n,s){if(typeof o!=="function"){throw new TypeError("The listener must be a function")}var i=new EE(o,n||e,s),a=r?r+t:t;if(!e._events[a])e._events[a]=i,e._eventsCount++;else if(!e._events[a].fn)e._events[a].push(i);else e._events[a]=[e._events[a],i];return e}function clearEvent(e,t){if(--e._eventsCount===0)e._events=new Events;else delete e._events[t]}function EventEmitter(){this._events=new Events;this._eventsCount=0}EventEmitter.prototype.eventNames=function eventNames(){var e=[],o,n;if(this._eventsCount===0)return e;for(n in o=this._events){if(t.call(o,n))e.push(r?n.slice(1):n)}if(Object.getOwnPropertySymbols){return e.concat(Object.getOwnPropertySymbols(o))}return e};EventEmitter.prototype.listeners=function listeners(e){var t=r?r+e:e,o=this._events[t];if(!o)return[];if(o.fn)return[o.fn];for(var n=0,s=o.length,i=new Array(s);n{var o=r(310);var n=o.URL;var s=r(685);var i=r(687);var a=r(491);var c=r(781).Writable;var u=r(937)("follow-redirects");var f={GET:true,HEAD:true,OPTIONS:true,TRACE:true};var h=Object.create(null);["abort","aborted","connect","error","socket","timeout"].forEach((function(e){h[e]=function(t,r,o){this._redirectable.emit(e,t,r,o)}}));function RedirectableRequest(e,t){c.call(this);this._sanitizeOptions(e);this._options=e;this._ended=false;this._ending=false;this._redirectCount=0;this._redirects=[];this._requestBodyLength=0;this._requestBodyBuffers=[];if(t){this.on("response",t)}var r=this;this._onNativeResponse=function(e){r._processResponse(e)};this._performRequest()}RedirectableRequest.prototype=Object.create(c.prototype);RedirectableRequest.prototype.write=function(e,t,r){if(this._ending){throw new Error("write after end")}if(!(typeof e==="string"||typeof e==="object"&&"length"in e)){throw new Error("data should be a string, Buffer or Uint8Array")}if(typeof t==="function"){r=t;t=null}if(e.length===0){if(r){r()}return}if(this._requestBodyLength+e.length<=this._options.maxBodyLength){this._requestBodyLength+=e.length;this._requestBodyBuffers.push({data:e,encoding:t});this._currentRequest.write(e,t,r)}else{this.emit("error",new Error("Request body larger than maxBodyLength limit"));this.abort()}};RedirectableRequest.prototype.end=function(e,t,r){if(typeof e==="function"){r=e;e=t=null}else if(typeof t==="function"){r=t;t=null}if(!e){this._ended=this._ending=true;this._currentRequest.end(null,null,r)}else{var o=this;var n=this._currentRequest;this.write(e,t,(function(){o._ended=true;n.end(null,null,r)}));this._ending=true}};RedirectableRequest.prototype.setHeader=function(e,t){this._options.headers[e]=t;this._currentRequest.setHeader(e,t)};RedirectableRequest.prototype.removeHeader=function(e){delete this._options.headers[e];this._currentRequest.removeHeader(e)};RedirectableRequest.prototype.setTimeout=function(e,t){if(t){this.once("timeout",t)}if(this.socket){startTimer(this,e)}else{var r=this;this._currentRequest.once("socket",(function(){startTimer(r,e)}))}this.once("response",clearTimer);this.once("error",clearTimer);return this};function startTimer(e,t){clearTimeout(e._timeout);e._timeout=setTimeout((function(){e.emit("timeout")}),t)}function clearTimer(){clearTimeout(this._timeout)}["abort","flushHeaders","getHeader","setNoDelay","setSocketKeepAlive"].forEach((function(e){RedirectableRequest.prototype[e]=function(t,r){return this._currentRequest[e](t,r)}}));["aborted","connection","socket"].forEach((function(e){Object.defineProperty(RedirectableRequest.prototype,e,{get:function(){return this._currentRequest[e]}})}));RedirectableRequest.prototype._sanitizeOptions=function(e){if(!e.headers){e.headers={}}if(e.host){if(!e.hostname){e.hostname=e.host}delete e.host}if(!e.pathname&&e.path){var t=e.path.indexOf("?");if(t<0){e.pathname=e.path}else{e.pathname=e.path.substring(0,t);e.search=e.path.substring(t)}}};RedirectableRequest.prototype._performRequest=function(){var e=this._options.protocol;var t=this._options.nativeProtocols[e];if(!t){this.emit("error",new Error("Unsupported protocol "+e));return}if(this._options.agents){var r=e.substr(0,e.length-1);this._options.agent=this._options.agents[r]}var n=this._currentRequest=t.request(this._options,this._onNativeResponse);this._currentUrl=o.format(this._options);n._redirectable=this;for(var s in h){if(s){n.on(s,h[s])}}if(this._isRedirect){var i=0;var a=this;var c=this._requestBodyBuffers;(function writeNext(e){if(n===a._currentRequest){if(e){a.emit("error",e)}else if(i=300&&t<400){this._currentRequest.removeAllListeners();this._currentRequest.on("error",noop);this._currentRequest.abort();e.destroy();if(++this._redirectCount>this._options.maxRedirects){this.emit("error",new Error("Max redirects exceeded."));return}var n;var s=this._options.headers;if(t!==307&&!(this._options.method in f)){this._options.method="GET";this._requestBodyBuffers=[];for(n in s){if(/^content-/i.test(n)){delete s[n]}}}if(!this._isRedirect){for(n in s){if(/^host$/i.test(n)){delete s[n]}}}var i=o.resolve(this._currentUrl,r);u("redirecting to",i);Object.assign(this._options,o.parse(i));if(typeof this._options.beforeRedirect==="function"){try{this._options.beforeRedirect.call(null,this._options)}catch(e){this.emit("error",e);return}this._sanitizeOptions(this._options)}this._isRedirect=true;this._performRequest()}else{e.responseUrl=this._currentUrl;e.redirects=this._redirects;this.emit("response",e);this._requestBodyBuffers=[]}};function wrap(e){var t={maxRedirects:21,maxBodyLength:10*1024*1024};var r={};Object.keys(e).forEach((function(s){var i=s+":";var c=r[i]=e[s];var f=t[s]=Object.create(c);f.request=function(e,s,c){if(typeof e==="string"){var f=e;try{e=urlToOptions(new n(f))}catch(t){e=o.parse(f)}}else if(n&&e instanceof n){e=urlToOptions(e)}else{c=s;s=e;e={protocol:i}}if(typeof s==="function"){c=s;s=null}s=Object.assign({maxRedirects:t.maxRedirects,maxBodyLength:t.maxBodyLength},e,s);s.nativeProtocols=r;a.equal(s.protocol,i,"protocol mismatch");u("options",s);return new RedirectableRequest(s,c)};f.get=function(e,t,r){var o=f.request(e,t,r);o.end();return o}}));return t}function noop(){}function urlToOptions(e){var t={protocol:e.protocol,hostname:e.hostname.startsWith("[")?e.hostname.slice(1,-1):e.hostname,hash:e.hash,search:e.search,pathname:e.pathname,path:e.pathname+e.search,href:e.href};if(e.port!==""){t.port=Number(e.port)}return t}e.exports=wrap({http:s,https:i});e.exports.wrap=wrap},204:(e,t,r)=>{ /*! * Caron dimonio, con occhi di bragia * loro accennando, tutte le raccoglie; @@ -10,25 +10,25 @@ * * Dante - The Divine Comedy (Canto III) */ -e.exports=r(852)},852:(e,t,r)=>{var o=r(949).Server;function createProxyServer(e){return new o(e)}o.createProxyServer=createProxyServer;o.createServer=createProxyServer;o.createProxy=createProxyServer;e.exports=o},26:(e,t,r)=>{var o=t,s=r(310),n=r(85);var i=/(^|,)\s*upgrade\s*($|,)/i,a=/^https|wss/;o.isSSL=a;o.setupOutgoing=function(e,t,r,c){e.port=t[c||"target"].port||(a.test(t[c||"target"].protocol)?443:80);["host","hostname","socketPath","pfx","key","passphrase","cert","ca","ciphers","secureProtocol"].forEach((function(r){e[r]=t[c||"target"][r]}));e.method=t.method||r.method;e.headers=Object.assign({},r.headers);if(t.headers){Object.assign(e.headers,t.headers)}if(t.auth){e.auth=t.auth}if(t.ca){e.ca=t.ca}if(a.test(t[c||"target"].protocol)){e.rejectUnauthorized=typeof t.secure==="undefined"?true:t.secure}e.agent=t.agent||false;e.localAddress=t.localAddress;if(!e.agent){e.headers=e.headers||{};if(typeof e.headers.connection!=="string"||!i.test(e.headers.connection)){e.headers.connection="close"}}var u=t[c||"target"];var f=u&&t.prependPath!==false?u.path||"":"";var h=!t.toProxy?s.parse(r.url).path||"":r.url;h=!t.ignorePath?h:"";e.path=o.urlJoin(f,h);if(t.changeOrigin){e.headers.host=n(e.port,t[c||"target"].protocol)&&!hasPort(e.host)?e.host+":"+e.port:e.host}return e};o.setupSocket=function(e){e.setTimeout(0);e.setNoDelay(true);e.setKeepAlive(true,0);return e};o.getPort=function(e){var t=e.headers.host?e.headers.host.match(/:(\d+)/):"";return t?t[1]:o.hasEncryptedConnection(e)?"443":"80"};o.hasEncryptedConnection=function(e){return Boolean(e.connection.encrypted||e.connection.pair)};o.urlJoin=function(){var e=Array.prototype.slice.call(arguments),t=e.length-1,r=e[t],o=r.split("?"),s;e[t]=o.shift();s=[e.filter(Boolean).join("/").replace(/\/+/g,"/").replace("http:/","http://").replace("https:/","https://")];s.push.apply(s,o);return s.join("?")};o.rewriteCookieProperty=function rewriteCookieProperty(e,t,r){if(Array.isArray(e)){return e.map((function(e){return rewriteCookieProperty(e,t,r)}))}return e.replace(new RegExp("(;\\s*"+r+"=)([^;]+)","i"),(function(e,r,o){var s;if(o in t){s=t[o]}else if("*"in t){s=t["*"]}else{return e}if(s){return r+s}else{return""}}))};function hasPort(e){return!!~e.indexOf(":")}},949:(e,t,r)=>{var o=e.exports,s=r(310).parse,n=r(993),i=r(685),a=r(687),c=r(244),u=r(893);o.Server=ProxyServer;function createRightProxy(e){return function(t){return function(r,o){var n=e==="ws"?this.wsPasses:this.webPasses,i=[].slice.call(arguments),a=i.length-1,c,u;if(typeof i[a]==="function"){u=i[a];a--}var f=t;if(!(i[a]instanceof Buffer)&&i[a]!==o){f=Object.assign({},t);Object.assign(f,i[a]);a--}if(i[a]instanceof Buffer){c=i[a]}["target","forward"].forEach((function(e){if(typeof f[e]==="string")f[e]=s(f[e])}));if(!f.target&&!f.forward){return this.emit("error",new Error("Must provide a proper URL as target"))}for(var h=0;h{var o=r(685),s=r(687),n=r(417),i=r(26),a=r(900);n=Object.keys(n).map((function(e){return n[e]}));var c={http:o,https:s}; +e.exports=r(763)},763:(e,t,r)=>{var o=r(458).Server;function createProxyServer(e){return new o(e)}o.createProxyServer=createProxyServer;o.createServer=createProxyServer;o.createProxy=createProxyServer;e.exports=o},341:(e,t,r)=>{var o=t,n=r(310),s=r(85);var i=/(^|,)\s*upgrade\s*($|,)/i,a=/(^|,)\s*transfer-encoding\s*($|,)/i,c=/^https|wss/;o.isSSL=c;o.setupOutgoing=function(e,t,r,u){e.port=t[u||"target"].port||(c.test(t[u||"target"].protocol)?443:80);["host","hostname","socketPath","pfx","key","passphrase","cert","ca","ciphers","secureProtocol"].forEach((function(r){e[r]=t[u||"target"][r]}));e.method=t.method||r.method;e.headers=Object.assign({},r.headers);if(t.headers){Object.assign(e.headers,t.headers)}if(t.auth){e.auth=t.auth}if(t.ca){e.ca=t.ca}if(c.test(t[u||"target"].protocol)){e.rejectUnauthorized=typeof t.secure==="undefined"?true:t.secure}e.agent=t.agent||false;e.localAddress=t.localAddress;e.headers=e.headers||{};var f=Object.keys(e.headers).some((function(t){return t.toLowerCase()==="transfer-encoding"&&typeof e.headers[t]!=="undefined"}));if(f||typeof e.headers.connection==="string"&&a.test(e.headers.connection)){e.headers.connection="close"}if(!e.agent){if(typeof e.headers.connection!=="string"||!i.test(e.headers.connection)){e.headers.connection="close"}}var h=t[u||"target"];var p=h&&t.prependPath!==false?h.path||"":"";var d=!t.toProxy?n.parse(r.url).path||"":r.url;d=!t.ignorePath?d:"";e.path=o.urlJoin(p,d);if(t.changeOrigin){e.headers.host=s(e.port,t[u||"target"].protocol)&&!hasPort(e.host)?e.host+":"+e.port:e.host}return e};o.setupSocket=function(e){e.setTimeout(0);e.setNoDelay(true);e.setKeepAlive(true,0);return e};o.getPort=function(e){var t=e.headers.host?e.headers.host.match(/:(\d+)/):"";return t?t[1]:o.hasEncryptedConnection(e)?"443":"80"};o.hasEncryptedConnection=function(e){return Boolean(e.connection.encrypted||e.connection.pair)};o.urlJoin=function(){var e=Array.prototype.slice.call(arguments),t=e.length-1,r=e[t],o=r.split("?"),n;e[t]=o.shift();n=[e.filter(Boolean).join("/").replace(/\/+/g,"/").replace("http:/","http://").replace("https:/","https://")];n.push.apply(n,o);return n.join("?")};o.rewriteCookieProperty=function rewriteCookieProperty(e,t,r){if(Array.isArray(e)){return e.map((function(e){return rewriteCookieProperty(e,t,r)}))}return e.replace(new RegExp("(;\\s*"+r+"=)([^;]+)","i"),(function(e,r,o){var n;if(o in t){n=t[o]}else if("*"in t){n=t["*"]}else{return e}if(n){return r+n}else{return""}}))};function hasPort(e){return!!~e.indexOf(":")}},458:(e,t,r)=>{var o=e.exports,n=r(310).parse,s=r(993),i=r(685),a=r(687),c=r(101),u=r(761);o.Server=ProxyServer;function createRightProxy(e){return function(t){return function(r,o){var s=e==="ws"?this.wsPasses:this.webPasses,i=[].slice.call(arguments),a=i.length-1,c,u;if(typeof i[a]==="function"){u=i[a];a--}var f=t;if(!(i[a]instanceof Buffer)&&i[a]!==o){f=Object.assign({},t);Object.assign(f,i[a]);a--}if(i[a]instanceof Buffer){c=i[a]}["target","forward"].forEach((function(e){if(typeof f[e]==="string")f[e]=n(f[e])}));if(!f.target&&!f.forward){return this.emit("error",new Error("Must provide a proper URL as target"))}for(var h=0;h{var o=r(685),n=r(687),s=r(445),i=r(341),a=r(900);s=Object.keys(s).map((function(e){return s[e]}));var c={http:o,https:n}; /*! * Array of passes. * * A `pass` is just a function that is executed on `req, res, options` * so that you can easily add new checks while still keeping the base * flexible. - */e.exports={deleteLength:function deleteLength(e,t,r){if((e.method==="DELETE"||e.method==="OPTIONS")&&!e.headers["content-length"]){e.headers["content-length"]="0";delete e.headers["transfer-encoding"]}},timeout:function timeout(e,t,r){if(r.timeout){e.socket.setTimeout(r.timeout)}},XHeaders:function XHeaders(e,t,r){if(!r.xfwd)return;var o=e.isSpdy||i.hasEncryptedConnection(e);var s={for:e.connection.remoteAddress||e.socket.remoteAddress,port:i.getPort(e),proto:o?"https":"http"};["for","port","proto"].forEach((function(t){e.headers["x-forwarded-"+t]=(e.headers["x-forwarded-"+t]||"")+(e.headers["x-forwarded-"+t]?",":"")+s[t]}));e.headers["x-forwarded-host"]=e.headers["x-forwarded-host"]||e.headers["host"]||""},stream:function stream(e,t,r,o,s,u){s.emit("start",e,t,r.target||r.forward);var f=r.followRedirects?a:c;var h=f.http;var p=f.https;if(r.forward){var d=(r.forward.protocol==="https:"?p:h).request(i.setupOutgoing(r.ssl||{},r,e,"forward"));var l=createErrorHandler(d,r.forward);e.on("error",l);d.on("error",l);(r.buffer||e).pipe(d);if(!r.target){return t.end()}}var v=(r.target.protocol==="https:"?p:h).request(i.setupOutgoing(r.ssl||{},r,e));v.on("socket",(function(o){if(s&&!v.getHeader("expect")){s.emit("proxyReq",v,e,t,r)}}));if(r.proxyTimeout){v.setTimeout(r.proxyTimeout,(function(){v.abort()}))}e.on("aborted",(function(){v.abort()}));var m=createErrorHandler(v,r.target);e.on("error",m);v.on("error",m);function createErrorHandler(r,o){return function proxyError(n){if(e.socket.destroyed&&n.code==="ECONNRESET"){s.emit("econnreset",n,e,t,o);return r.abort()}if(u){u(n,e,t,o)}else{s.emit("error",n,e,t,o)}}}(r.buffer||e).pipe(v);v.on("response",(function(o){if(s){s.emit("proxyRes",o,e,t)}if(!t.headersSent&&!r.selfHandleResponse){for(var i=0;i{var o=r(310),s=r(26);var n=/^201|30(1|2|7|8)$/; + */e.exports={deleteLength:function deleteLength(e,t,r){if((e.method==="DELETE"||e.method==="OPTIONS")&&typeof e.headers["content-length"]==="undefined"&&typeof e.headers["transfer-encoding"]==="undefined"){e.headers["content-length"]="0"}},timeout:function timeout(e,t,r){if(r.timeout){e.socket.setTimeout(r.timeout)}},XHeaders:function XHeaders(e,t,r){if(!r.xfwd)return;var o=e.isSpdy||i.hasEncryptedConnection(e);var n={for:e.connection.remoteAddress||e.socket.remoteAddress,port:i.getPort(e),proto:o?"https":"http"};["for","port","proto"].forEach((function(t){e.headers["x-forwarded-"+t]=(e.headers["x-forwarded-"+t]||"")+(e.headers["x-forwarded-"+t]?",":"")+n[t]}));e.headers["x-forwarded-host"]=e.headers["x-forwarded-host"]||e.headers["host"]||""},stream:function stream(e,t,r,o,n,u){n.emit("start",e,t,r.target||r.forward);var f=r.followRedirects?a:c;var h=f.http;var p=f.https;if(r.forward){var d=(r.forward.protocol==="https:"?p:h).request(i.setupOutgoing(r.ssl||{},r,e,"forward"));var l=createErrorHandler(d,r.forward);e.on("error",l);d.on("error",l);(r.buffer||e).pipe(d);if(!r.target){return t.end()}}var v=(r.target.protocol==="https:"?p:h).request(i.setupOutgoing(r.ssl||{},r,e));v.on("socket",(function(o){if(n&&!v.getHeader("expect")){n.emit("proxyReq",v,e,t,r)}}));if(r.proxyTimeout){v.setTimeout(r.proxyTimeout,(function(){v.abort()}))}e.on("aborted",(function(){v.abort()}));var m=createErrorHandler(v,r.target);e.on("error",m);v.on("error",m);function createErrorHandler(r,o){return function proxyError(s){if(e.socket.destroyed&&s.code==="ECONNRESET"){n.emit("econnreset",s,e,t,o);return r.abort()}if(u){u(s,e,t,o)}else{n.emit("error",s,e,t,o)}}}(r.buffer||e).pipe(v);v.on("response",(function(o){if(n){n.emit("proxyRes",o,e,t)}if(!t.headersSent&&!r.selfHandleResponse){for(var i=0;i{var o=r(310),n=r(341);var s=/^201|30(1|2|7|8)$/; /*! * Array of passes. * * A `pass` is just a function that is executed on `req, res, options` * so that you can easily add new checks while still keeping the base * flexible. - */e.exports={removeChunked:function removeChunked(e,t,r){if(e.httpVersion==="1.0"){delete r.headers["transfer-encoding"]}},setConnection:function setConnection(e,t,r){if(e.httpVersion==="1.0"){r.headers.connection=e.headers.connection||"close"}else if(e.httpVersion!=="2.0"&&!r.headers.connection){r.headers.connection=e.headers.connection||"keep-alive"}},setRedirectHostRewrite:function setRedirectHostRewrite(e,t,r,s){if((s.hostRewrite||s.autoRewrite||s.protocolRewrite)&&r.headers["location"]&&n.test(r.statusCode)){var i=o.parse(s.target);var a=o.parse(r.headers["location"]);if(i.host!=a.host){return}if(s.hostRewrite){a.host=s.hostRewrite}else if(s.autoRewrite){a.host=e.headers["host"]}if(s.protocolRewrite){a.protocol=s.protocolRewrite}r.headers["location"]=a.format()}},writeHeaders:function writeHeaders(e,t,r,o){var n=o.cookieDomainRewrite,i=o.cookiePathRewrite,a=o.preserveHeaderKeyCase,c,setHeader=function(e,r){if(r==undefined)return;if(n&&e.toLowerCase()==="set-cookie"){r=s.rewriteCookieProperty(r,n,"domain")}if(i&&e.toLowerCase()==="set-cookie"){r=s.rewriteCookieProperty(r,i,"path")}t.setHeader(String(e).trim(),r)};if(typeof n==="string"){n={"*":n}}if(typeof i==="string"){i={"*":i}}if(a&&r.rawHeaders!=undefined){c={};for(var u=0;u{var o=r(685),s=r(687),n=r(26); + */e.exports={removeChunked:function removeChunked(e,t,r){if(e.httpVersion==="1.0"){delete r.headers["transfer-encoding"]}},setConnection:function setConnection(e,t,r){if(e.httpVersion==="1.0"){r.headers.connection=e.headers.connection||"close"}else if(e.httpVersion!=="2.0"&&!r.headers.connection){r.headers.connection=e.headers.connection||"keep-alive"}},setRedirectHostRewrite:function setRedirectHostRewrite(e,t,r,n){if((n.hostRewrite||n.autoRewrite||n.protocolRewrite)&&r.headers["location"]&&s.test(r.statusCode)){var i=o.parse(n.target);var a=o.parse(r.headers["location"]);if(i.host!=a.host){return}if(n.hostRewrite){a.host=n.hostRewrite}else if(n.autoRewrite){a.host=e.headers["host"]}if(n.protocolRewrite){a.protocol=n.protocolRewrite}r.headers["location"]=a.format()}},writeHeaders:function writeHeaders(e,t,r,o){var s=o.cookieDomainRewrite,i=o.cookiePathRewrite,a=o.preserveHeaderKeyCase,c,setHeader=function(e,r){if(r==undefined)return;if(s&&e.toLowerCase()==="set-cookie"){r=n.rewriteCookieProperty(r,s,"domain")}if(i&&e.toLowerCase()==="set-cookie"){r=n.rewriteCookieProperty(r,i,"path")}t.setHeader(String(e).trim(),r)};if(typeof s==="string"){s={"*":s}}if(typeof i==="string"){i={"*":i}}if(a&&r.rawHeaders!=undefined){c={};for(var u=0;u{var o=r(685),n=r(687),s=r(341); /*! * Array of passes. * * A `pass` is just a function that is executed on `req, socket, options` * so that you can easily add new checks while still keeping the base * flexible. - */e.exports={checkMethodAndHeader:function checkMethodAndHeader(e,t){if(e.method!=="GET"||!e.headers.upgrade){t.destroy();return true}if(e.headers.upgrade.toLowerCase()!=="websocket"){t.destroy();return true}},XHeaders:function XHeaders(e,t,r){if(!r.xfwd)return;var o={for:e.connection.remoteAddress||e.socket.remoteAddress,port:n.getPort(e),proto:n.hasEncryptedConnection(e)?"wss":"ws"};["for","port","proto"].forEach((function(t){e.headers["x-forwarded-"+t]=(e.headers["x-forwarded-"+t]||"")+(e.headers["x-forwarded-"+t]?",":"")+o[t]}))},stream:function stream(e,t,r,i,a,c){var createHttpHeader=function(e,t){return Object.keys(t).reduce((function(e,r){var o=t[r];if(!Array.isArray(o)){e.push(r+": "+o);return e}for(var s=0;s{"use strict";e.exports=function required(e,t){t=t.split(":")[0];e=+e;if(!e)return false;switch(t){case"http":case"ws":return e!==80;case"https":case"wss":return e!==443;case"ftp":return e!==21;case"gopher":return e!==70;case"file":return false}return e!==0}},491:e=>{"use strict";e.exports=require("assert")},685:e=>{"use strict";e.exports=require("http")},687:e=>{"use strict";e.exports=require("https")},937:e=>{"use strict";e.exports=require("next/dist/compiled/debug")},781:e=>{"use strict";e.exports=require("stream")},310:e=>{"use strict";e.exports=require("url")},837:e=>{"use strict";e.exports=require("util")}};var t={};function __nccwpck_require__(r){var o=t[r];if(o!==undefined){return o.exports}var s=t[r]={exports:{}};var n=true;try{e[r](s,s.exports,__nccwpck_require__);n=false}finally{if(n)delete t[r]}return s.exports}if(typeof __nccwpck_require__!=="undefined")__nccwpck_require__.ab=__dirname+"/";var r=__nccwpck_require__(768);module.exports=r})(); \ No newline at end of file + */e.exports={checkMethodAndHeader:function checkMethodAndHeader(e,t){if(e.method!=="GET"||!e.headers.upgrade){t.destroy();return true}if(e.headers.upgrade.toLowerCase()!=="websocket"){t.destroy();return true}},XHeaders:function XHeaders(e,t,r){if(!r.xfwd)return;var o={for:e.connection.remoteAddress||e.socket.remoteAddress,port:s.getPort(e),proto:s.hasEncryptedConnection(e)?"wss":"ws"};["for","port","proto"].forEach((function(t){e.headers["x-forwarded-"+t]=(e.headers["x-forwarded-"+t]||"")+(e.headers["x-forwarded-"+t]?",":"")+o[t]}))},stream:function stream(e,t,r,i,a,c){var createHttpHeader=function(e,t){return Object.keys(t).reduce((function(e,r){var o=t[r];if(!Array.isArray(o)){e.push(r+": "+o);return e}for(var n=0;n{"use strict";e.exports=function required(e,t){t=t.split(":")[0];e=+e;if(!e)return false;switch(t){case"http":case"ws":return e!==80;case"https":case"wss":return e!==443;case"ftp":return e!==21;case"gopher":return e!==70;case"file":return false}return e!==0}},491:e=>{"use strict";e.exports=require("assert")},685:e=>{"use strict";e.exports=require("http")},687:e=>{"use strict";e.exports=require("https")},937:e=>{"use strict";e.exports=require("next/dist/compiled/debug")},781:e=>{"use strict";e.exports=require("stream")},310:e=>{"use strict";e.exports=require("url")},837:e=>{"use strict";e.exports=require("util")}};var t={};function __nccwpck_require__(r){var o=t[r];if(o!==undefined){return o.exports}var n=t[r]={exports:{}};var s=true;try{e[r](n,n.exports,__nccwpck_require__);s=false}finally{if(s)delete t[r]}return n.exports}if(typeof __nccwpck_require__!=="undefined")__nccwpck_require__.ab=__dirname+"/";var r=__nccwpck_require__(204);module.exports=r})(); \ No newline at end of file diff --git a/patches/http-proxy@1.18.1.patch b/patches/http-proxy@1.18.1.patch index 04e179137026a3..e30900ebf339d3 100644 --- a/patches/http-proxy@1.18.1.patch +++ b/patches/http-proxy@1.18.1.patch @@ -1,15 +1,19 @@ diff --git a/lib/http-proxy/common.js b/lib/http-proxy/common.js -index 6513e81d80d5250ea249ea833f819ece67897c7e..486d4c896d65a3bb7cf63307af68facb3ddb886b 100644 +index 6513e81d80d5250ea249ea833f819ece67897c7e..09143dd1fe4e67885f40ea916a6ea1ef3e3afa19 100644 --- a/lib/http-proxy/common.js +++ b/lib/http-proxy/common.js -@@ -1,6 +1,5 @@ +@@ -1,9 +1,9 @@ var common = exports, url = require('url'), - extend = require('util')._extend, required = require('requires-port'); var upgradeHeader = /(^|,)\s*upgrade\s*($|,)/i, -@@ -40,10 +39,10 @@ common.setupOutgoing = function(outgoing, options, req, forward) { ++ hopByHopTransferEncodingHeader = /(^|,)\s*transfer-encoding\s*($|,)/i, + isSSL = /^https|wss/; + + /** +@@ -40,10 +40,10 @@ common.setupOutgoing = function(outgoing, options, req, forward) { ); outgoing.method = options.method || req.method; @@ -22,6 +26,30 @@ index 6513e81d80d5250ea249ea833f819ece67897c7e..486d4c896d65a3bb7cf63307af68facb } if (options.auth) { +@@ -61,13 +61,22 @@ common.setupOutgoing = function(outgoing, options, req, forward) { + + outgoing.agent = options.agent || false; + outgoing.localAddress = options.localAddress; ++ outgoing.headers = outgoing.headers || {}; ++ var hasTransferEncodingHeader = Object.keys(outgoing.headers).some(function (header) { ++ return header.toLowerCase() === 'transfer-encoding' ++ && typeof outgoing.headers[header] !== 'undefined'; ++ }); ++ ++ if (hasTransferEncodingHeader ++ || (typeof outgoing.headers.connection === 'string' ++ && hopByHopTransferEncodingHeader.test(outgoing.headers.connection)) ++ ) { outgoing.headers.connection = 'close'; } + + // + // Remark: If we are false and not upgrading, set the connection: close. This is the right thing to do + // as node core doesn't handle this COMPLETELY properly yet. + // + if (!outgoing.agent) { +- outgoing.headers = outgoing.headers || {}; + if (typeof outgoing.headers.connection !== 'string' + || !upgradeHeader.test(outgoing.headers.connection) + ) { outgoing.headers.connection = 'close'; } diff --git a/lib/http-proxy/index.js b/lib/http-proxy/index.js index 977a4b3622b9eaac27689f06347ea4c5173a96cd..88b2d0fcfa03c3aafa47c7e6d38e64412c45a7cc 100644 --- a/lib/http-proxy/index.js @@ -44,3 +72,19 @@ index 977a4b3622b9eaac27689f06347ea4c5173a96cd..88b2d0fcfa03c3aafa47c7e6d38e6441 cntr--; } +diff --git a/lib/http-proxy/passes/web-incoming.js b/lib/http-proxy/passes/web-incoming.js +index 7ae735514190eea569c605fff7d27c045fe8d601..c7c25e7228b21c76b3c7115af82ddcbf13a8e3ec 100644 +--- a/lib/http-proxy/passes/web-incoming.js ++++ b/lib/http-proxy/passes/web-incoming.js +@@ -33,9 +33,9 @@ module.exports = { + + deleteLength: function deleteLength(req, res, options) { + if((req.method === 'DELETE' || req.method === 'OPTIONS') +- && !req.headers['content-length']) { ++ && typeof req.headers['content-length'] === 'undefined' ++ && typeof req.headers['transfer-encoding'] === 'undefined') { + req.headers['content-length'] = '0'; +- delete req.headers['transfer-encoding']; + } + }, + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32b38260424056..6607a3d4b40c43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,7 +29,7 @@ patchedDependencies: hash: rvl3vkomen3tospgr67bzubfyu path: patches/@types__node@20.17.6.patch http-proxy@1.18.1: - hash: qqiqxx62zlcu62nljjmhlvexni + hash: eyqcxg3pntyhqyqr5zytxa7pbi path: patches/http-proxy@1.18.1.patch minizlib@3.1.0: hash: o4nv5mg6kfnuzlknbdln3azhvu @@ -371,7 +371,7 @@ importers: version: 5.1.18 http-proxy: specifier: 1.18.1 - version: 1.18.1(patch_hash=qqiqxx62zlcu62nljjmhlvexni) + version: 1.18.1(patch_hash=eyqcxg3pntyhqyqr5zytxa7pbi) husky: specifier: 9.0.11 version: 9.0.11 @@ -1523,7 +1523,7 @@ importers: version: 5.1.1 http-proxy: specifier: 1.18.1 - version: 1.18.1(patch_hash=qqiqxx62zlcu62nljjmhlvexni) + version: 1.18.1(patch_hash=eyqcxg3pntyhqyqr5zytxa7pbi) http-proxy-agent: specifier: 5.0.0 version: 5.0.0 @@ -30554,7 +30554,7 @@ snapshots: http-proxy-middleware@2.0.7(@types/express@4.17.21): dependencies: '@types/http-proxy': 1.17.17 - http-proxy: 1.18.1(patch_hash=qqiqxx62zlcu62nljjmhlvexni) + http-proxy: 1.18.1(patch_hash=eyqcxg3pntyhqyqr5zytxa7pbi) is-glob: 4.0.3 is-plain-obj: 3.0.0 micromatch: 4.0.8 @@ -30563,7 +30563,7 @@ snapshots: transitivePeerDependencies: - supports-color - http-proxy@1.18.1(patch_hash=qqiqxx62zlcu62nljjmhlvexni): + http-proxy@1.18.1(patch_hash=eyqcxg3pntyhqyqr5zytxa7pbi): dependencies: eventemitter3: 4.0.7 follow-redirects: 1.9.0 diff --git a/test/production/rewrite-request-smuggling/next.config.js b/test/production/rewrite-request-smuggling/next.config.js new file mode 100644 index 00000000000000..d0471a4691ece2 --- /dev/null +++ b/test/production/rewrite-request-smuggling/next.config.js @@ -0,0 +1,13 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + async rewrites() { + return [ + { + source: '/rewrites/:path*', + destination: `http://127.0.0.1:${process.env.TEST_INTERMEDIARY_PORT}/rewrites/:path*`, + }, + ] + }, +} + +module.exports = nextConfig diff --git a/test/production/rewrite-request-smuggling/pages/index.tsx b/test/production/rewrite-request-smuggling/pages/index.tsx new file mode 100644 index 00000000000000..ff7159d9149fee --- /dev/null +++ b/test/production/rewrite-request-smuggling/pages/index.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

hello world

+} diff --git a/test/production/rewrite-request-smuggling/rewrite-request-smuggling.test.ts b/test/production/rewrite-request-smuggling/rewrite-request-smuggling.test.ts new file mode 100644 index 00000000000000..e53984ab1f54b2 --- /dev/null +++ b/test/production/rewrite-request-smuggling/rewrite-request-smuggling.test.ts @@ -0,0 +1,229 @@ +import net from 'net' +import http from 'http' +import { createNext, NextInstance } from 'e2e-utils' +import { findPort, retry } from 'next-test-utils' + +describe('rewrite-request-smuggling', () => { + let backend: http.Server + let backendPort: number + let intermediary: http.Server + let intermediaryPort: number + let next: NextInstance + const backendRequests: string[] = [] + + async function sendSmugglingPayload({ + nextPort, + connectionHeader, + method = 'DELETE', + rewritePath = '/rewrites/poc', + }: { + nextPort: number + connectionHeader: string + method?: 'DELETE' | 'OPTIONS' + rewritePath?: string + }) { + const smuggledRequest = Buffer.from( + `GET /secret HTTP/1.1\r\nHost: 127.0.0.1:${nextPort}\r\n\r\n`, + 'latin1' + ) + const chunkSize = Buffer.from( + `${smuggledRequest.length.toString(16).toUpperCase()}\r\n`, + 'latin1' + ) + + const payload = Buffer.concat([ + Buffer.from( + `${method} ${rewritePath} HTTP/1.1\r\nHost: 127.0.0.1:${nextPort}\r\nTransfer-Encoding: chunked\r\nConnection: ${connectionHeader}\r\n\r\n`, + 'latin1' + ), + chunkSize, + smuggledRequest, + Buffer.from('\r\n0\r\n\r\n', 'latin1'), + ]) + + await new Promise((resolve, reject) => { + const socket = net.createConnection({ + host: '127.0.0.1', + port: nextPort, + }) + + socket.once('connect', () => { + socket.write(payload) + }) + socket.once('error', reject) + socket.setTimeout(1000, () => socket.destroy()) + socket.once('close', () => resolve()) + }) + } + + beforeAll(async () => { + backendPort = await findPort() + intermediaryPort = await findPort() + + backend = http.createServer((req, res) => { + backendRequests.push(`${req.method} ${req.url}`) + + if (req.url?.startsWith('/rewrites/')) { + res.statusCode = 200 + res.end('rewrite-ok') + return + } + + if (req.url === '/secret') { + res.statusCode = 200 + res.end('secret') + return + } + + res.statusCode = 404 + res.end('not-found') + }) + + intermediary = http.createServer((req, res) => { + const connectionHeader = Array.isArray(req.headers['connection']) + ? req.headers['connection'].join(',') + : req.headers['connection'] || '' + const hopByHopHeaders = connectionHeader + .split(',') + .map((h) => h.trim().toLowerCase()) + .filter(Boolean) + const stripTransferEncodingUnconditionally = + req.url?.startsWith('/rewrites/non-rfc-strip') || false + + const forwardHeaders: Record = {} + for (const [key, value] of Object.entries(req.headers)) { + if (key === 'connection') continue + if (stripTransferEncodingUnconditionally && key === 'transfer-encoding') + continue + if (hopByHopHeaders.includes(key)) continue + if (value !== undefined) { + forwardHeaders[key] = value + } + } + forwardHeaders.connection = stripTransferEncodingUnconditionally + ? connectionHeader.toLowerCase().includes('close') + ? 'close' + : 'keep-alive' + : 'keep-alive' + + const proxyReq = http.request( + { + hostname: '127.0.0.1', + port: backendPort, + method: req.method, + path: req.url, + headers: forwardHeaders, + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode || 500, proxyRes.headers) + proxyRes.pipe(res) + } + ) + + proxyReq.on('error', () => { + res.statusCode = 502 + res.end('Bad Gateway') + }) + + req.pipe(proxyReq) + }) + + await new Promise((resolve, reject) => { + backend.listen(backendPort, '127.0.0.1', resolve) + backend.once('error', reject) + }) + + await new Promise((resolve, reject) => { + intermediary.listen(intermediaryPort, '127.0.0.1', resolve) + intermediary.once('error', reject) + }) + + next = await createNext({ + files: __dirname, + env: { + TEST_INTERMEDIARY_PORT: String(intermediaryPort), + }, + }) + }) + + afterAll(async () => { + await next?.destroy() + await new Promise((resolve) => intermediary.close(() => resolve())) + await new Promise((resolve) => backend.close(() => resolve())) + }) + + it('does not smuggle a second request when using keep-alive only', async () => { + backendRequests.length = 0 + + const nextPort = Number(new URL(next.url).port) + await sendSmugglingPayload({ nextPort, connectionHeader: 'keep-alive' }) + + await retry(async () => { + expect(backendRequests).toContain('DELETE /rewrites/poc') + }) + expect(backendRequests).not.toContain('GET /secret') + }) + + it('does not smuggle a second request with keep-alive, upgrade', async () => { + backendRequests.length = 0 + + const nextPort = Number(new URL(next.url).port) + await sendSmugglingPayload({ + nextPort, + connectionHeader: 'keep-alive, upgrade', + }) + + await retry(async () => { + expect(backendRequests).toContain('DELETE /rewrites/poc') + }) + expect(backendRequests).not.toContain('GET /secret') + }) + + it('does not smuggle a second request with Transfer-Encoding, upgrade', async () => { + backendRequests.length = 0 + + const nextPort = Number(new URL(next.url).port) + await sendSmugglingPayload({ + nextPort, + connectionHeader: 'Transfer-Encoding, upgrade', + }) + + await retry(async () => { + expect(backendRequests).toContain('DELETE /rewrites/poc') + }) + expect(backendRequests).not.toContain('GET /secret') + }) + + it('does not smuggle a second request for OPTIONS with Transfer-Encoding, upgrade', async () => { + backendRequests.length = 0 + + const nextPort = Number(new URL(next.url).port) + await sendSmugglingPayload({ + nextPort, + method: 'OPTIONS', + connectionHeader: 'Transfer-Encoding, upgrade', + }) + + await retry(async () => { + expect(backendRequests).toContain('OPTIONS /rewrites/poc') + }) + expect(backendRequests).not.toContain('GET /secret') + }) + + it('does not smuggle a second request when an intermediary strips transfer-encoding unconditionally', async () => { + backendRequests.length = 0 + + const nextPort = Number(new URL(next.url).port) + await sendSmugglingPayload({ + nextPort, + method: 'OPTIONS', + rewritePath: '/rewrites/non-rfc-strip', + connectionHeader: 'keep-alive, upgrade', + }) + + await retry(async () => { + expect(backendRequests).toContain('OPTIONS /rewrites/non-rfc-strip') + }) + expect(backendRequests).not.toContain('GET /secret') + }) +}) From a27a11d78e748a8c7ccfd14b7759ad2b9bf097d8 Mon Sep 17 00:00:00 2001 From: Zack Tanner <1939140+ztanner@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:57:51 -0700 Subject: [PATCH 2/3] Disallow Server Action submissions from privacy-sensitive contexts (#91478) See: https://github.com/vercel/next.js/security/advisories/GHSA-mq59-m269-xvcx and [16.1.7](https://github.com/vercel/next.js/releases/tag/v16.1.7) --------- Co-authored-by: Sebastian "Sebbie" Silbermann --- .../src/server/app-render/action-handler.ts | 25 ++++--- .../app-action-allowed-origins.test.ts | 13 ---- .../app-action-opaque-origin.test.ts | 71 +++++++++++++++++++ .../opaque-origin/app/action.js | 10 +++ .../opaque-origin/app/layout.js | 21 ++++++ .../opaque-origin/app/sandboxed/page.js | 14 ++++ .../opaque-origin/next.config.js | 27 +++++++ 7 files changed, 159 insertions(+), 22 deletions(-) create mode 100644 test/e2e/app-dir/actions-allowed-origins/app-action-opaque-origin.test.ts create mode 100644 test/e2e/app-dir/actions-allowed-origins/opaque-origin/app/action.js create mode 100644 test/e2e/app-dir/actions-allowed-origins/opaque-origin/app/layout.js create mode 100644 test/e2e/app-dir/actions-allowed-origins/opaque-origin/app/sandboxed/page.js create mode 100644 test/e2e/app-dir/actions-allowed-origins/opaque-origin/next.config.js diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index b183998acd1904..2ebc053b741838 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -616,9 +616,14 @@ export async function handleAction({ workStore.fetchCache = 'default-no-store' const originHeader = req.headers['origin'] - const originDomain = - typeof originHeader === 'string' && originHeader !== 'null' - ? new URL(originHeader).host + const originHost = + typeof originHeader === 'string' + ? // 'null' is a valid origin e.g. from privacy-sensitive contexts like sandboxed iframes. + // However, these contexts can still send along credentials like cookies, + // so we need to check if they're allowed cross-origin requests. + originHeader === 'null' + ? 'null' + : new URL(originHeader).host : undefined const host = parseHostHeader(req.headers) @@ -631,15 +636,17 @@ export async function handleAction({ } // This is to prevent CSRF attacks. If `x-forwarded-host` is set, we need to // ensure that the request is coming from the same host. - if (!originDomain) { - // This might be an old browser that doesn't send `host` header. We ignore - // this case. + if (!originHost) { + // This is a handcrafted request without an origin or a request from an unsafe browser. + // We'll let this through but log a warning. + // We can't guard against unsafe browsers and handcrafted requests can't contain + // user credentials that haven't been shared willingly. warning = 'Missing `origin` header from a forwarded Server Actions request.' - } else if (!host || originDomain !== host.value) { + } else if (!host || originHost !== host.value) { // If the customer sets a list of allowed origins, we'll allow the request. // These are considered safe but might be different from forwarded host set // by the infra (i.e. reverse proxies). - if (isCsrfOriginAllowed(originDomain, serverActions?.allowedOrigins)) { + if (isCsrfOriginAllowed(originHost, serverActions?.allowedOrigins)) { // Ignore it } else { if (host) { @@ -650,7 +657,7 @@ export async function handleAction({ }\` header with value \`${limitUntrustedHeaderValueForLogs( host.value )}\` does not match \`origin\` header with value \`${limitUntrustedHeaderValueForLogs( - originDomain + originHost )}\` from a forwarded Server Actions request. Aborting the action.` ) } else { diff --git a/test/e2e/app-dir/actions-allowed-origins/app-action-allowed-origins.test.ts b/test/e2e/app-dir/actions-allowed-origins/app-action-allowed-origins.test.ts index 8cf75c75860fcc..7a055bb2fc5a92 100644 --- a/test/e2e/app-dir/actions-allowed-origins/app-action-allowed-origins.test.ts +++ b/test/e2e/app-dir/actions-allowed-origins/app-action-allowed-origins.test.ts @@ -26,17 +26,4 @@ describe('app-dir action allowed origins', () => { return await browser.elementByCss('#res').text() }, 'hi') }) - - it('should not crash for requests from privacy sensitive contexts', async function () { - const res = await next.fetch('/', { - method: 'POST', - headers: { - Origin: 'null', - 'Content-type': 'application/x-www-form-urlencoded', - 'Sec-Fetch-Site': 'same-origin', - }, - }) - - expect({ status: res.status }).toEqual({ status: 200 }) - }) }) diff --git a/test/e2e/app-dir/actions-allowed-origins/app-action-opaque-origin.test.ts b/test/e2e/app-dir/actions-allowed-origins/app-action-opaque-origin.test.ts new file mode 100644 index 00000000000000..b4126ae632d0c2 --- /dev/null +++ b/test/e2e/app-dir/actions-allowed-origins/app-action-opaque-origin.test.ts @@ -0,0 +1,71 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' +import { join } from 'path' + +describe('app-dir action allowed from opaque origins', () => { + const { next, skipped } = nextTestSetup({ + files: join(__dirname, 'opaque-origin'), + skipDeployment: true, + env: { + NEXT_TEST_ALLOW_OPAQUE_ORIGIN: '1', + }, + }) + + if (skipped) { + return + } + + it('should succeed on submission', async function () { + const browser = await next.browser('/sandboxed') + + await browser.elementByCss('input[type="submit"]').click() + + await retry(async () => { + expect(await browser.elementByCss('output').text()).toEqual( + 'Action Invoked' + ) + }) + }) +}) + +describe('app-dir action disallowed from opaque origins', () => { + const { isNextDev, next, skipped } = nextTestSetup({ + files: join(__dirname, 'opaque-origin'), + skipDeployment: true, + env: { + NEXT_TEST_ALLOW_OPAQUE_ORIGIN: '', + }, + }) + + if (skipped) { + return + } + + it('should fail on submission', async function () { + const browser = await next.browser('/sandboxed') + const beforeSubmissionLogOffset = (await browser.log()).length + + await browser.elementByCss('input[type="submit"]').click() + + await retry(async () => { + const logs = await browser.log() + const newLogs = logs.slice(beforeSubmissionLogOffset) + expect(newLogs).toEqual( + expect.arrayContaining([ + { + source: 'error', + message: + 'Failed to load resource: the server responded with a status of 500 (Internal Server Error)', + }, + ]) + ) + }) + if (isNextDev) { + // page is borked at this point. Nothing interesting to assert on. + } else { + expect(await browser.elementByCss('body').text()).toEqual( + 'Internal Server Error' + ) + } + }) +}) diff --git a/test/e2e/app-dir/actions-allowed-origins/opaque-origin/app/action.js b/test/e2e/app-dir/actions-allowed-origins/opaque-origin/app/action.js new file mode 100644 index 00000000000000..956cb1edc08854 --- /dev/null +++ b/test/e2e/app-dir/actions-allowed-origins/opaque-origin/app/action.js @@ -0,0 +1,10 @@ +'use server' + +import { cookies } from 'next/headers' + +export async function log() { + console.log('action invoked') + const cookieStore = await cookies() + cookieStore.set('log-action-invoked', '1') + return 'hi' +} diff --git a/test/e2e/app-dir/actions-allowed-origins/opaque-origin/app/layout.js b/test/e2e/app-dir/actions-allowed-origins/opaque-origin/app/layout.js new file mode 100644 index 00000000000000..1b89d467cf3ec8 --- /dev/null +++ b/test/e2e/app-dir/actions-allowed-origins/opaque-origin/app/layout.js @@ -0,0 +1,21 @@ +import { Suspense } from 'react' + +export default function RootLayout({ children }) { + return ( + // Needs to be above html since we can't allow scripts in sandbox + Loading...}> + + + +
    + {/* These need to be MPAs so that the appropriate headers are applied */} +
  • + Sandboxed Page +
  • +
+ {children} + + +
+ ) +} diff --git a/test/e2e/app-dir/actions-allowed-origins/opaque-origin/app/sandboxed/page.js b/test/e2e/app-dir/actions-allowed-origins/opaque-origin/app/sandboxed/page.js new file mode 100644 index 00000000000000..cfc916eb2f13cc --- /dev/null +++ b/test/e2e/app-dir/actions-allowed-origins/opaque-origin/app/sandboxed/page.js @@ -0,0 +1,14 @@ +import { cookies } from 'next/headers' +import { log } from '../action' + +export default async function Page() { + const cookieStore = await cookies() + const cookie = cookieStore.get('log-action-invoked') + const hasLogged = cookie?.value === '1' + return ( +
+ + {hasLogged ? 'Action Invoked' : 'Action Not Invoked'} +
+ ) +} diff --git a/test/e2e/app-dir/actions-allowed-origins/opaque-origin/next.config.js b/test/e2e/app-dir/actions-allowed-origins/opaque-origin/next.config.js new file mode 100644 index 00000000000000..1fade4aa2f130b --- /dev/null +++ b/test/e2e/app-dir/actions-allowed-origins/opaque-origin/next.config.js @@ -0,0 +1,27 @@ +const allowOpaqueOrigin = process.env.NEXT_TEST_ALLOW_OPAQUE_ORIGIN === '1' + +/** @type {import('next').NextConfig} */ +module.exports = { + productionBrowserSourceMaps: true, + logging: { + fetches: {}, + }, + headers() { + return [ + { + source: '/sandboxed', + headers: [ + { + key: 'Content-Security-Policy', + value: 'sandbox allow-forms', + }, + ], + }, + ] + }, + experimental: { + serverActions: { + allowedOrigins: allowOpaqueOrigin ? ['null'] : [], + }, + }, +} From 3af4cc14ea400ebc01fdc267f3d3495bfe086a7e Mon Sep 17 00:00:00 2001 From: Josh Story Date: Mon, 16 Mar 2026 22:36:34 -0700 Subject: [PATCH 3/3] Fix startup warmup for empty-shell app route cache (#91470) Startup loading of APP_PAGE PPR entries seeded the in-memory cache from disk using a size estimate that only counted html and the route-level RSC payload. Empty-shell prerenders have 0-byte html and do not persist a monolithic .rsc file, with the reusable data instead stored in postponed metadata and per-segment .segment.rsc files. That made the computed size 0, caused LRUCache to reject the seeded entry, and forced the first request after next start to rerun prerender and rewrite the build artifacts. Account for postponed state and segment buffers when sizing APP_PAGE cache entries so empty-shell prerenders can warm the startup cache the same way contentful shells do. Add a production regression that asserts both routes are emitted as build artifacts and that next start does not rewrite them on the first request. The regression skips deployment mode because it verifies self-hosted behavior by inspecting local .next artifact mtimes, which is not a portable signal in deployment mode. When deployed through an adapter it is not expected that the local Next.js process is handling entries through the Response cache reading from the `.next` folder anyway --- .../memory-cache.external.ts | 37 ++++++-- .../empty-shell-route-cache/app/layout.tsx | 9 ++ .../empty-shell-route-cache/app/page.tsx | 17 ++++ .../app/with-suspense/page.tsx | 18 ++++ .../app/without-suspense/page.tsx | 13 +++ .../empty-shell-route-cache.test.ts | 92 +++++++++++++++++++ .../empty-shell-route-cache/next.config.js | 8 ++ 7 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 test/production/app-dir/empty-shell-route-cache/app/layout.tsx create mode 100644 test/production/app-dir/empty-shell-route-cache/app/page.tsx create mode 100644 test/production/app-dir/empty-shell-route-cache/app/with-suspense/page.tsx create mode 100644 test/production/app-dir/empty-shell-route-cache/app/without-suspense/page.tsx create mode 100644 test/production/app-dir/empty-shell-route-cache/empty-shell-route-cache.test.ts create mode 100644 test/production/app-dir/empty-shell-route-cache/next.config.js diff --git a/packages/next/src/server/lib/incremental-cache/memory-cache.external.ts b/packages/next/src/server/lib/incremental-cache/memory-cache.external.ts index 33ce99e47ed4b7..e44665640973a1 100644 --- a/packages/next/src/server/lib/incremental-cache/memory-cache.external.ts +++ b/packages/next/src/server/lib/incremental-cache/memory-cache.external.ts @@ -4,6 +4,24 @@ import { LRUCache } from '../lru-cache' let memoryCache: LRUCache | undefined +function getBufferSize(buffer: Buffer | undefined) { + return buffer?.length || 0 +} + +function getSegmentDataSize(segmentData: Map | undefined) { + if (!segmentData) { + return 0 + } + + let size = 0 + + for (const [segmentPath, buffer] of segmentData) { + size += segmentPath.length + getBufferSize(buffer) + } + + return size +} + export function getMemoryCache(maxMemoryCacheSize: number) { if (!memoryCache) { memoryCache = new LRUCache(maxMemoryCacheSize, function length({ value }) { @@ -19,14 +37,17 @@ export function getMemoryCache(maxMemoryCacheSize: number) { return value.body.length } // rough estimate of size of cache value - return ( - value.html.length + - (JSON.stringify( - value.kind === CachedRouteKind.APP_PAGE - ? value.rscData - : value.pageData - )?.length || 0) - ) + if (value.kind === CachedRouteKind.APP_PAGE) { + return Math.max( + 1, + value.html.length + + getBufferSize(value.rscData) + + (value.postponed?.length || 0) + + getSegmentDataSize(value.segmentData) + ) + } + + return value.html.length + (JSON.stringify(value.pageData)?.length || 0) }) } diff --git a/test/production/app-dir/empty-shell-route-cache/app/layout.tsx b/test/production/app-dir/empty-shell-route-cache/app/layout.tsx new file mode 100644 index 00000000000000..716a8db36f52c5 --- /dev/null +++ b/test/production/app-dir/empty-shell-route-cache/app/layout.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from 'react' + +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/production/app-dir/empty-shell-route-cache/app/page.tsx b/test/production/app-dir/empty-shell-route-cache/app/page.tsx new file mode 100644 index 00000000000000..4a395e2d31eadc --- /dev/null +++ b/test/production/app-dir/empty-shell-route-cache/app/page.tsx @@ -0,0 +1,17 @@ +import Link from 'next/link' + +export default function Page() { + return ( +
+

Empty Shell Route Cache

+
    +
  • + With Suspense +
  • +
  • + Without Suspense +
  • +
+
+ ) +} diff --git a/test/production/app-dir/empty-shell-route-cache/app/with-suspense/page.tsx b/test/production/app-dir/empty-shell-route-cache/app/with-suspense/page.tsx new file mode 100644 index 00000000000000..675cdb5586e6b5 --- /dev/null +++ b/test/production/app-dir/empty-shell-route-cache/app/with-suspense/page.tsx @@ -0,0 +1,18 @@ +import { Suspense } from 'react' +import { connection } from 'next/server' + +async function DynamicContent() { + await connection() + return

Dynamic content rendered at request time

+} + +export default function Page() { + return ( +
+

With Suspense

+ Loading...

}> + +
+
+ ) +} diff --git a/test/production/app-dir/empty-shell-route-cache/app/without-suspense/page.tsx b/test/production/app-dir/empty-shell-route-cache/app/without-suspense/page.tsx new file mode 100644 index 00000000000000..e2c79bc63f998d --- /dev/null +++ b/test/production/app-dir/empty-shell-route-cache/app/without-suspense/page.tsx @@ -0,0 +1,13 @@ +import { connection } from 'next/server' + +export const unstable_instant = false + +export default async function Page() { + await connection() + return ( +
+

Without Suspense

+

Dynamic content rendered at request time

+
+ ) +} diff --git a/test/production/app-dir/empty-shell-route-cache/empty-shell-route-cache.test.ts b/test/production/app-dir/empty-shell-route-cache/empty-shell-route-cache.test.ts new file mode 100644 index 00000000000000..13a0b25090a3f6 --- /dev/null +++ b/test/production/app-dir/empty-shell-route-cache/empty-shell-route-cache.test.ts @@ -0,0 +1,92 @@ +import fs from 'fs/promises' +import { nextTestSetup } from 'e2e-utils' +import path from 'path' + +describe('empty-shell-route-cache', () => { + const { next } = nextTestSetup({ + files: __dirname, + skipStart: true, + // This regression inspects on-disk .next artifacts, so it only applies to + // self-hosted build + start and is not portable to deployment mode. + skipDeployment: true, + }) + + beforeAll(async () => { + await next.build() + }) + + async function getRouteArtifactMtimes(route: string) { + const routeMeta = JSON.parse( + await next.readFile(`.next/server/app/${route}.meta`) + ) + const files = [ + `${route}.html`, + `${route}.meta`, + ...routeMeta.segmentPaths.map( + (segmentPath: string) => `${route}.segments${segmentPath}.segment.rsc` + ), + ] + + return Object.fromEntries( + await Promise.all( + files.map(async (file) => { + const stat = await fs.stat( + path.join(next.testDir, '.next/server/app', file) + ) + + return [file, stat.mtimeMs] + }) + ) + ) + } + + it('should emit both routes as partially static build artifacts', async () => { + const prerenderManifest = JSON.parse( + await next.readFile('.next/prerender-manifest.json') + ) + + expect(prerenderManifest.routes['/with-suspense'].renderingMode).toBe( + 'PARTIALLY_STATIC' + ) + expect(prerenderManifest.routes['/without-suspense'].renderingMode).toBe( + 'PARTIALLY_STATIC' + ) + + expect(await next.readFile('.next/server/app/with-suspense.html')).not.toBe( + '' + ) + expect(await next.readFile('.next/server/app/without-suspense.html')).toBe( + '' + ) + + const withoutSuspenseMeta = JSON.parse( + await next.readFile('.next/server/app/without-suspense.meta') + ) + expect(withoutSuspenseMeta.postponed).toBeTruthy() + }) + + describe('after next start', () => { + beforeAll(async () => { + await next.start({ skipBuild: true }) + }) + + afterAll(async () => { + await next.stop() + }) + + it.each([ + ['/with-suspense', 'with-suspense'], + ['/without-suspense', 'without-suspense'], + ])( + 'should not rewrite %s build artifacts on the first request', + async (pathname, route) => { + const before = await getRouteArtifactMtimes(route) + const response = await next.fetch(pathname) + const after = await getRouteArtifactMtimes(route) + + expect(response.status).toBe(200) + expect(after).toEqual(before) + } + ) + }) +}) diff --git a/test/production/app-dir/empty-shell-route-cache/next.config.js b/test/production/app-dir/empty-shell-route-cache/next.config.js new file mode 100644 index 00000000000000..e64bae22d65803 --- /dev/null +++ b/test/production/app-dir/empty-shell-route-cache/next.config.js @@ -0,0 +1,8 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + cacheComponents: true, +} + +module.exports = nextConfig