PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.1.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.1.0
1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
suredonation / inc / pdf / pdf-manager.php

pdf-manager.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.1.0, at inc/pdf/pdf-manager.php

240 lines 7.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * PDF Library Manager.
4 *
5 * Handles downloading and deleting the mPDF library via AJAX.
6 *
7 * @package SureDonation
8 */
9
10 namespace SureDonation\Inc\Pdf;
11
12 use SureDonation\Inc\Traits\Get_Instance;
13
14 // Exit if accessed directly.
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit;
17 }
18
19 /**
20 * Pdf_Manager class.
21 *
22 * @since 1.0.0
23 */
24 class Pdf_Manager {
25 use Get_Instance;
26
27 /**
28 * GitHub URL for the PDF library zip.
29 */
30 public const LIBRARY_URL = 'https://raw.githubusercontent.com/brainstormforce/sureforms-libraries/master/pdf.zip';
31
32 /**
33 * Expected SHA-256 hash of the PDF library zip for integrity verification.
34 *
35 * Update this hash whenever the library zip is updated in the repository.
36 */
37 public const LIBRARY_HASH = '71ea36ac9e41f38e395f8e62d6be04efd1c9290fc34eb464a6c34fabee632994';
38
39 /**
40 * Constructor.
41 *
42 * @since 1.0.0
43 */
44 public function __construct() {
45 add_action( 'wp_ajax_suredonation_download_pdf_library', [ $this, 'download_pdf_library' ] );
46 add_action( 'wp_ajax_suredonation_delete_pdf_library', [ $this, 'delete_pdf_library' ] );
47 }
48
49 /**
50 * Download and install the PDF library.
51 *
52 * @return void
53 * @since 1.0.0
54 */
55 public function download_pdf_library() {
56 check_ajax_referer( 'suredonation_pdf_nonce', 'nonce' );
57
58 if ( ! current_user_can( 'manage_options' ) ) {
59 wp_send_json_error( [ 'message' => __( 'You do not have permission to perform this action.', 'suredonation' ) ] );
60 }
61
62 if ( ! Pdf_Utils::is_php_compatible() ) {
63 wp_send_json_error( [ 'message' => __( 'PHP 8.0 or higher is required for the PDF library.', 'suredonation' ) ] );
64 }
65
66 // Include required WordPress file handling functions.
67 require_once ABSPATH . 'wp-admin/includes/file.php';
68 require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
69
70 // Download the zip file.
71 $tmp_file = download_url( self::LIBRARY_URL );
72
73 if ( is_wp_error( $tmp_file ) ) {
74 wp_send_json_error( [ 'message' => $tmp_file->get_error_message() ] );
75 }
76
77 // Verify file integrity.
78 $file_hash = hash_file( 'sha256', $tmp_file );
79 if ( self::LIBRARY_HASH !== $file_hash ) {
80 wp_delete_file( $tmp_file );
81 wp_send_json_error( [ 'message' => __( 'Library integrity check failed. The downloaded file may be corrupted or tampered with.', 'suredonation' ) ] );
82 }
83
84 // Prepare target directory.
85 $target_dir = WP_PLUGIN_DIR . '/suredonation-libraries';
86
87 // Initialize WP_Filesystem.
88 WP_Filesystem();
89 global $wp_filesystem;
90
91 if ( ! $wp_filesystem ) {
92 wp_delete_file( $tmp_file );
93 wp_send_json_error( [ 'message' => __( 'Could not initialize filesystem.', 'suredonation' ) ] );
94 }
95
96 // Create target directory if it doesn't exist.
97 if ( ! $wp_filesystem->exists( $target_dir ) ) {
98 $wp_filesystem->mkdir( $target_dir, FS_CHMOD_DIR );
99 }
100
101 // Remove existing pdf directory if present.
102 $pdf_dir = $target_dir . '/pdf';
103 if ( $wp_filesystem->exists( $pdf_dir ) ) {
104 $wp_filesystem->delete( $pdf_dir, true );
105 }
106
107 // Per-entry path validation before extraction. WP's unzip_file()
108 // applies basic safety but doesn't reject absolute paths or
109 // directory traversal on every PHP / zip combination. Validate
110 // each entry resolves inside $target_dir before letting unzip_file
111 // touch the disk — belt-and-suspenders against a tampered zip
112 // (covered today by the hash check, but cheap defense-in-depth).
113 $zip_check = self::validate_zip_entries( $tmp_file, $target_dir );
114 if ( is_wp_error( $zip_check ) ) {
115 wp_delete_file( $tmp_file );
116 wp_send_json_error( [ 'message' => $zip_check->get_error_message() ] );
117 }
118
119 // Unzip the library.
120 $result = unzip_file( $tmp_file, $target_dir );
121
122 // Clean up temp file.
123 wp_delete_file( $tmp_file );
124
125 if ( is_wp_error( $result ) ) {
126 wp_send_json_error( [ 'message' => $result->get_error_message() ] );
127 }
128
129 // Verify the library was installed correctly.
130 if ( ! Pdf_Utils::check_if_library_exists() ) {
131 wp_send_json_error( [ 'message' => __( 'Library extraction failed. Please try again.', 'suredonation' ) ] );
132 }
133
134 // Create receipts directory.
135 Pdf_Utils::ensure_receipts_dir();
136
137 wp_send_json_success( [ 'message' => __( 'PDF library installed successfully.', 'suredonation' ) ] );
138 }
139
140 /**
141 * Delete the PDF library.
142 *
143 * @return void
144 * @since 1.0.0
145 */
146 public function delete_pdf_library() {
147 check_ajax_referer( 'suredonation_pdf_nonce', 'nonce' );
148
149 if ( ! current_user_can( 'manage_options' ) ) {
150 wp_send_json_error( [ 'message' => __( 'You do not have permission to perform this action.', 'suredonation' ) ] );
151 }
152
153 $target_dir = WP_PLUGIN_DIR . '/suredonation-libraries';
154
155 // Initialize WP_Filesystem.
156 WP_Filesystem();
157 global $wp_filesystem;
158
159 if ( ! $wp_filesystem ) {
160 wp_send_json_error( [ 'message' => __( 'Could not initialize filesystem.', 'suredonation' ) ] );
161 }
162
163 if ( $wp_filesystem->exists( $target_dir ) ) {
164 $wp_filesystem->delete( $target_dir, true );
165 }
166
167 wp_send_json_success( [ 'message' => __( 'PDF library removed successfully.', 'suredonation' ) ] );
168 }
169
170 /**
171 * Validate every entry in a zip resolves inside the target directory.
172 *
173 * Rejects absolute-path entries, parent-directory traversal, and any
174 * entry whose resolved path escapes $target_dir. Also enforces a max
175 * uncompressed size guard against zip-bomb expansion. Returns a
176 * WP_Error on the first unsafe entry; null on success.
177 *
178 * @param string $zip_path Path to the downloaded zip.
179 * @param string $target_dir Absolute path where the zip will extract.
180 * @return \WP_Error|null
181 * @since 1.0.0
182 */
183 private static function validate_zip_entries( $zip_path, $target_dir ) {
184 if ( ! class_exists( 'ZipArchive' ) ) {
185 // Can't validate without ZipArchive; let unzip_file's own
186 // defenses run rather than blocking the install. Hash check
187 // upstream is the primary integrity defense.
188 return null;
189 }
190
191 $zip = new \ZipArchive();
192 if ( true !== $zip->open( $zip_path ) ) {
193 return new \WP_Error( 'unsafe_zip', __( 'Could not open library archive for validation.', 'suredonation' ) );
194 }
195
196 // 50MB uncompressed cap — the mPDF zip is ~10MB; anything wildly
197 // larger is either a corrupted archive or a zip-bomb attempt.
198 $max_uncompressed = 50 * 1024 * 1024;
199 $total_size = 0;
200
201 $target_real = rtrim( wp_normalize_path( $target_dir ), '/' );
202
203 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- $numFiles is a PHP stdlib property on ZipArchive.
204 $num_files = $zip->numFiles;
205
206 for ( $i = 0; $i < $num_files; $i++ ) {
207 $stat = $zip->statIndex( $i );
208 if ( ! is_array( $stat ) || ! isset( $stat['name'] ) ) {
209 $zip->close();
210 return new \WP_Error( 'unsafe_zip_entry', __( 'Archive contains an unreadable entry.', 'suredonation' ) );
211 }
212
213 $name = (string) $stat['name'];
214
215 // Reject absolute paths and parent-directory traversal up-front
216 // — the cheapest checks that catch the common malicious patterns.
217 if ( '' === $name || '/' === $name[0] || false !== strpos( $name, '..' ) || preg_match( '#^[a-zA-Z]:[\\\/]#', $name ) ) {
218 $zip->close();
219 return new \WP_Error( 'unsafe_zip_entry', __( 'Archive contains an unsafe path.', 'suredonation' ) );
220 }
221
222 // Verify the resolved entry stays inside $target_dir.
223 $resolved = wp_normalize_path( $target_real . '/' . $name );
224 if ( 0 !== strpos( $resolved, $target_real . '/' ) ) {
225 $zip->close();
226 return new \WP_Error( 'unsafe_zip_entry', __( 'Archive contains a path escaping the target directory.', 'suredonation' ) );
227 }
228
229 $total_size += isset( $stat['size'] ) ? (int) $stat['size'] : 0;
230 if ( $total_size > $max_uncompressed ) {
231 $zip->close();
232 return new \WP_Error( 'unsafe_zip', __( 'Archive uncompressed size exceeds the safety limit.', 'suredonation' ) );
233 }
234 }
235
236 $zip->close();
237 return null;
238 }
239 }
240