Matomo-Tracking und Aufrufzaehlung absichern
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user