Release: CalDAV sync bundle hardening and LMW sync
This commit is contained in:
@@ -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, "\\\"");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user