PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.6.2
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.6.2
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
← All changes | logger/logger.php +334 -135 5.3.1 → 5.6.2 View file →
@@ -13,164 +13,363 @@
13 13 if (!defined('F12_DEBUG_LOG_LEVEL')) {
14 14 define('F12_DEBUG_LOG_LEVEL', 200);
15 15 }
16 16
17 -if ( ! class_exists( 'Forge12\Shared\Logger' ) ) {
18 - class Logger implements LoggerInterface {
19 - private static $instance;
20 - private $log_file;
21 - private $log_level;
22 - private $log_dir;
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;
23 24
24 - const DEBUG = 100;
25 - const INFO = 200;
26 - const NOTICE = 250;
27 - const WARNING = 300;
28 - const ERROR = 400;
29 - const CRITICAL = 500;
25 + const DEBUG = 100;
26 + const INFO = 200;
27 + const NOTICE = 250;
28 + const WARNING = 300;
29 + const ERROR = 400;
30 + const CRITICAL = 500;
30 31
31 - private function __construct() {
32 - if ( ! defined( 'F12_DEBUG' ) || ! F12_DEBUG ) {
33 - // Wenn F12_DEBUG nicht definiert ist oder false, beenden wir.
34 - return;
35 - }
32 + private function __construct()
33 + {
34 + // Debug aus → Logger ist komplett deaktiviert
35 + if (!defined('F12_DEBUG') || !F12_DEBUG) {
36 + return;
37 + }
36 38
37 - $upload_dir = wp_upload_dir();
38 - $this->log_dir = $upload_dir['basedir'] . '/f12-logs';
39 + // Sicher WordPress Upload-DIR holen
40 + $upload_dir = wp_upload_dir();
39 41
40 - if ( ! file_exists( $this->log_dir ) ) {
41 - wp_mkdir_p( $this->log_dir );
42 - }
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 + }
43 47
44 - // Tagesbasiert loggen
45 - $this->log_file = $this->log_dir . '/plugins-' . date('Y-m-d') . '.log';
48 + // Pfad normalisieren
49 + $base = $this->normalizePath($base);
46 50
47 - // Setze Level je nach WP_DEBUG
48 - $this->log_level = F12_DEBUG_LOG_LEVEL;
49 - }
51 + // Log-Ordner festlegen
52 + $this->log_dir = $this->normalizePath($base . '/f12-logs');
50 53
51 - public static function getInstance() {
52 - if ( ! self::$instance ) {
53 - self::$instance = new self();
54 - }
55 - return self::$instance;
56 - }
54 + // Falls Pfad relativ ist → absolut machen
55 + $this->log_dir = $this->ensureAbsolutePath($this->log_dir);
57 56
58 - private function sanitizeContext(array $context): array {
59 - foreach ($context as $key => $value) {
60 - if (in_array(strtolower($key), ['ip', 'user_ip'])) {
61 - $context[$key] = $this->mask_ip($value);
62 - }
63 - if (in_array(strtolower($key), ['email', 'user_email'])) {
64 - $context[$key] = $this->mask_email($value);
65 - }
66 - if (in_array(strtolower($key), ['password', 'pwd'])) {
67 - $context[$key] = $this->mask_password($value);
68 - }
69 - }
70 - return $context;
71 - }
57 + // Sicherstellen, dass Verzeichnis existiert
58 + if (!is_dir($this->log_dir)) {
59 + wp_mkdir_p($this->log_dir);
60 + }
72 61
73 - private function mask_password(string $password): string {
74 - return '********';
75 - }
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);
76 70
77 - private function mask_email(string $email): string {
78 - if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
79 - return 'invalid';
80 - }
81 - [$user, $domain] = explode('@', $email, 2);
82 - $len = strlen($user);
83 - if ($len <= 2) {
84 - $maskedUser = substr($user, 0, 1) . '*';
85 - } else {
86 - $maskedUser = substr($user, 0, 1) . str_repeat('*', $len - 2) . substr($user, -1);
87 - }
88 - return $maskedUser . '@' . $domain;
89 - }
71 + // Logdatei definieren
72 + $this->log_file = $this->normalizePath(
73 + self::logFilePath($this->log_dir, date('Y-m-d'), $secret)
74 + );
90 75
91 - private function mask_ip(string $ip): string {
92 - if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
93 - $parts = explode('.', $ip);
94 - $parts[3] = '0';
95 - return implode('.', $parts);
96 - }
97 - if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
98 - return substr($ip, 0, 20) . '::';
99 - }
100 - return 'unknown';
101 - }
76 + // Logdatei 100% absolut sicher machen
77 + $this->log_file = $this->ensureAbsolutePath($this->log_file);
102 78
103 - private function rotateLogs(int $maxSize = 52428800, int $maxFiles = 5): void {
104 - if (file_exists($this->log_file) && filesize($this->log_file) > $maxSize) {
105 - for ($i = $maxFiles - 1; $i >= 1; $i--) {
106 - $old = $this->log_file . '.' . $i;
107 - $new = $this->log_file . '.' . ($i + 1);
108 - if (file_exists($old)) {
109 - rename($old, $new);
110 - }
111 - }
112 - rename($this->log_file, $this->log_file . '.1');
113 - }
114 - }
79 + // Log-Level
80 + $this->log_level = defined('F12_DEBUG_LOG_LEVEL')
81 + ? F12_DEBUG_LOG_LEVEL
82 + : self::INFO;
83 + }
115 84
116 - private function cleanupOldLogs(int $days = 7): void {
117 - foreach (glob($this->log_dir . '/plugins-*.log*') as $file) {
118 - if (filemtime($file) < strtotime("-{$days} days")) {
119 - @unlink($file);
120 - }
121 - }
122 - }
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 + }
123 93
124 - private function writeLog( $level, $levelName, $message, array $context = [] ) {
125 - if ( ! defined( 'F12_DEBUG' ) || ! F12_DEBUG ) {
126 - // Wenn F12_DEBUG nicht definiert ist oder false, beenden wir.
127 - return;
128 - }
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 + }
129 111
130 - if ( $level < $this->log_level ) {
131 - return;
132 - }
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 + }
133 122
134 - // Cleanup & Rotation bei jedem Schreiben
135 - $this->cleanupOldLogs();
136 - $this->rotateLogs();
123 + return $material === '' ? '' : substr(hash_hmac('sha256', 'f12-logs', $material), 0, 20);
124 + }
137 125
138 - $context = $this->sanitizeContext($context);
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 + }
139 137
140 - $time = date( 'Y-m-d H:i:s' );
141 - $plugin = $context['plugin'] ?? 'unknown';
142 - $msg = sprintf( "[%s] [%s] [%s] %s %s\n",
143 - $time,
144 - strtoupper( $levelName ),
145 - $plugin,
146 - $message,
147 - $context ? json_encode( $context ) : ''
148 - );
149 - error_log( $msg, 3, $this->log_file );
150 - }
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 + );
151 147
152 - public function debug( $message, array $context = [] ) :void {
153 - $this->writeLog( self::DEBUG, 'DEBUG', $message, $context );
154 - }
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 155
156 - public function info( $message, array $context = [] ) : void {
157 - $this->writeLog( self::INFO, 'INFO', $message, $context );
158 - }
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 + }
159 165
160 - public function error( $message, array $context = [] ) : void {
161 - $this->writeLog( self::ERROR, 'ERROR', $message, $context );
162 - }
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 + }
163 183
164 - public function warning( $message, array $context = [] ) : void {
165 - $this->writeLog( self::WARNING, 'WARNING', $message, $context );
166 - }
184 + public static function getInstance()
185 + {
186 + if (!self::$instance) {
187 + self::$instance = new self();
188 + }
189 + return self::$instance;
190 + }
167 191
168 - public function notice( $message, array $context = [] ) : void {
169 - $this->writeLog( self::NOTICE, 'NOTICE', $message, $context );
170 - }
192 + /**
193 + * Pfade bereinigen (Windows + Linux)
194 + */
195 + private function normalizePath(string $path): string
196 + {
197 + // Backslashes → Slashes
198 + $path = str_replace('\\', '/', $path);
171 199
172 - public function critical( $message, array $context = [] ) : void {
173 - $this->writeLog( self::CRITICAL, 'CRITICAL', $message, $context );
174 - }
175 - }
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 + }
176 375 }