| 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 |
|