Merge pull request 'Fix: Nextcloud-Belegimport – IP-Pinning bei Dual-Stack-Hosts brach jede WebDAV-Verbindung' (#162) from fds2610-bot/Steuerkram:main into main
Reviewed-on: https://codeberg.org/fds2610/Steuerkram/pulls/162
This commit is contained in:
commit
8e47c8c3d3
5 changed files with 132 additions and 13 deletions
|
|
@ -2,6 +2,13 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-08-01 – Nextcloud-Belegimport wieder funktionsfähig (IP-Pinning bei Dual-Stack-Hosts)
|
||||||
|
|
||||||
|
- Seit dem Deployment des SSRF-Schutzes (#130) schlug der Nextcloud-Import bei jedem Lauf mit „PROPFIND fehlgeschlagen. URL/Zugangsdaten prüfen." fehl. Ursache war nicht die Konfiguration, sondern das IP-Pinning: `SsrfGuard::resolvePinned()` gab pro aufgelöster IP-Adresse einen eigenen `CURLOPT_RESOLVE`-Eintrag zurück. libcurl behandelt mehrere Einträge für dieselbe `HOST:PORT`-Kombination aber nicht als Alternativen – der zuletzt gesetzte verdrängt die vorherigen. Bei einem Host mit A- **und** AAAA-Record blieb dadurch nur die IPv6-Adresse übrig, und da der Container keine IPv6-Konnektivität hat, scheiterte jede Verbindung sofort mit „Could not connect to server".
|
||||||
|
- **`src/Services/SsrfGuard.php`:** `resolvePinned()` liefert jetzt genau einen Eintrag mit allen geprüften Adressen als Liste (`HOST:PORT:IP1,IP2,…`), IPv6 in eckigen Klammern. cURL probiert die Adressen damit wie bei eigener Auflösung nacheinander durch und fällt auf IPv4 zurück. Der DNS-Rebinding-Schutz bleibt unverändert – gepinnt wird weiterhin ausschließlich auf die zuvor validierten Adressen.
|
||||||
|
- **`src/Services/NextcloudImportService.php`:** Fehlgeschlagene Requests landeten bisher ohne jedes Detail im Ereignis-Log, was die Eingrenzung ohne Server-Zugriff unmöglich machte. PROPFIND- und Download-Fehler nennen jetzt den cURL-Fehlercode bzw. den HTTP-Status samt tatsächlich kontaktierter Gegenstelle (neue Methode `describeTransfer()`).
|
||||||
|
- **`tests/74_SsrfPinningDualStackTest.php`** (neu): Stellt sicher, dass `resolvePinned()` genau einen Eintrag liefert, beide Adressfamilien eines Dual-Stack-Hosts darin enthalten sind und IPv6-Adressen geklammert werden.
|
||||||
|
|
||||||
## 2026-07-31 – Rechnungsstatus folgt automatisch den erfassten Zahlungen
|
## 2026-07-31 – Rechnungsstatus folgt automatisch den erfassten Zahlungen
|
||||||
|
|
||||||
- Bisher blieb eine Ausgangsrechnung auch dann auf „Gesendet", wenn dem zugehörigen Beleg bereits Zahlungsbuchungen zugeordnet waren – der Status wurde ausschließlich manuell über „Rechnung als bezahlt markieren" gesetzt. Neu leitet sich der Status aus der Summe der zugeordneten Buchungen ab: **Teilzahlung** (weniger als der Rechnungsbetrag), **Bezahlt** (exakt der Rechnungsbetrag) und **Überzahlt** (mehr als der Rechnungsbetrag). Werden alle Buchungen storniert, fällt die Rechnung auf „Gesendet" bzw. „Finalisiert" zurück.
|
- Bisher blieb eine Ausgangsrechnung auch dann auf „Gesendet", wenn dem zugehörigen Beleg bereits Zahlungsbuchungen zugeordnet waren – der Status wurde ausschließlich manuell über „Rechnung als bezahlt markieren" gesetzt. Neu leitet sich der Status aus der Summe der zugeordneten Buchungen ab: **Teilzahlung** (weniger als der Rechnungsbetrag), **Bezahlt** (exakt der Rechnungsbetrag) und **Überzahlt** (mehr als der Rechnungsbetrag). Werden alle Buchungen storniert, fällt die Rechnung auf „Gesendet" bzw. „Finalisiert" zurück.
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@ class NextcloudImportService
|
||||||
private string $davBase;
|
private string $davBase;
|
||||||
private array $pendingEvents = [];
|
private array $pendingEvents = [];
|
||||||
private string|null $configError = null;
|
private string|null $configError = null;
|
||||||
|
/** Details des letzten fehlgeschlagenen Requests (HTTP-Code bzw. cURL-Fehler) für das Ereignis-Log */
|
||||||
|
private string $lastTransferError = '';
|
||||||
/** @var string[] CURLOPT_RESOLVE-Einträge für die beim Konstruieren geprüfte(n) IP(s) von baseUrl */
|
/** @var string[] CURLOPT_RESOLVE-Einträge für die beim Konstruieren geprüfte(n) IP(s) von baseUrl */
|
||||||
private array $pinnedResolve = [];
|
private array $pinnedResolve = [];
|
||||||
|
|
||||||
|
|
@ -105,7 +107,8 @@ class NextcloudImportService
|
||||||
|
|
||||||
$files = $this->listFiles();
|
$files = $this->listFiles();
|
||||||
if ($files === null) {
|
if ($files === null) {
|
||||||
$msg = 'FEHLER: PROPFIND fehlgeschlagen. URL/Zugangsdaten prüfen.';
|
$msg = 'FEHLER: PROPFIND fehlgeschlagen. URL/Zugangsdaten prüfen.'
|
||||||
|
. ($this->lastTransferError !== '' ? ' – ' . $this->lastTransferError : '');
|
||||||
$result['details'][] = $msg;
|
$result['details'][] = $msg;
|
||||||
$result['errors']++;
|
$result['errors']++;
|
||||||
$this->persistEvent($msg);
|
$this->persistEvent($msg);
|
||||||
|
|
@ -160,9 +163,11 @@ class NextcloudImportService
|
||||||
|
|
||||||
$response = curl_exec($ch);
|
$response = curl_exec($ch);
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$failed = $response === false || $httpCode < 200 || $httpCode >= 300;
|
||||||
|
$this->lastTransferError = $failed ? $this->describeTransfer($ch) : '';
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
|
||||||
if ($response === false || $httpCode < 200 || $httpCode >= 300) {
|
if ($failed) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,7 +260,8 @@ class NextcloudImportService
|
||||||
$tmpPath = sys_get_temp_dir() . '/nextcloud_import_' . uniqid() . '.' . $ext;
|
$tmpPath = sys_get_temp_dir() . '/nextcloud_import_' . uniqid() . '.' . $ext;
|
||||||
|
|
||||||
if (!$this->downloadFile($fileUrl, $tmpPath)) {
|
if (!$this->downloadFile($fileUrl, $tmpPath)) {
|
||||||
$msg = "FEHLER beim Download: {$filename}";
|
$msg = "FEHLER beim Download: {$filename}"
|
||||||
|
. ($this->lastTransferError !== '' ? ' – ' . $this->lastTransferError : '');
|
||||||
$result['details'][] = $msg;
|
$result['details'][] = $msg;
|
||||||
$this->persistEvent($msg);
|
$this->persistEvent($msg);
|
||||||
$result['errors']++;
|
$result['errors']++;
|
||||||
|
|
@ -353,10 +359,12 @@ class NextcloudImportService
|
||||||
|
|
||||||
$ok = curl_exec($ch);
|
$ok = curl_exec($ch);
|
||||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$failed = $ok === false || $httpCode < 200 || $httpCode >= 300;
|
||||||
|
$this->lastTransferError = $failed ? $this->describeTransfer($ch) : '';
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
fclose($fh);
|
fclose($fh);
|
||||||
|
|
||||||
if ($ok === false || $httpCode < 200 || $httpCode >= 300) {
|
if ($failed) {
|
||||||
@unlink($targetPath);
|
@unlink($targetPath);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -392,6 +400,26 @@ class NextcloudImportService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beschreibt einen fehlgeschlagenen Request so, dass er ohne Server-Zugriff
|
||||||
|
* eingegrenzt werden kann: Transportfehler mit cURL-Code, sonst HTTP-Status
|
||||||
|
* samt tatsächlich kontaktierter Gegenstelle.
|
||||||
|
*/
|
||||||
|
private function describeTransfer(\CurlHandle $ch): string
|
||||||
|
{
|
||||||
|
$errno = curl_errno($ch);
|
||||||
|
if ($errno !== 0) {
|
||||||
|
return sprintf('cURL-Fehler %d: %s', $errno, curl_error($ch));
|
||||||
|
}
|
||||||
|
|
||||||
|
$ip = (string)curl_getinfo($ch, CURLINFO_PRIMARY_IP);
|
||||||
|
return sprintf(
|
||||||
|
'HTTP %d von %s',
|
||||||
|
(int)curl_getinfo($ch, CURLINFO_HTTP_CODE),
|
||||||
|
$ip !== '' ? $ip : 'unbekannt'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt einen vorkonfigurierten cURL-Handle mit Basis-Auth und TLS-Einstellungen.
|
* Erstellt einen vorkonfigurierten cURL-Handle mit Basis-Auth und TLS-Einstellungen.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,22 @@ class SsrfGuard
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wie assertPublicHttpsUrl(), gibt aber zusätzlich die bereits geprüften
|
* Wie assertPublicHttpsUrl(), gibt aber zusätzlich die bereits geprüften
|
||||||
* IP-Adressen als CURLOPT_RESOLVE-Einträge ("HOST:PORT:IP") zurück. Damit
|
* IP-Adressen als CURLOPT_RESOLVE-Eintrag ("HOST:PORT:IP1,IP2,…") zurück.
|
||||||
* kann der aufrufende Code den Host für den tatsächlichen Request auf genau
|
* Damit kann der aufrufende Code den Host für den tatsächlichen Request auf
|
||||||
* diese IP(s) festnageln, statt ihn (z.B. durch cURL selbst) erneut über
|
* genau diese IP(s) festnageln, statt ihn (z.B. durch cURL selbst) erneut
|
||||||
* den System-Resolver aufzulösen – Schutz gegen DNS-Rebinding/TOCTOU
|
* über den System-Resolver aufzulösen – Schutz gegen DNS-Rebinding/TOCTOU
|
||||||
* zwischen Prüfung und Verbindungsaufbau (siehe Issue #130).
|
* zwischen Prüfung und Verbindungsaufbau (siehe Issue #130).
|
||||||
*
|
*
|
||||||
* @return string[] leer, wenn der Host bereits ein IP-Literal ist (dann
|
* Alle Adressen stehen bewusst in einem einzigen Eintrag: Mehrere Einträge
|
||||||
* ist kein Pinning nötig, cURL löst nichts auf)
|
* für dieselbe HOST:PORT-Kombination behandelt libcurl nicht als Alternativen,
|
||||||
|
* sondern der zuletzt gesetzte verdrängt die vorherigen. Bei einem Host mit
|
||||||
|
* A- und AAAA-Record blieb so nur eine Adressfamilie übrig – ohne IPv6 im
|
||||||
|
* Container scheiterte damit jeder Request. Als Liste in einem Eintrag
|
||||||
|
* probiert cURL die Adressen wie bei eigener Auflösung nacheinander durch.
|
||||||
|
*
|
||||||
|
* @return string[] genau ein Eintrag, oder leer, wenn der Host bereits ein
|
||||||
|
* IP-Literal ist (dann ist kein Pinning nötig, cURL löst
|
||||||
|
* nichts auf)
|
||||||
* @throws \RuntimeException wenn die URL kein zulässiges Ziel ist
|
* @throws \RuntimeException wenn die URL kein zulässiges Ziel ist
|
||||||
*/
|
*/
|
||||||
public static function resolvePinned(string $url): array
|
public static function resolvePinned(string $url): array
|
||||||
|
|
@ -65,10 +73,14 @@ class SsrfGuard
|
||||||
self::assertPublicIp($ip);
|
self::assertPublicIp($ip);
|
||||||
}
|
}
|
||||||
|
|
||||||
return array_map(
|
// IPv6-Adressen in eckigen Klammern, damit ihre Doppelpunkte in der
|
||||||
static fn(string $ip): string => sprintf('%s:%d:%s', $host, $port, $ip),
|
// kommaseparierten Liste eindeutig bleiben.
|
||||||
|
$addresses = array_map(
|
||||||
|
static fn(string $ip): string => str_contains($ip, ':') ? '[' . $ip . ']' : $ip,
|
||||||
$ips
|
$ips
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return [sprintf('%s:%d:%s', $host, $port, implode(',', $addresses))];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function assertPublicIp(string $ip): void
|
private static function assertPublicIp(string $ip): void
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
1-260731
|
1-260801
|
||||||
|
|
|
||||||
72
tests/74_SsrfPinningDualStackTest.php
Normal file
72
tests/74_SsrfPinningDualStackTest.php
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Steuerkram\Services\SsrfGuard;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test 74: IP-Pinning funktioniert auch bei Dual-Stack-Hosts (A + AAAA).
|
||||||
|
*
|
||||||
|
* Regression zum Nextcloud-Import: resolvePinned() lieferte pro IP einen eigenen
|
||||||
|
* CURLOPT_RESOLVE-Eintrag. libcurl behandelt mehrere Einträge für dieselbe
|
||||||
|
* HOST:PORT-Kombination aber nicht als Alternativen – der zuletzt gesetzte
|
||||||
|
* verdrängt die vorherigen. Fiel dabei die IPv6-Adresse an und hatte der
|
||||||
|
* Container keine IPv6-Konnektivität, scheiterte jeder Request mit
|
||||||
|
* "Could not connect to server". Alle Adressen müssen daher in genau einem
|
||||||
|
* Eintrag als Liste stehen.
|
||||||
|
*/
|
||||||
|
class SsrfPinningDualStackTest extends TestCase
|
||||||
|
{
|
||||||
|
protected string $testName = 'Test 74: IP-Pinning bei Dual-Stack-Hosts (A + AAAA)';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$entries = SsrfGuard::resolvePinned('https://example.com/dav');
|
||||||
|
$this->assertEquals(1, count($entries), 'resolvePinned() liefert genau einen CURLOPT_RESOLVE-Eintrag');
|
||||||
|
|
||||||
|
$entry = $entries[0];
|
||||||
|
$this->assert(
|
||||||
|
str_starts_with($entry, 'example.com:443:'),
|
||||||
|
'Eintrag beginnt mit "example.com:443:" (Eintrag: ' . $entry . ')'
|
||||||
|
);
|
||||||
|
|
||||||
|
$addresses = explode(',', substr($entry, strlen('example.com:443:')));
|
||||||
|
$this->assertNotEmpty($addresses, 'Der Eintrag enthält mindestens eine Adresse');
|
||||||
|
|
||||||
|
foreach ($addresses as $address) {
|
||||||
|
// IPv6 muss geklammert sein, sonst sind seine Doppelpunkte in der Liste nicht abgrenzbar
|
||||||
|
$isBracketed = str_starts_with($address, '[') && str_ends_with($address, ']');
|
||||||
|
$ip = $isBracketed ? substr($address, 1, -1) : $address;
|
||||||
|
|
||||||
|
$this->assert(
|
||||||
|
filter_var($ip, FILTER_VALIDATE_IP) !== false,
|
||||||
|
'Adresse "' . $address . '" ist eine gültige IP'
|
||||||
|
);
|
||||||
|
$this->assertEquals(
|
||||||
|
str_contains($ip, ':'),
|
||||||
|
$isBracketed,
|
||||||
|
'Genau IPv6-Adressen stehen in eckigen Klammern (Adresse: ' . $address . ')'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Dual-Stack-Host darf nicht dazu führen, dass eine Adressfamilie verlorengeht:
|
||||||
|
// beide aufgelösten Familien müssen im selben Eintrag landen.
|
||||||
|
$resolved = [];
|
||||||
|
foreach ((array)@dns_get_record('example.com', DNS_A + DNS_AAAA) as $record) {
|
||||||
|
if (!empty($record['ip'])) {
|
||||||
|
$resolved[] = $record['ip'];
|
||||||
|
}
|
||||||
|
if (!empty($record['ipv6'])) {
|
||||||
|
$resolved[] = $record['ipv6'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($resolved as $ip) {
|
||||||
|
$expected = str_contains($ip, ':') ? '[' . $ip . ']' : $ip;
|
||||||
|
$this->assert(
|
||||||
|
in_array($expected, $addresses, true),
|
||||||
|
'Aufgelöste Adresse ' . $ip . ' ist im Pinning-Eintrag enthalten'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue