From a761a838d586ef124c716b095caecb905b6c08ce Mon Sep 17 00:00:00 2001 From: Fathi Ben Nasr Date: Wed, 12 Aug 2026 13:10:40 +0000 Subject: [PATCH 1/2] HTTP: send associative extra headers correctly Both request drivers assume $extra_headers is a flat list of "Name: value" strings, but callers pass an associative array. The most visible case is the CardDAV provider, which sends array('Content-Type' => 'text/vcard; charset=utf-8') from RainLoop\Providers\AddressBook\CardDAV::davClientRequest(). curl: CURLOPT_HTTPHEADER receives the map as-is, so curl emits the bare value "text/vcard; charset=utf-8" as a header line and discards it as malformed. socket: array_merge() then implode("\r\n", ...) keeps only the values, producing the same malformed line. In both drivers the header is therefore lost, and because a body is present the request falls back to Content-Type: application/x-www-form-urlencoded. A CardDAV server is entitled to reject that: Cyrus IMAP answers PUT of a vCard with 403 so every contact upload fails while the download half of the sync works, which makes the sync look silently one-way. Normalise string keys to "Name: value" in both drivers and leave integer keys untouched, so existing callers that already pass a flat list are unaffected. --- .../0.0.0/app/libraries/snappymail/http/request/curl.php | 9 ++++++++- .../app/libraries/snappymail/http/request/socket.php | 7 ++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/snappymail/v/0.0.0/app/libraries/snappymail/http/request/curl.php b/snappymail/v/0.0.0/app/libraries/snappymail/http/request/curl.php index f25fef1c0..31559d112 100644 --- a/snappymail/v/0.0.0/app/libraries/snappymail/http/request/curl.php +++ b/snappymail/v/0.0.0/app/libraries/snappymail/http/request/curl.php @@ -47,7 +47,14 @@ class CURL extends \SnappyMail\HTTP\Request \curl_setopt($c, CURLOPT_CAINFO, $this->ca_bundle); } if ($extra_headers) { - \curl_setopt($c, CURLOPT_HTTPHEADER, $extra_headers); + // CURLOPT_HTTPHEADER expects a flat list of "Name: value" strings. + // Callers may pass an associative array, in which case curl sends + // the bare values as malformed header lines and drops them. + $aHeaderLines = array(); + foreach ($extra_headers as $mKey => $sValue) { + $aHeaderLines[] = \is_int($mKey) ? $sValue : "{$mKey}: {$sValue}"; + } + \curl_setopt($c, CURLOPT_HTTPHEADER, $aHeaderLines); } if ($this->auth['user'] && $this->auth['type']) { if ($this->auth['type'] & self::AUTH_BEARER ) { diff --git a/snappymail/v/0.0.0/app/libraries/snappymail/http/request/socket.php b/snappymail/v/0.0.0/app/libraries/snappymail/http/request/socket.php index 8b6ffc975..522bcd65a 100644 --- a/snappymail/v/0.0.0/app/libraries/snappymail/http/request/socket.php +++ b/snappymail/v/0.0.0/app/libraries/snappymail/http/request/socket.php @@ -43,7 +43,12 @@ class Socket extends \SnappyMail\HTTP\Request $extra_headers['Authorization'] = static::$Authorization[$host]; } if ($extra_headers) { - $headers = \array_merge($headers, $extra_headers); + // $headers is a flat list of "Name: value" strings, so an + // associative $extra_headers would lose its keys in the implode() + // below and emit bare values as malformed header lines. + foreach ($extra_headers as $mKey => $sValue) { + $headers[] = \is_int($mKey) ? $sValue : "{$mKey}: {$sValue}"; + } } $headers = \implode("\r\n", $headers); if (!\is_null($body)) { From 3a173a6dffdbf70a860c7a2a7fa47c4c7b6c4267 Mon Sep 17 00:00:00 2001 From: Fathi Ben Nasr Date: Wed, 7 Oct 2026 14:59:23 +0000 Subject: [PATCH 2/2] HTTP: keep a complete header line filed under its own name The socket transport's 401 retry stores "Authorization: Basic ..." (and Digest, Bearer) under the 'Authorization' key. The first version of this change prefixed every named value, so that retry would have sent "Authorization: Authorization: Basic ...". Both transports now share Request::headerLines(), which leaves such a value as it is. --- .../app/libraries/snappymail/http/request.php | 25 +++++++++++++++++++ .../snappymail/http/request/curl.php | 9 +------ .../snappymail/http/request/socket.php | 7 +----- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/snappymail/v/0.0.0/app/libraries/snappymail/http/request.php b/snappymail/v/0.0.0/app/libraries/snappymail/http/request.php index 8c1c3604a..19c8c2123 100644 --- a/snappymail/v/0.0.0/app/libraries/snappymail/http/request.php +++ b/snappymail/v/0.0.0/app/libraries/snappymail/http/request.php @@ -166,4 +166,29 @@ abstract class Request { return self::$scheme_ports[$scheme] ?? 0; } + + /** + * Extra headers as the flat list of "Name: value" lines both transports send. + * + * Callers pass either form: a list of ready lines, or an associative array + * such as ['Accept-Encoding' => 'gzip']. The + * associative form used to reach CURLOPT_HTTPHEADER as bare values, so the + * header was sent as a malformed line and dropped. + * + * Some callers file a complete line under its own name + * (['Authorization' => 'Authorization: Basic ...'] in the socket 401 + * retry). Such a value is kept as it is, otherwise it would go out + * as "Authorization: Authorization: Basic ...". + */ + public static function headerLines(array $headers) : array + { + $lines = array(); + foreach ($headers as $name => $value) { + $value = (string) $value; + $lines[] = (\is_int($name) || 0 === \stripos($value, $name . ':')) + ? $value + : "{$name}: {$value}"; + } + return $lines; + } } diff --git a/snappymail/v/0.0.0/app/libraries/snappymail/http/request/curl.php b/snappymail/v/0.0.0/app/libraries/snappymail/http/request/curl.php index 31559d112..1c838b989 100644 --- a/snappymail/v/0.0.0/app/libraries/snappymail/http/request/curl.php +++ b/snappymail/v/0.0.0/app/libraries/snappymail/http/request/curl.php @@ -47,14 +47,7 @@ class CURL extends \SnappyMail\HTTP\Request \curl_setopt($c, CURLOPT_CAINFO, $this->ca_bundle); } if ($extra_headers) { - // CURLOPT_HTTPHEADER expects a flat list of "Name: value" strings. - // Callers may pass an associative array, in which case curl sends - // the bare values as malformed header lines and drops them. - $aHeaderLines = array(); - foreach ($extra_headers as $mKey => $sValue) { - $aHeaderLines[] = \is_int($mKey) ? $sValue : "{$mKey}: {$sValue}"; - } - \curl_setopt($c, CURLOPT_HTTPHEADER, $aHeaderLines); + \curl_setopt($c, CURLOPT_HTTPHEADER, static::headerLines($extra_headers)); } if ($this->auth['user'] && $this->auth['type']) { if ($this->auth['type'] & self::AUTH_BEARER ) { diff --git a/snappymail/v/0.0.0/app/libraries/snappymail/http/request/socket.php b/snappymail/v/0.0.0/app/libraries/snappymail/http/request/socket.php index 522bcd65a..f70167427 100644 --- a/snappymail/v/0.0.0/app/libraries/snappymail/http/request/socket.php +++ b/snappymail/v/0.0.0/app/libraries/snappymail/http/request/socket.php @@ -43,12 +43,7 @@ class Socket extends \SnappyMail\HTTP\Request $extra_headers['Authorization'] = static::$Authorization[$host]; } if ($extra_headers) { - // $headers is a flat list of "Name: value" strings, so an - // associative $extra_headers would lose its keys in the implode() - // below and emit bare values as malformed header lines. - foreach ($extra_headers as $mKey => $sValue) { - $headers[] = \is_int($mKey) ? $sValue : "{$mKey}: {$sValue}"; - } + $headers = \array_merge($headers, static::headerLines($extra_headers)); } $headers = \implode("\r\n", $headers); if (!\is_null($body)) {