Matomo-Tracking und Aufrufzaehlung absichern

This commit is contained in:
2026-08-27 12:20:56 +02:00
parent 528af07890
commit c86803bbd6
26 changed files with 1019 additions and 106 deletions
+31 -13
View File
@@ -55,31 +55,47 @@ $configuration = app_matomo_configuration();
$assert('Vollständige Konfiguration wird aktiviert', $configuration !== null);
$assert('Matomo-Basis-URL wird normalisiert', ($configuration['matomoUrl'] ?? '') === 'https://analytics.example.test/');
$assert('Einwilligung gilt 180 Tage', ($configuration['consentMaxAge'] ?? 0) === 15552000);
$assert('Neue Tracking-Art fragt bestehende Entscheidungen erneut ab', ($configuration['consentVersion'] ?? '') === '2');
$head = app_analytics_head_html();
$body = app_analytics_body_html();
$assert('Consent-Stylesheet wird lokal geladen', str_contains($head, 'assets/css/analytics-consent.css'));
$assert('Consent-Stylesheet wird lokal und filterarm geladen', str_contains($head, 'assets/css/privacy-settings.css'));
$assert('Zustimmen und Ablehnen stehen in derselben Ebene',
str_contains($body, 'data-analytics-consent="denied"')
&& str_contains($body, 'data-analytics-consent="granted"'));
$assert('Matomo selbst ist im HTML nicht vorab eingebunden',
!str_contains($body, 'src="https://analytics.example.test'));
!str_contains($body, 'https://analytics.example.test')
&& !str_contains($body, '"siteId"'));
$assert('Cookie-Einstellungen bleiben nach der Entscheidung erreichbar',
str_contains($body, 'analytics-consent-settings'));
$assert('Browser nutzt den lokalen First-Party-Endpunkt',
str_contains($body, '"trackingEndpoint":"nutzungsanalyse.php"')
&& str_contains($body, 'assets/js/privacy-settings.js'));
$javascript = file_get_contents(APP_ROOT . '/assets/js/analytics-consent.js') ?: '';
$assert('Tracker verlangt Tracking-Einwilligung', str_contains($javascript, "['requireConsent']"));
$assert('Matomo-Script wird erst dynamisch nach Zustimmung erzeugt',
str_contains($javascript, "document.createElement('script')"));
$javascript = file_get_contents(APP_ROOT . '/assets/js/privacy-settings.js') ?: '';
$assert('Tracker sendet nur bei erteilter Einwilligung',
str_contains($javascript, "readDecision() !== 'granted'"));
$assert('Browser sendet nur an den lokalen Endpunkt',
str_contains($javascript, 'window.fetch(configuration.trackingEndpoint')
&& !str_contains($javascript, 'matomo.js')
&& !str_contains($javascript, 'matomo.php'));
$assert('Query und Fragment werden aus Analyse-URLs entfernt',
str_contains($javascript, 'url.origin + url.pathname'));
$assert('Zusätzliche Browser- und Linkmessung bleibt deaktiviert',
str_contains($javascript, "['disableBrowserFeatureDetection']")
&& str_contains($javascript, "['disablePerformanceTracking']")
&& !str_contains($javascript, "['enableLinkTracking']"));
$assert('Widerruf stoppt Tracking und löscht Cookies',
str_contains($javascript, "['forgetConsentGiven']")
&& str_contains($javascript, "['deleteCookies']"));
$assert('Keine Kaffeeliste-Benutzerkennung wird gelesen',
!str_contains($javascript, 'userId')
&& !str_contains($javascript, 'sessionStorage')
&& !str_contains($javascript, 'localStorage'));
$assert('Widerruf stoppt Tracking und löscht Analyse-Cookies',
str_contains($javascript, 'removeAnalysisCookies()')
&& str_contains($javascript, "name !== visitorCookieName"));
$relay = file_get_contents(APP_ROOT . '/app/analytics-relay.php') ?: '';
$assert('Relay übernimmt keine Client-IP',
!str_contains($relay, 'REMOTE_ADDR')
&& !str_contains($relay, "'cip'"));
$assert('Relay bindet Ziel und Site-ID an die Serverkonfiguration',
str_contains($relay, "['idsite'] = \$configuration['siteId']")
&& str_contains($relay, "\$configuration['matomoUrl'] . 'matomo.php'"));
$publicTemplates = [
'abo-bestellen.php',
@@ -113,6 +129,8 @@ $privacy = app_legal_document_text('privacy_notice');
$assert('Aktuelle Datenschutzerklärung beschreibt Matomo und Widerruf',
str_contains($privacy, 'Matomo')
&& str_contains($privacy, 'Cookie-Einstellungen')
&& str_contains($privacy, 'kaffeeliste_analytics_visitor')
&& str_contains($privacy, 'nicht an Matomo weitergegeben')
&& str_contains($privacy, 'Verifizierungs-, Passwort-Reset- und Zugriffs-Tokens'));
foreach ($previousEnvironment as $name => $value) {
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../app/analytics-relay.php';
$failures = 0;
$assert = static function (string $label, bool $condition) use (&$failures): void {
if ($condition) {
echo "OK: {$label}\n";
return;
}
$failures++;
echo "FEHLER: {$label}\n";
};
$environmentNames = [
'APP_ENV',
'APP_HOST',
'APP_MARKETING_URL',
'MATOMO_URL',
'MATOMO_SITE_ID',
'MATOMO_CONSENT_COOKIE_DOMAIN',
'MATOMO_PRIVACY_CONFIRMED',
];
$previousEnvironment = [];
foreach ($environmentNames as $name) {
$previousEnvironment[$name] = getenv($name);
}
putenv('APP_ENV=dev');
putenv('APP_HOST=app.example.test');
putenv('APP_MARKETING_URL=http://example.test:8765');
putenv('MATOMO_URL=http://analytics.example.test/');
putenv('MATOMO_SITE_ID=7');
putenv('MATOMO_CONSENT_COOKIE_DOMAIN=.example.test');
putenv('MATOMO_PRIVACY_CONFIRMED=1');
$visitorId = '0123456789abcdef';
$cookies = [
APP_ANALYTICS_CONSENT_COOKIE => APP_ANALYTICS_CONSENT_VERSION . ':granted',
APP_ANALYTICS_VISITOR_COOKIE => $visitorId,
];
$server = [
'HTTP_HOST' => 'example.test:8765',
'HTTP_ORIGIN' => 'http://example.test:8765',
'HTTP_SEC_FETCH_SITE' => 'same-origin',
'SERVER_PORT' => '8765',
'HTTP_USER_AGENT' => 'Relay-Testbrowser/1.0',
'REMOTE_ADDR' => '203.0.113.42',
];
$payload = [
'visitorId' => $visitorId,
'pageUrl' => 'http://example.test:8765/passwort-zuruecksetzen.php?token=GEHEIM#formular',
'pageTitle' => 'Kaffeeliste Passwort zurücksetzen',
'referrerUrl' => 'http://app.example.test/login.php?source=GEHEIM',
'resolution' => '1440x900',
'language' => 'de-DE',
'h' => 12,
'm' => 34,
's' => 56,
];
$parameters = app_matomo_page_view_parameters($payload, $server, $cookies);
$assert('Seiten-URL verliert Query und Fragment',
($parameters['url'] ?? '') === 'http://example.test:8765/passwort-zuruecksetzen.php');
$assert('Interner Referrer verliert Query und Fragment',
($parameters['urlref'] ?? '') === 'http://app.example.test/login.php');
$assert('Zufällige Besucherkennung wird unverändert genutzt', ($parameters['_id'] ?? '') === $visitorId);
$assert('Browserdaten werden auf erlaubte Angaben begrenzt',
($parameters['res'] ?? '') === '1440x900'
&& ($parameters['lang'] ?? '') === 'de-DE'
&& ($parameters['ua'] ?? '') === 'Relay-Testbrowser/1.0');
$assert('Client-IP wird nicht an Matomo übergeben',
!array_key_exists('cip', $parameters)
&& !in_array('203.0.113.42', $parameters, true));
$capturedUrl = '';
$capturedParameters = [];
$forwardResult = app_matomo_forward_page_view(
$parameters,
static function (string $url, array $values) use (&$capturedUrl, &$capturedParameters): array {
$capturedUrl = $url;
$capturedParameters = $values;
return ['ok' => true, 'status' => 204, 'error' => null];
}
);
$assert('Relay verwendet den fest konfigurierten Matomo-Endpunkt',
$forwardResult['ok']
&& $capturedUrl === 'http://analytics.example.test/matomo.php'
&& ($capturedParameters['idsite'] ?? '') === '7');
$mustReject = static function (array $changedPayload, array $changedServer, array $changedCookies): bool {
try {
app_matomo_page_view_parameters($changedPayload, $changedServer, $changedCookies);
return false;
} catch (Throwable) {
return true;
}
};
$deniedCookies = $cookies;
$deniedCookies[APP_ANALYTICS_CONSENT_COOKIE] = APP_ANALYTICS_CONSENT_VERSION . ':denied';
$assert('Ohne Einwilligung wird der Aufruf verworfen', $mustReject($payload, $server, $deniedCookies));
$foreignServer = $server;
$foreignServer['HTTP_ORIGIN'] = 'https://evil.example';
$foreignServer['HTTP_SEC_FETCH_SITE'] = 'cross-site';
$assert('Fremder Ursprung wird verworfen', $mustReject($payload, $foreignServer, $cookies));
$foreignPage = $payload;
$foreignPage['pageUrl'] = 'https://evil.example/gestohlen?token=1';
$assert('Fremde Seiten-URL wird verworfen', $mustReject($foreignPage, $server, $cookies));
$wrongVisitor = $payload;
$wrongVisitor['visitorId'] = 'fedcba9876543210';
$assert('Nicht zum Cookie passende Besucherkennung wird verworfen', $mustReject($wrongVisitor, $server, $cookies));
$relaySource = file_get_contents(APP_ROOT . '/app/analytics-relay.php') ?: '';
$assert('Relay-Code liest die Client-IP nicht', !str_contains($relaySource, "['REMOTE_ADDR']"));
foreach ($previousEnvironment as $name => $value) {
$value === false ? putenv($name) : putenv($name . '=' . $value);
}
if ($failures > 0) {
echo "\n{$failures} Relay-Prüfung(en) fehlgeschlagen.\n";
exit(1);
}
echo "\nFirst-Party-Matomo-Relay: alle Prüfungen bestanden.\n";
+3 -3
View File
@@ -65,8 +65,8 @@ $assertSame(
);
$migrationFiles = glob(__DIR__ . '/../database/migrations/*.sql') ?: [];
if (count($migrationFiles) !== 30) {
fwrite(STDERR, 'FEHLER: Erwartet wurden 30 Migrationen, gefunden wurden ' . count($migrationFiles) . ".\n");
if (count($migrationFiles) !== 32) {
fwrite(STDERR, 'FEHLER: Erwartet wurden 32 Migrationen, gefunden wurden ' . count($migrationFiles) . ".\n");
exit(1);
}
@@ -93,4 +93,4 @@ if (preg_match('/(?<![A-Za-z0-9_])fk_[A-Za-z0-9_]+(?![A-Za-z0-9_])/i', $prefixed
exit(1);
}
echo "DB-Prefix-Prüfung bestanden: Produktion ist fail-closed; Laufzeit-SQL und alle 30 Migrationen werden getrennt umgeschrieben.\n";
echo "DB-Prefix-Prüfung bestanden: Produktion ist fail-closed; Laufzeit-SQL und alle 32 Migrationen werden getrennt umgeschrieben.\n";
+9 -4
View File
@@ -28,6 +28,11 @@ if ($baseUrl !== '' && (!str_starts_with($baseUrl, 'https://') || parse_url($bas
foreach (app_matomo_configuration_errors() as $matomoError) {
$errors[] = $matomoError;
}
foreach (['nutzungsanalyse.php', 'app/analytics-relay.php', 'assets/js/privacy-settings.js', 'assets/css/privacy-settings.css'] as $analyticsFile) {
if (!is_file(APP_ROOT . '/' . $analyticsFile)) {
$errors[] = "First-Party-Analyse-Datei {$analyticsFile} fehlt.";
}
}
try {
$dbTablePrefix = app_db_table_prefix();
@@ -78,7 +83,7 @@ if (preg_match('~@import\s+url\(["\']?https?://|url\(["\']?https?://~i', $mainCs
}
$privacyText = app_legal_document_text('privacy_notice');
foreach (['Matomo', 'kaffeeliste_analytics_consent', 'Cookie-Einstellungen'] as $privacyRequirement) {
foreach (['Matomo', 'kaffeeliste_analytics_consent', 'kaffeeliste_analytics_visitor', 'Cookie-Einstellungen', 'anonyme Aufrufzahlen'] as $privacyRequirement) {
if (!str_contains($privacyText, $privacyRequirement)) {
$errors[] = "Datenschutzerklärung nennt {$privacyRequirement} nicht.";
}
@@ -86,7 +91,7 @@ foreach (['Matomo', 'kaffeeliste_analytics_consent', 'Cookie-Einstellungen'] as
try {
$pdo = app_db_pdo();
foreach (['legal_acceptances', 'legal_requests', 'tenant_billing', 'rate_limit_attempts'] as $table) {
foreach (['legal_acceptances', 'legal_requests', 'tenant_billing', 'rate_limit_attempts', 'public_page_views'] as $table) {
$stmt = $pdo->prepare(
'SELECT 1 FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'
@@ -97,9 +102,9 @@ try {
}
}
$stmt = $pdo->prepare('SELECT COUNT(*) FROM schema_migrations WHERE version = ?');
$stmt->execute(['0030_contract_termination.sql']);
$stmt->execute(['0032_public_page_views.sql']);
if ((int)$stmt->fetchColumn() !== 1) {
$errors[] = 'Migration 0030_contract_termination.sql ist nicht angewendet.';
$errors[] = 'Migration 0032_public_page_views.sql ist nicht angewendet.';
}
$unknownTenants = (int)$pdo->query("SELECT COUNT(*) FROM tenants WHERE customer_type = 'unknown'")->fetchColumn();
if ($unknownTenants > 0) {
+106
View File
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
require __DIR__ . '/dev-db.php';
require_once __DIR__ . '/../app/public-page-views.php';
require_once __DIR__ . '/../app/legal.php';
$pdo = dev_pdo();
dev_apply_migrations($pdo);
$failures = 0;
$assert = static function (string $label, bool $condition) use (&$failures): void {
if ($condition) {
echo "OK: {$label}\n";
return;
}
$failures++;
echo "FEHLER: {$label}\n";
};
$previousMethod = $_SERVER['REQUEST_METHOD'] ?? null;
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('SELECT view_count FROM public_page_views WHERE view_date = CURRENT_DATE AND page_key = ?');
$stmt->execute(['landing']);
$before = (int)($stmt->fetchColumn() ?: 0);
$_SERVER['REQUEST_METHOD'] = 'GET';
app_record_public_page_view('landing', $pdo);
$stmt->execute(['landing']);
$afterGet = (int)($stmt->fetchColumn() ?: 0);
$assert('Ein GET erhöht den aggregierten Tageszähler genau einmal', $afterGet === $before + 1);
$_SERVER['REQUEST_METHOD'] = 'POST';
app_record_public_page_view('landing', $pdo);
$stmt->execute(['landing']);
$afterPost = (int)($stmt->fetchColumn() ?: 0);
$assert('Ein POST wird nicht als Seitenaufruf gezählt', $afterPost === $afterGet);
$_SERVER['REQUEST_METHOD'] = 'GET';
try {
app_record_public_page_view('token-aus-einer-url', $pdo);
$assert('Unbekannte Seitenschlüssel werden abgewiesen', false);
} catch (InvalidArgumentException) {
$assert('Unbekannte Seitenschlüssel werden abgewiesen', true);
}
$summary = app_public_page_view_summary($pdo);
$landingRows = array_values(array_filter(
$summary['pages'],
static fn(array $row): bool => $row['page_key'] === 'landing'
));
$assert('Back-Office-Zusammenfassung enthält den aktuellen Zähler',
$summary['totals']['today'] >= $afterGet
&& count($landingRows) === 1
&& $landingRows[0]['today'] === $afterGet);
$columns = $pdo->query('SHOW COLUMNS FROM public_page_views')->fetchAll(PDO::FETCH_COLUMN);
sort($columns);
$assert('Statistiktabelle enthält nur Datum, Seitenschlüssel und Anzahl',
$columns === ['page_key', 'view_count', 'view_date']);
$counterSource = file_get_contents(__DIR__ . '/../app/public-page-views.php') ?: '';
$assert('Zähler liest keine IP-, User-Agent- oder URL-Daten',
!str_contains($counterSource, 'REMOTE_ADDR')
&& !str_contains($counterSource, 'HTTP_USER_AGENT')
&& !str_contains($counterSource, 'REQUEST_URI'));
foreach ([
'landing.php' => 'landing',
'preise.php' => 'prices',
'register.php' => 'register',
'login.php' => 'login',
'datenschutz.php' => 'privacy',
'impressum.php' => 'imprint',
'agb.php' => 'terms',
'avv.php' => 'dpa',
'widerruf.php' => 'withdrawal_information',
] as $template => $pageKey) {
$templateSource = file_get_contents(APP_ROOT . '/' . $template) ?: '';
$assert("{$template} zählt den festen Seitenschlüssel {$pageKey}",
str_contains($templateSource, "app_record_public_page_view('{$pageKey}')"));
}
$privacy = app_legal_document_text('privacy_notice');
$assert('Datenschutzerklärung grenzt den anonymen Zähler von Matomo ab',
str_contains($privacy, 'anonyme Aufrufzahlen')
&& str_contains($privacy, 'weder die Ermittlung eindeutiger Besucher noch die Bildung von Nutzungsprofilen'));
} finally {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
if ($previousMethod === null) {
unset($_SERVER['REQUEST_METHOD']);
} else {
$_SERVER['REQUEST_METHOD'] = $previousMethod;
}
}
if ($failures > 0) {
echo "\n{$failures} Seitenzähler-Prüfung(en) fehlgeschlagen.\n";
exit(1);
}
echo "\nAnonymer Seitenzähler: alle Prüfungen bestanden.\n";