From 566f2f497ad163d595a186e58b54138c263ba32d Mon Sep 17 00:00:00 2001 From: Matteo Nastasi Date: Sun, 13 Sep 2026 12:32:24 +0200 Subject: [PATCH] headers_render: three duplicated headers in every response Http header names are case insensitive, php array keys are not. The transports set "Content-type" with a lowercase t (transports.phh:568 and :618, index.php:1025 and :1199) while headers_render() checked for "Content-Type": the check never saw it and added the default anyway. Same dynamic for Expires and Cache-Control, which force_no_cache() sets and headers_render added again without checking at all. Every response therefore went out with: Content-Type: text/html + Content-type: text/html; charset="utf-8" Expires: -1 + Expires: Mon, 26 Jul 1997 05:00:00 GMT Cache-Control: no-cache + Cache-Control: no-cache, must-revalidate With apache this went unnoticed: the daemon wrote the bytes straight to the client and the browser applied the last value. Behind a reverse proxy the response is parsed instead, the first value wins and the second is dropped: the charset was lost, and in transports.phh:568 an application/xml was replaced by text/html. Fixed at the root rather than in the five calling places: headers_render builds a map of the keys normalised to lowercase and uses it for every check, so the defect does not come back if somebody writes "Content-type" again tomorrow. Found by putting nginx in front of the daemon: it reported "upstream sent duplicate header line" 123 times. After the fix: zero. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014M1jiEq9cHdE5SE5j6vFuE --- web/Obj/sac-a-push.phh | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/web/Obj/sac-a-push.phh b/web/Obj/sac-a-push.phh index 1f745e7..25f5bfc 100644 --- a/web/Obj/sac-a-push.phh +++ b/web/Obj/sac-a-push.phh @@ -237,6 +237,24 @@ function headers_render($header, $len) { $cookies = ""; + /* Http header names are case insensitive, php array keys are not. The + transports set "Content-type" (lowercase t) while the code below + checked for "Content-Type": the check never saw it and the default was + added anyway, so two Content-Type headers went out in the same + response. Same story for Expires and Cache-Control, already set by + force_no_cache() and then added again unconditionally. + With apache this went unnoticed, because the daemon wrote the bytes + straight to the client and the browser applied the last value. A + reverse proxy instead parses the response, keeps the first one and + drops the second: the charset was lost, and an application/xml was + replaced by text/html. + Here a map of the normalised keys is built and used for every + check. */ + $hset = array(); + foreach ($header as $hk => $hv) { + $hset[strtolower($hk)] = TRUE; + } + if (isset($header['cookies'])) { $cookies = $header['cookies']->render(); unset($header['cookies']); @@ -258,11 +276,11 @@ function headers_render($header, $len) else { $s = "HTTP/1.1 200 OK\r\n"; - if (!isset($header['Date'])) + if (!isset($hset['date'])) $s .= sprintf("Date: %s\r\n", date(DATE_RFC822)); - if (!isset($header['Connection'])) + if (!isset($hset['connection'])) $s .= "Connection: close\r\n"; - if (!isset($header['Content-Type'])) + if (!isset($hset['content-type'])) $s .= "Content-Type: text/html\r\n"; foreach($header as $key => $value) { $s .= sprintf("%s: %s\r\n", $key, $value); @@ -271,9 +289,13 @@ function headers_render($header, $len) $s .= sprintf("Content-Length: %d\r\n", $len); } else { - $s .= "Cache-Control: no-cache, must-revalidate\r\n"; - $s .= "Expires: Mon, 26 Jul 1997 05:00:00 GMT\r\n"; - if (!isset($header['Content-Encoding'])) { + if (!isset($hset['cache-control'])) { + $s .= "Cache-Control: no-cache, must-revalidate\r\n"; + } + if (!isset($hset['expires'])) { + $s .= "Expires: Mon, 26 Jul 1997 05:00:00 GMT\r\n"; + } + if (!isset($hset['content-encoding'])) { $s .= "Content-Encoding: chunked\r\n"; } $s .= "Transfer-Encoding: chunked\r\n"; -- 2.47.3