PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / trunk
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification vtrunk
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 3.0.70 3.0.71 3.0.72 3.1.0 All 34 releases
double-opt-in / src / Files / FileStorage.php

FileStorage.php in Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification trunk, at src/Files/FileStorage.php

345 lines 10.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * File-storage service — pending-area lifecycle for opt-in uploads.
4 *
5 * @package Forge12\DoubleOptIn\Files
6 * @since 4.3.0
7 */
8
9 namespace Forge12\DoubleOptIn\Files;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 use Forge12\Shared\LoggerInterface;
16
17 /**
18 * Centralised handling of file uploads attached to opt-in records.
19 *
20 * Pre-4.3 file uploads were copied (not moved) into PHP's
21 * upload_tmp_dir with random names. The OS would eventually clean
22 * them up — unpredictable. Plus, integrations had no consistent
23 * way to hand the file off to their form system on confirmation.
24 *
25 * This service centralises the storage location and the
26 * delete-by-paths primitive. The hand-off itself stays per-
27 * integration (each form system has its own attachment-storage
28 * conventions) — see AbstractFormIntegration::handOffFilesToFormSystem.
29 *
30 * Lifecycle (single source of truth):
31 *
32 * submit ─► store($files) moves to pending/
33 * paths saved in OptIn::files
34 * confirm ─► hand-off succeeds integration's own DB has the file
35 * deletePaths($paths) pending/{file} unlinked
36 * delete ─► deletePaths($paths) cascade-delete on OptIn removal
37 *
38 * Pending dir layout (FLAT — no per-hash subdir):
39 * wp-content/uploads/f12-doi/
40 * ├── .htaccess deny from all (Apache)
41 * ├── index.php empty file (universal blocker)
42 * └── pending/
43 * ├── .htaccess
44 * ├── index.php
45 * └── {32-hex}.{ext} files with random non-guessable names
46 *
47 * Why flat instead of pending/{hash}/: at storeFiles() call-time
48 * (line 259 of AbstractFormIntegration::processSubmission), the OptIn
49 * record has not been constructed yet — we have no hash. Re-shuffling
50 * that flow is invasive in a security-critical code path. Flat layout
51 * is sufficient: random hex names are non-guessable + not web-served
52 * (deny-from-all).
53 */
54 class FileStorage {
55
56 /**
57 * Subdirectory under wp-content/uploads/ for opt-in file storage.
58 * NOT web-served: the .htaccess + index.php blockers below ensure
59 * direct URL access returns 403 / empty.
60 */
61 public const SUBDIR = 'f12-doi';
62
63 /**
64 * Phase-subdir under SUBDIR for files awaiting confirmation +
65 * hand-off. Files removed from here on either:
66 * - successful confirm + hand-off (immediate)
67 * - OptIn cascade-delete (cron expiry / manual REST / hash)
68 */
69 public const PENDING = 'pending';
70
71 /**
72 * Allowed MIME types for uploaded opt-in files.
73 *
74 * Extension via the `f12_cf7_doubleoptin_allowed_mime_types` filter.
75 * Anything not on this list is rejected at store() time and never
76 * reaches the pending dir — defence in depth against the form
77 * system's own validation drifting.
78 */
79 private const ALLOWED_MIME_TYPES = array(
80 'jpg' => 'image/jpeg',
81 'jpeg' => 'image/jpeg',
82 'png' => 'image/png',
83 'gif' => 'image/gif',
84 'webp' => 'image/webp',
85 'pdf' => 'application/pdf',
86 'doc' => 'application/msword',
87 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
88 'txt' => 'text/plain',
89 'csv' => 'text/csv',
90 );
91
92 private LoggerInterface $logger;
93
94 public function __construct( LoggerInterface $logger ) {
95 $this->logger = $logger;
96 }
97
98 /**
99 * Move uploaded files into the pending dir under random hex names.
100 *
101 * Each file is MIME-validated against the allowlist. Invalid files
102 * are skipped + logged at warning level. The returned array has
103 * exactly the paths that were actually stored — caller persists
104 * these in OptIn::files.
105 *
106 * Accepts either a flat `[path, path, ...]` array or a nested
107 * `[fieldName => path-or-paths]` map (Avada/CF7 fields can carry
108 * multiple files).
109 *
110 * @param array $files Either flat list of source paths, or
111 * [fieldName => path|paths] map.
112 *
113 * @return array<int,string> Final paths in pending/. Empty if none stored.
114 */
115 public function store( array $files ): array {
116 $stored = array();
117
118 if ( empty( $files ) ) {
119 return $stored;
120 }
121
122 $pendingDir = $this->ensureSecureDir();
123 if ( $pendingDir === '' ) {
124 $this->logger->error(
125 'Aborting file storage: pending dir not writable',
126 array(
127 'plugin' => 'double-opt-in',
128 )
129 );
130 return $stored;
131 }
132
133 $allowedMimes = apply_filters(
134 'f12_cf7_doubleoptin_allowed_mime_types',
135 self::ALLOWED_MIME_TYPES
136 );
137
138 foreach ( $files as $entry ) {
139 // Tolerant of nested (fieldName => path|paths) shapes.
140 $paths = is_array( $entry ) ? $entry : array( $entry );
141
142 foreach ( $paths as $sourcePath ) {
143 if ( ! is_string( $sourcePath ) || $sourcePath === '' || ! is_file( $sourcePath ) ) {
144 continue;
145 }
146
147 $movedPath = $this->moveOneFile( $sourcePath, $pendingDir, $allowedMimes );
148 if ( $movedPath !== null ) {
149 $stored[] = $movedPath;
150 }
151 }
152 }
153
154 return $stored;
155 }
156
157 /**
158 * Unlink every path that lives under the pending dir. Paths
159 * outside it are silently rejected — defence-in-depth against
160 * a future caller passing tainted strings into a delete primitive.
161 *
162 * Idempotent: missing files are not an error (could already have
163 * been deleted by hand-off, or removed by an admin manually).
164 *
165 * @param array<int,string> $paths Paths to unlink.
166 */
167 public function deletePaths( array $paths ): void {
168 if ( empty( $paths ) ) {
169 return;
170 }
171
172 $pendingDir = $this->getPendingDirRealPath();
173 if ( $pendingDir === '' ) {
174 return; // dir doesn't exist yet → nothing to delete
175 }
176
177 foreach ( $paths as $path ) {
178 if ( ! is_string( $path ) || $path === '' ) {
179 continue;
180 }
181
182 // Path-traversal defense. realpath() returns the canonical
183 // path with all `..` resolved. If the result doesn't begin
184 // with our pending dir, the path is rejected — no matter
185 // what the caller passed in.
186 $realPath = @realpath( $path );
187 if ( $realPath === false ) {
188 continue; // file already gone — idempotent
189 }
190
191 if ( ! str_starts_with( $realPath, $pendingDir ) ) {
192 $this->logger->error(
193 'Refusing to unlink path outside pending dir',
194 array(
195 'plugin' => 'double-opt-in',
196 'path' => $path,
197 'real_path' => $realPath,
198 'pending_dir' => $pendingDir,
199 )
200 );
201 continue;
202 }
203
204 if ( @unlink( $realPath ) ) {
205 $this->logger->debug(
206 'Unlinked file from pending',
207 array(
208 'plugin' => 'double-opt-in',
209 'path' => $realPath,
210 )
211 );
212 } else {
213 $this->logger->warning(
214 'Failed to unlink file (will retry on next cleanup)',
215 array(
216 'plugin' => 'double-opt-in',
217 'path' => $realPath,
218 )
219 );
220 }
221 }
222 }
223
224 /**
225 * Ensure the pending dir exists and is locked down against direct
226 * web access. Returns the absolute path on success, '' on failure.
227 *
228 * Idempotent — safe to call on every store() invocation.
229 */
230 public function ensureSecureDir(): string {
231 $uploads = wp_upload_dir();
232 if ( empty( $uploads['basedir'] ) ) {
233 return '';
234 }
235
236 $base = trailingslashit( $uploads['basedir'] ) . self::SUBDIR;
237 $pending = trailingslashit( $base ) . self::PENDING;
238
239 // Two nested dirs to secure. wp_mkdir_p is recursive +
240 // idempotent — safe to call repeatedly.
241 if ( ! file_exists( $pending ) ) {
242 if ( ! wp_mkdir_p( $pending ) ) {
243 return '';
244 }
245 }
246
247 // Drop blockers in BOTH dirs (base + pending). Belt and braces:
248 // shared hosts vary in which file-types the webserver serves.
249 $this->writeBlockers( $base );
250 $this->writeBlockers( $pending );
251
252 // Return canonical path — used by deletePaths() for the
253 // path-traversal check.
254 $real = @realpath( $pending );
255 return $real === false ? '' : $real;
256 }
257
258 /**
259 * Single-file move with MIME validation + non-guessable rename.
260 * Returns the new path or null on rejection/failure.
261 *
262 * @param string $source Source path (from $_FILES tmp).
263 * @param string $pendingDir Absolute pending dir path.
264 * @param array<string, string> $allowedMimes ext => mime allowlist.
265 */
266 private function moveOneFile( string $source, string $pendingDir, array $allowedMimes ): ?string {
267 $check = wp_check_filetype_and_ext( $source, wp_basename( $source ), $allowedMimes );
268
269 if ( empty( $check['type'] ) || empty( $check['ext'] ) ) {
270 $this->logger->warning(
271 'File rejected: MIME type not on allowlist',
272 array(
273 'plugin' => 'double-opt-in',
274 'source' => $source,
275 )
276 );
277 return null;
278 }
279
280 $newName = bin2hex( random_bytes( 16 ) ) . '.' . $check['ext'];
281 $dest = trailingslashit( $pendingDir ) . $newName;
282
283 // Prefer move (rename) over copy — rename is atomic + faster +
284 // doesn't double-store. Falls back to copy+unlink for cross-
285 // device sources (PHP's tmp dir on a different filesystem
286 // than uploads/ — common in containerised hosts).
287 if ( @rename( $source, $dest ) ) {
288 return $dest;
289 }
290
291 if ( @copy( $source, $dest ) ) {
292 @unlink( $source );
293 return $dest;
294 }
295
296 $this->logger->error(
297 'Failed to move file into pending dir',
298 array(
299 'plugin' => 'double-opt-in',
300 'source' => $source,
301 'dest' => $dest,
302 )
303 );
304 return null;
305 }
306
307 /**
308 * Write .htaccess + index.php into the given dir. Idempotent.
309 */
310 private function writeBlockers( string $dir ): void {
311 $htaccess = trailingslashit( $dir ) . '.htaccess';
312 $index = trailingslashit( $dir ) . 'index.php';
313
314 if ( ! file_exists( $htaccess ) ) {
315 @file_put_contents(
316 $htaccess,
317 "# Forge12 DOI — never serve from this dir directly\n" .
318 "<IfModule mod_authz_core.c>\nRequire all denied\n</IfModule>\n" .
319 "<IfModule !mod_authz_core.c>\nDeny from all\n</IfModule>\n"
320 );
321 }
322
323 if ( ! file_exists( $index ) ) {
324 // Standard WP-style empty PHP file — silent in case the
325 // host's webserver serves index.php for directory requests.
326 @file_put_contents( $index, "<?php\n// Silence is golden.\n" );
327 }
328 }
329
330 /**
331 * Canonical absolute path of the pending dir, or '' if not yet
332 * created. Used by deletePaths for the path-traversal check.
333 */
334 private function getPendingDirRealPath(): string {
335 $uploads = wp_upload_dir();
336 if ( empty( $uploads['basedir'] ) ) {
337 return '';
338 }
339 $pending = trailingslashit( $uploads['basedir'] ) . self::SUBDIR . '/' . self::PENDING;
340
341 $real = @realpath( $pending );
342 return $real === false ? '' : $real;
343 }
344 }
345