Release: CalDAV sync bundle hardening and LMW sync

This commit is contained in:
Jürgen Mummert
2026-03-27 22:16:48 +01:00
commit c6f63a56a9
36 changed files with 2993 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\CalDav\Transport;
use RuntimeException;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final readonly class CalDavTransport implements CalDavTransportInterface
{
public function __construct(private HttpClientInterface $httpClient)
{
}
public function propfind(string $url, string $username, string $password, string $body, int $depth = 1, array $headers = []): TransportResponse
{
$headers = array_merge(['Depth' => (string) $depth], $headers);
return $this->request('PROPFIND', $url, $username, $password, $body, $headers);
}
public function report(string $url, string $username, string $password, string $body, int $depth = 1, array $headers = []): TransportResponse
{
$headers = array_merge(['Depth' => (string) $depth], $headers);
return $this->request('REPORT', $url, $username, $password, $body, $headers);
}
public function get(string $url, string $username, string $password, array $headers = []): TransportResponse
{
return $this->request('GET', $url, $username, $password, null, $headers);
}
public function put(string $url, string $username, string $password, string $body, array $headers = []): TransportResponse
{
return $this->request('PUT', $url, $username, $password, $body, $headers);
}
public function delete(string $url, string $username, string $password, array $headers = []): TransportResponse
{
return $this->request('DELETE', $url, $username, $password, null, $headers);
}
/**
* @param array<string, string> $headers
*/
private function request(string $method, string $url, string $username, string $password, ?string $body, array $headers): TransportResponse
{
$requestHeaders = [
'Accept' => 'application/xml,text/xml,text/calendar,*/*',
...$headers,
];
try {
$response = $this->httpClient->request($method, $url, [
'auth_basic' => [$username, $password],
'headers' => $requestHeaders,
'body' => $body,
]);
$statusCode = $response->getStatusCode();
$rawHeaders = $response->getHeaders(false);
$normalizedHeaders = [];
foreach ($rawHeaders as $name => $values) {
$normalizedHeaders[strtolower($name)] = implode(', ', $values);
}
if (401 === $statusCode && isset($normalizedHeaders['www-authenticate']) && str_contains(strtolower($normalizedHeaders['www-authenticate']), 'digest')) {
$digestAuthorization = $this->buildDigestAuthorizationHeader(
$normalizedHeaders['www-authenticate'],
$method,
$url,
$username,
$password,
);
if (null !== $digestAuthorization) {
$retryResponse = $this->httpClient->request($method, $url, [
'headers' => [
...$requestHeaders,
'Authorization' => $digestAuthorization,
],
'body' => $body,
]);
$statusCode = $retryResponse->getStatusCode();
$rawHeaders = $retryResponse->getHeaders(false);
$normalizedHeaders = [];
foreach ($rawHeaders as $name => $values) {
$normalizedHeaders[strtolower($name)] = implode(', ', $values);
}
$content = $retryResponse->getContent(false);
return new TransportResponse($statusCode, $content, $normalizedHeaders);
}
}
$content = $response->getContent(false);
return new TransportResponse($statusCode, $content, $normalizedHeaders);
} catch (TransportExceptionInterface $e) {
throw new RuntimeException(sprintf('CalDAV transport error for %s %s: %s', $method, $url, $e->getMessage()), 0, $e);
}
}
private function buildDigestAuthorizationHeader(string $wwwAuthenticateHeader, string $method, string $url, string $username, string $password): ?string
{
$challenge = trim($wwwAuthenticateHeader);
$digestPos = stripos($challenge, 'Digest ');
if (false === $digestPos) {
return null;
}
$challengeBody = trim(substr($challenge, $digestPos + 7));
preg_match_all('/([a-zA-Z0-9_-]+)=("[^"]*"|[^,]+)/', $challengeBody, $matches, PREG_SET_ORDER);
$params = [];
foreach ($matches as $match) {
$params[strtolower($match[1])] = trim($match[2], " \t\n\r\0\x0B\"");
}
$realm = $params['realm'] ?? null;
$nonce = $params['nonce'] ?? null;
if (null === $realm || null === $nonce) {
return null;
}
$qop = null;
if (isset($params['qop'])) {
$qopCandidates = array_map('trim', explode(',', $params['qop']));
$qop = in_array('auth', $qopCandidates, true) ? 'auth' : ('' !== ($qopCandidates[0] ?? '') ? $qopCandidates[0] : null);
}
$algorithm = strtoupper((string) ($params['algorithm'] ?? 'MD5'));
$uri = (string) (parse_url($url, PHP_URL_PATH) ?? '/');
$query = parse_url($url, PHP_URL_QUERY);
if (is_string($query) && '' !== $query) {
$uri .= '?'.$query;
}
$cnonce = bin2hex(random_bytes(8));
$nc = '00000001';
$ha1 = md5($username.':'.$realm.':'.$password);
if ('MD5-SESS' === $algorithm) {
$ha1 = md5($ha1.':'.$nonce.':'.$cnonce);
}
$ha2 = md5($method.':'.$uri);
if (null !== $qop) {
$response = md5($ha1.':'.$nonce.':'.$nc.':'.$cnonce.':'.$qop.':'.$ha2);
} else {
$response = md5($ha1.':'.$nonce.':'.$ha2);
}
$headerParts = [
'Digest username="'.$this->escapeDigestValue($username).'"',
'realm="'.$this->escapeDigestValue($realm).'"',
'nonce="'.$this->escapeDigestValue($nonce).'"',
'uri="'.$this->escapeDigestValue($uri).'"',
'response="'.$response.'"',
'algorithm='.$algorithm,
];
if (isset($params['opaque'])) {
$headerParts[] = 'opaque="'.$this->escapeDigestValue((string) $params['opaque']).'"';
}
if (null !== $qop) {
$headerParts[] = 'qop='.$qop;
$headerParts[] = 'nc='.$nc;
$headerParts[] = 'cnonce="'.$cnonce.'"';
}
return implode(', ', $headerParts);
}
private function escapeDigestValue(string $value): string
{
return addcslashes($value, "\\\"");
}
}