PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.6.3
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.6.3
5.6.2 5.6.3 5.6.1 5.6.0 5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 3.0.51 3.0.60 3.0.61 3.0.62 All 38 releases
double-opt-in / logger / logger.php

logger.php in Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification 5.6.3, at logger/logger.php

376 lines 10.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace Forge12\Shared;
3
4 // Sicherstellen, dass Interface existiert
5 if (! interface_exists('Forge12\Shared\LoggerInterface')) {
6 require_once __DIR__ . '/logger.interface.php';
7 }
8
9 if (!defined('F12_DEBUG')) {
10 define('F12_DEBUG', false);
11 }
12
13 if (!defined('F12_DEBUG_LOG_LEVEL')) {
14 define('F12_DEBUG_LOG_LEVEL', 200);
15 }
16
17 if (!class_exists('Forge12\Shared\Logger')) {
18 class Logger implements LoggerInterface
19 {
20 private static $instance;
21 private $log_file;
22 private $log_level;
23 private $log_dir;
24
25 const DEBUG = 100;
26 const INFO = 200;
27 const NOTICE = 250;
28 const WARNING = 300;
29 const ERROR = 400;
30 const CRITICAL = 500;
31
32 private function __construct()
33 {
34 // Debug aus → Logger ist komplett deaktiviert
35 if (!defined('F12_DEBUG') || !F12_DEBUG) {
36 return;
37 }
38
39 // Sicher WordPress Upload-DIR holen
40 $upload_dir = wp_upload_dir();
41
42 if (empty($upload_dir['basedir']) || !is_string($upload_dir['basedir'])) {
43 $base = WP_CONTENT_DIR . '/uploads';
44 } else {
45 $base = $upload_dir['basedir'];
46 }
47
48 // Pfad normalisieren
49 $base = $this->normalizePath($base);
50
51 // Log-Ordner festlegen
52 $this->log_dir = $this->normalizePath($base . '/f12-logs');
53
54 // Falls Pfad relativ ist → absolut machen
55 $this->log_dir = $this->ensureAbsolutePath($this->log_dir);
56
57 // Sicherstellen, dass Verzeichnis existiert
58 if (!is_dir($this->log_dir)) {
59 wp_mkdir_p($this->log_dir);
60 }
61
62 // The directory is inside uploads/ and therefore web-reachable.
63 // A guessable name (plugins-<date>.log) was downloadable by
64 // anyone on a live site. Deny rules first, unguessable file
65 // names second — the latter also holds on servers that ignore
66 // .htaccess (nginx).
67 $secret = self::fileSecret();
68 self::protectDirectory($this->log_dir);
69 self::migrateGuessableFiles($this->log_dir, $secret);
70
71 // Logdatei definieren
72 $this->log_file = $this->normalizePath(
73 self::logFilePath($this->log_dir, date('Y-m-d'), $secret)
74 );
75
76 // Logdatei 100% absolut sicher machen
77 $this->log_file = $this->ensureAbsolutePath($this->log_file);
78
79 // Log-Level
80 $this->log_level = defined('F12_DEBUG_LOG_LEVEL')
81 ? F12_DEBUG_LOG_LEVEL
82 : self::INFO;
83 }
84
85 /**
86 * Daily log file: plugins-<date>-<secret>.log. The glob in
87 * cleanupOldLogs() (plugins-*.log*) matches both shapes.
88 */
89 public static function logFilePath(string $dir, string $date, string $secret): string
90 {
91 return rtrim($dir, '/\\') . '/plugins-' . $date . ($secret !== '' ? '-' . $secret : '') . '.log';
92 }
93
94 /**
95 * A per-site value nobody outside the server knows. Derived from
96 * the wp-config keys when they are set (no database access — the
97 * logger is constructed while plugins load); otherwise a random
98 * value kept in an option.
99 */
100 public static function fileSecret(): string
101 {
102 $material = '';
103 foreach (array('AUTH_KEY', 'AUTH_SALT', 'SECURE_AUTH_KEY') as $constant) {
104 if (defined($constant)) {
105 $value = (string) constant($constant);
106 if ($value !== '' && $value !== 'put your unique phrase here') {
107 $material .= $value;
108 }
109 }
110 }
111
112 if ($material === '' && function_exists('get_option')) {
113 $stored = get_option('f12_logs_file_secret');
114 if (!is_string($stored) || strlen($stored) < 32) {
115 $stored = bin2hex(random_bytes(16));
116 if (function_exists('update_option')) {
117 update_option('f12_logs_file_secret', $stored, false);
118 }
119 }
120 $material = $stored;
121 }
122
123 return $material === '' ? '' : substr(hash_hmac('sha256', 'f12-logs', $material), 0, 20);
124 }
125
126 /**
127 * Deny web access to the log directory: Apache 2.2 and 2.4
128 * (.htaccess), IIS (web.config), and no directory listing
129 * (index.php). Existing files are left alone, so an admin's own
130 * rules are never overwritten.
131 */
132 public static function protectDirectory(string $dir): void
133 {
134 if (!is_dir($dir) || !is_writable($dir)) {
135 return;
136 }
137
138 $files = array(
139 '.htaccess' => "# Written by the Forge12 logger. Log files must not be downloadable.\n"
140 . "<IfModule mod_authz_core.c>\n\tRequire all denied\n</IfModule>\n"
141 . "<IfModule !mod_authz_core.c>\n\tOrder allow,deny\n\tDeny from all\n</IfModule>\n",
142 'web.config' => "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<configuration>\n"
143 . "\t<system.webServer>\n\t\t<authorization>\n\t\t\t<deny users=\"*\" />\n\t\t</authorization>\n"
144 . "\t</system.webServer>\n</configuration>\n",
145 'index.php' => "<?php\n// Silence is golden.\n",
146 );
147
148 foreach ($files as $name => $content) {
149 $path = $dir . '/' . $name;
150 if (!file_exists($path)) {
151 @file_put_contents($path, $content);
152 }
153 }
154 }
155
156 /**
157 * Rename log files from the guessable scheme (plugins-<date>.log,
158 * plugins-<date>.log.<n>) so their known URLs stop working.
159 */
160 public static function migrateGuessableFiles(string $dir, string $secret): void
161 {
162 if ($secret === '' || !is_dir($dir)) {
163 return;
164 }
165
166 foreach ((array) glob($dir . '/plugins-*.log*') as $file) {
167 if (!is_string($file)) {
168 continue;
169 }
170 if (!preg_match('/^plugins-(\d{4}-\d{2}-\d{2})\.log(\.\d+)?$/', basename($file), $m)) {
171 continue;
172 }
173 $target = self::logFilePath($dir, $m[1], $secret) . ($m[2] ?? '');
174 if (file_exists($target)) {
175 // Both exist (concurrent request): keep the content.
176 @file_put_contents($target, (string) @file_get_contents($file), FILE_APPEND);
177 @unlink($file);
178 } else {
179 @rename($file, $target);
180 }
181 }
182 }
183
184 public static function getInstance()
185 {
186 if (!self::$instance) {
187 self::$instance = new self();
188 }
189 return self::$instance;
190 }
191
192 /**
193 * Pfade bereinigen (Windows + Linux)
194 */
195 private function normalizePath(string $path): string
196 {
197 // Backslashes → Slashes
198 $path = str_replace('\\', '/', $path);
199
200 // Doppelte Slashes entfernen, außer nach C:
201 $path = preg_replace('#(?<!:)/{2,}#', '/', $path);
202
203 return rtrim($path, '/');
204 }
205
206 /**
207 * ABSOLUTEN Pfad erzwingen
208 */
209 private function ensureAbsolutePath(string $path): string
210 {
211 $path = $this->normalizePath($path);
212
213 // Linux absolute Pfade: /var/www/...
214 if (substr($path, 0, 1) === '/') {
215 return $path;
216 }
217
218 // Windows absolute Pfade: C:/xampp/...
219 if (preg_match('#^[A-Za-z]:/#', $path)) {
220 return $path;
221 }
222
223 // → Pfad ist relativ → ABSPATH davor hängen
224 $absolute = $this->normalizePath(ABSPATH . '/' . $path);
225
226 return $absolute;
227 }
228
229
230 private function sanitizeContext(array $context): array
231 {
232 foreach ($context as $key => $value) {
233 if (in_array(strtolower($key), ['ip', 'user_ip'])) {
234 $context[$key] = $this->mask_ip($value);
235 }
236 if (in_array(strtolower($key), ['email', 'user_email'])) {
237 $context[$key] = $this->mask_email($value);
238 }
239 if (in_array(strtolower($key), ['password', 'pwd'])) {
240 $context[$key] = $this->mask_password($value);
241 }
242 }
243 return $context;
244 }
245
246 private function mask_password(string $password): string
247 {
248 return '********';
249 }
250
251 private function mask_email(string $email): string
252 {
253 if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
254 return 'invalid';
255 }
256 [$user, $domain] = explode('@', $email, 2);
257 $len = strlen($user);
258 if ($len <= 2) {
259 $maskedUser = substr($user, 0, 1) . '*';
260 } else {
261 $maskedUser = substr($user, 0, 1) . str_repeat('*', $len - 2) . substr($user, -1);
262 }
263 return $maskedUser . '@' . $domain;
264 }
265
266 private function mask_ip(string $ip): string
267 {
268 if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
269 $parts = explode('.', $ip);
270 $parts[3] = '0';
271 return implode('.', $parts);
272 }
273 if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
274 return substr($ip, 0, 20) . '::';
275 }
276 return 'unknown';
277 }
278
279 private function rotateLogs(int $maxSize = 52428800, int $maxFiles = 5): void
280 {
281 if (file_exists($this->log_file) && filesize($this->log_file) > $maxSize) {
282
283 for ($i = $maxFiles - 1; $i >= 1; $i--) {
284 $old = $this->log_file . '.' . $i;
285 $new = $this->log_file . '.' . ($i + 1);
286 if (file_exists($old)) {
287 rename($old, $new);
288 }
289 }
290
291 rename($this->log_file, $this->log_file . '.1');
292 }
293 }
294
295 private function cleanupOldLogs(int $days = 7): void
296 {
297 foreach (glob($this->log_dir . '/plugins-*.log*') as $file) {
298 if (filemtime($file) < strtotime("-{$days} days")) {
299 @unlink($file);
300 }
301 }
302 }
303
304 private function writeLog($level, $levelName, $message, array $context = [])
305 {
306 if (!defined('F12_DEBUG') || !F12_DEBUG) {
307 return;
308 }
309
310 if ($level < $this->log_level) {
311 return;
312 }
313
314 // Vor jedem Schreiben absolut sicherstellen
315 $this->log_file = $this->ensureAbsolutePath($this->log_file);
316
317 // Cleanup & Rotation
318 $this->cleanupOldLogs();
319 $this->rotateLogs();
320
321 $context = $this->sanitizeContext($context);
322
323 $time = date('Y-m-d H:i:s');
324 $plugin = $context['plugin'] ?? 'unknown';
325
326 $msg = sprintf(
327 "[%s] [%s] [%s] %s %s\n",
328 $time,
329 strtoupper($levelName),
330 $plugin,
331 $message,
332 $context ? json_encode($context) : ''
333 );
334
335 // Schreiben in absolut sicheren Pfad
336 error_log($msg, 3, $this->log_file);
337 }
338
339 public function debug($message, array $context = []): void
340 {
341 if ( ! F12_DEBUG ) { return; }
342 $this->writeLog(self::DEBUG, 'DEBUG', $message, $context);
343 }
344
345 public function info($message, array $context = []): void
346 {
347 if ( ! F12_DEBUG ) { return; }
348 $this->writeLog(self::INFO, 'INFO', $message, $context);
349 }
350
351 public function error($message, array $context = []): void
352 {
353 if ( ! F12_DEBUG ) { return; }
354 $this->writeLog(self::ERROR, 'ERROR', $message, $context);
355 }
356
357 public function warning($message, array $context = []): void
358 {
359 if ( ! F12_DEBUG ) { return; }
360 $this->writeLog(self::WARNING, 'WARNING', $message, $context);
361 }
362
363 public function notice($message, array $context = []): void
364 {
365 if ( ! F12_DEBUG ) { return; }
366 $this->writeLog(self::NOTICE, 'NOTICE', $message, $context);
367 }
368
369 public function critical($message, array $context = []): void
370 {
371 if ( ! F12_DEBUG ) { return; }
372 $this->writeLog(self::CRITICAL, 'CRITICAL', $message, $context);
373 }
374 }
375 }
376