PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.1
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.1
1.6.1 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
← All changes | inc/pdf/receipt-generator.php +457 -56 1.1.0 → 1.6.1 View file →
@@ -11,8 +11,9 @@
11 11
12 12 use SureDonation\Inc\Database\Tables\Donations;
13 13 use SureDonation\Inc\Database\Tables\Donors;
14 14 use SureDonation\Inc\Helper;
15 +use SureDonation\Inc\Payments\Payment_Helper;
15 16
16 17 // Exit if accessed directly.
17 18 if ( ! defined( 'ABSPATH' ) ) {
18 19 exit;
@@ -37,8 +38,12 @@
37 38 if ( ! $donation ) {
38 39 return false;
39 40 }
40 41
42 + if ( ! self::should_generate( $donation ) ) {
43 + return false;
44 + }
45 +
41 46 // Check if a cached PDF exists.
42 47 $existing_relative = $donation['receipt_pdf_url'] ?? '';
43 48
44 49 if ( ! empty( $existing_relative ) ) {
@@ -43,9 +48,10 @@
43 48
44 49 if ( ! empty( $existing_relative ) ) {
45 50 $existing_path = self::relative_to_path( Helper::get_string_value( $existing_relative ) );
46 51
47 - if ( $existing_path && file_exists( $existing_path ) ) {
52 + // is_file() so a directory can never be served as a cached receipt.
53 + if ( $existing_path && is_file( $existing_path ) ) {
48 54 return $existing_path;
49 55 }
50 56 }
51 57
@@ -72,8 +78,12 @@
72 78 if ( ! $donation ) {
73 79 return false;
74 80 }
75 81
82 + if ( ! self::should_generate( $donation ) ) {
83 + return false;
84 + }
85 +
76 86 $donor_id = Helper::get_integer_value( $donation['donor_id'] ?? 0 );
77 87 $donor = $donor_id ? Donors::get( $donor_id ) : null;
78 88
79 89 $campaign_id = Helper::get_integer_value( $donation['campaign_id'] ?? 0 );
@@ -84,11 +94,14 @@
84 94
85 95 /**
86 96 * Filter the receipt HTML before PDF generation.
87 97 *
88 - * SECURITY NOTE: The returned HTML is passed directly to mPDF's WriteHTML().
89 - * mPDF can process <img> tags with file:// URIs and load external resources.
90 - * Ensure any modifications only use trusted, escaped content.
98 + * SECURITY NOTE: The returned HTML is passed directly to mPDF's
99 + * WriteHTML(). Resource fetching is blocked at the mPDF level by the
100 + * empty 'whitelistStreamWrappers' in self::get_mpdf_config(), so an
101 + * `<img src>` cannot reach a remote host or a local file — but that is
102 + * the only guarantee. Everything else about the returned markup is
103 + * trusted, so only ever return escaped content.
91 104 *
92 105 * @param string $html Receipt HTML.
93 106 * @param array $donation Donation data.
94 107 * @param array|null $donor Donor data.
@@ -98,14 +111,32 @@
98 111
99 112 // Ensure receipts directory exists.
100 113 Pdf_Utils::ensure_receipts_dir();
101 114
102 - $receipts_dir = Pdf_Utils::get_receipts_dir();
103 - $filename = sprintf( 'suredonation-receipt-%d-%s.pdf', $donation_id, wp_generate_password( 8, false ) );
104 - $filepath = $receipts_dir . '/' . $filename;
115 + $receipts_dir = Pdf_Utils::get_receipts_dir();
116 + $default_filename = self::generate_storage_filename();
105 117
118 + $filename = self::resolve_storage_filename( $default_filename, $donation, $donor );
119 +
120 + $filepath = $receipts_dir . '/' . $filename;
121 +
106 122 try {
107 - $mpdf = new \Mpdf\Mpdf( self::get_mpdf_config() );
123 + $mpdf = new \Mpdf\Mpdf( self::get_mpdf_config( $donation, $donor ) );
124 +
125 + /**
126 + * Fires after the mPDF instance is created, before the receipt HTML is written.
127 + *
128 + * Allows instance-level configuration the constructor config cannot
129 + * express — e.g. SetProtection() for password-protected receipts or
130 + * SetTitle()/SetAuthor() document metadata.
131 + *
132 + * @param \Mpdf\Mpdf $mpdf mPDF instance.
133 + * @param array<string, mixed> $donation Donation data.
134 + * @param array<string, mixed>|null $donor Donor data.
135 + * @since 1.5.0
136 + */
137 + do_action( 'suredonation_receipt_mpdf_instance', $mpdf, $donation, $donor );
138 +
108 139 $mpdf->WriteHTML( $html );
109 140 $mpdf->Output( $filepath, \Mpdf\Output\Destination::FILE );
110 141 } catch ( \Exception $e ) {
111 142 return false;
@@ -115,39 +146,230 @@
115 146 $upload_dir = wp_upload_dir();
116 147 $relative_path = str_replace( $upload_dir['basedir'] . '/', '', $filepath );
117 148 Donations::update( $donation_id, [ 'receipt_pdf_url' => $relative_path ] );
118 149
150 + /**
151 + * Fires after a receipt PDF has been generated and stored.
152 + *
153 + * @param int $donation_id Donation ID.
154 + * @param string $filepath Absolute path to the generated PDF.
155 + * @param array<string, mixed> $donation Donation data.
156 + * @since 1.5.0
157 + */
158 + do_action( 'suredonation_receipt_generated', $donation_id, $filepath, $donation );
159 +
119 160 return $filepath;
120 161 }
121 162
122 163 /**
164 + * Build the opaque on-disk filename for a receipt.
165 + *
166 + * 128 bits of lowercase hex and nothing else. The name is sized as a
167 + * capability token rather than as a collision-avoidance suffix, because on
168 + * servers that ignore the receipts directory's .htaccess it is the only
169 + * thing standing between a request and a document carrying the donor's
170 + * name, email and amount. Lowercase keeps the entropy honest on
171 + * case-insensitive filesystems (macOS, Windows), where a mixed-case name
172 + * collapses to a much smaller space than it appears to occupy.
173 + *
174 + * @return string
175 + * @since 1.5.1
176 + */
177 + private static function generate_storage_filename() {
178 + try {
179 + $token = bin2hex( random_bytes( 16 ) );
180 + } catch ( \Exception $e ) {
181 + // random_bytes() only throws when the platform has no CSPRNG at all.
182 + // Be honest about the fallback: wp_rand() reaches for random_int()
183 + // first, which draws on the same source that just failed, so it lands
184 + // on its seeded md5/mt_rand stream -- salted and not trivially
185 + // predictable, but not cryptographic either. A host in that state
186 + // cannot keep any secret; a receipt name is the least of it.
187 + $token = strtolower( wp_generate_password( 32, false ) );
188 + }
189 +
190 + return 'sd-receipt-' . $token . '.pdf';
191 + }
192 +
193 + /**
194 + * Get the filename a donor sees when a receipt is delivered.
195 + *
196 + * Deliberately separate from the name on disk: the stored file is named
197 + * for unguessability, while the delivered copy is named for the person
198 + * reading it. Used for the email attachment name and for the
199 + * Content-Disposition of any authenticated download.
200 + *
201 + * @param array<string, mixed> $donation Donation data. Carries donor_name and
202 + * donor_email, so no separate donor record
203 + * is needed to build a donor-facing name.
204 + * @return string
205 + * @since 1.5.1
206 + */
207 + public static function get_download_filename( $donation ) {
208 + $donation_id = Helper::get_integer_value( $donation['id'] ?? 0 );
209 + $default_filename = $donation_id > 0
210 + ? sprintf( 'donation-receipt-%d.pdf', $donation_id )
211 + : 'donation-receipt.pdf';
212 +
213 + /**
214 + * Filter the filename a donor sees when a receipt is delivered.
215 + *
216 + * This never names anything on disk -- it is the name attached to the
217 + * email and sent as Content-Disposition -- so it is free to carry
218 + * donor-facing detail such as a receipt number.
219 + *
220 + * Deliberately NOT shaped like the storage name any more. That one
221 + * collapses interior dots to guarantee a single extension, because it
222 + * names a file inside a web-accessible directory; this one only ever
223 + * becomes a Content-Disposition value or an email attachment key, never
224 + * a path, so it keeps whatever dots the filter supplied and a filtered
225 + * "x.php" stays "x.php.pdf". Nothing here touches a filesystem, and the
226 + * name still ends in .pdf, so the OS treats it as one.
227 + *
228 + * @param string $default_filename Default download filename.
229 + * @param array<string, mixed> $donation Donation data.
230 + * @since 1.5.1
231 + */
232 + $filename = apply_filters( 'suredonation_receipt_download_filename', $default_filename, $donation );
233 + $filename = sanitize_file_name( Helper::get_string_value( $filename ) );
234 +
235 + if ( '' === $filename ) {
236 + return $default_filename;
237 + }
238 +
239 + if ( 'pdf' !== strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) ) ) {
240 + $filename .= '.pdf';
241 + }
242 +
243 + return $filename;
244 + }
245 +
246 + /**
247 + * Whether a receipt should be generated or served for a donation.
248 + *
249 + * @param array<string, mixed> $donation Donation data.
250 + * @return bool
251 + * @since 1.5.0
252 + */
253 + private static function should_generate( $donation ) {
254 + /**
255 + * Short-circuit filter to disable receipt generation for a donation.
256 + *
257 + * Return false to prevent generating a new receipt PDF and to stop a
258 + * previously cached one from being served. Lets extensions disable
259 + * receipts selectively — e.g. a per-form "disable PDF receipt"
260 + * setting keyed on the donation's form_id.
261 + *
262 + * @param bool $should_generate Whether to generate/serve the receipt. Default true.
263 + * @param array<string, mixed> $donation Donation data.
264 + * @since 1.5.0
265 + */
266 + return (bool) apply_filters( 'suredonation_should_generate_receipt', true, $donation );
267 + }
268 +
269 + /**
270 + * Delete a receipt PDF file by its stored uploads-relative path.
271 + *
272 + * Used by the personal-data eraser: the receipt is generated from the donor's
273 + * name/email/address, so an erasure must remove the file from disk, not just
274 + * the database columns.
275 + *
276 + * A path that fails containment is refused rather than deleted, and still
277 + * reports true: the caller's row should not be blocked forever by a
278 + * pointer this function will never act on, and refusing to touch the file
279 + * is the safe half of the trade.
280 + *
281 + * @since 1.2.0
282 + * @param string $relative_path Relative path within the uploads directory.
283 + * @return bool True when this function will do nothing further with the path (file removed, never existed, or refused as out of bounds), false when the file survived deletion.
284 + */
285 + public static function delete_receipt( $relative_path ) {
286 + $filepath = self::relative_to_path( $relative_path );
287 +
288 + // file_exists() here, is_file() below, and the asymmetry is deliberate:
289 + // PHPStan narrows a repeated identical call, so guarding with is_file()
290 + // makes the post-delete is_file() read as always-false to it. The two
291 + // answers only differ for a directory named *.pdf, and that returns true
292 + // either way (nothing that is a file remains), so nothing is lost.
293 + if ( false === $filepath || ! file_exists( $filepath ) ) {
294 + return true;
295 + }
296 +
297 + wp_delete_file( $filepath );
298 +
299 + // Re-check with is_file() (not file_exists()) — wp_delete_file() has a
300 + // filesystem side effect PHPStan can't see, so re-calling the already
301 + // narrowed file_exists() reads as always-false to it.
302 + clearstatcache( true, $filepath );
303 +
304 + return ! is_file( $filepath );
305 + }
306 +
307 + /**
123 308 * Get the mPDF configuration.
124 309 *
310 + * @param array<string, mixed> $donation Donation data.
311 + * @param array<string, mixed>|null $donor Donor data.
125 312 * @return array<string,mixed>
126 313 * @since 1.0.0
127 314 */
128 - private static function get_mpdf_config() {
129 - return [
130 - 'mode' => 'utf-8',
131 - 'format' => 'A4',
132 - 'orientation' => 'P',
133 - 'margin_left' => 15,
134 - 'margin_right' => 15,
135 - 'margin_top' => 15,
136 - 'margin_bottom' => 15,
137 - 'default_font' => 'dejavusans',
138 - 'tempDir' => Pdf_Utils::get_temp_dir(),
139 - // mPDF defaults allow <img src="file:///..."> and remote http(s)
140 - // resource fetching. The receipt HTML is server-templated with
141 - // escaped fields, but the suredonation_receipt_html filter (and
142 - // any future ucfirst-only gateway label) would still surface
143 - // donor / gateway data into HTML that mPDF processes. Disable
144 - // the dangerous resource-loading defaults so a malicious string
145 - // in any rendered field can't become SSRF (remote fetch) or
146 - // LFI (local file read into the PDF) regardless of the source.
147 - 'allow_remote_dir_in_links_filesystem' => false,
148 - 'curlAllowUnsafeSslRequests' => false,
315 + private static function get_mpdf_config( $donation = [], $donor = null ) {
316 + $config = [
317 + 'mode' => 'utf-8',
318 + 'format' => 'A4',
319 + 'orientation' => 'P',
320 + 'margin_left' => 15,
321 + 'margin_right' => 15,
322 + 'margin_top' => 15,
323 + 'margin_bottom' => 15,
324 + 'default_font' => 'dejavusans',
325 + 'tempDir' => Pdf_Utils::get_temp_dir(),
326 + // mPDF resolves `<img src>` and CSS url() through stream wrappers,
327 + // and its default whitelist is ['http', 'https', 'file'] — so out of
328 + // the box a src can reach an internal host (SSRF) or read a local
329 + // file into the PDF (LFI). The receipt HTML is server-templated with
330 + // escaped fields, but the suredonation_receipt_html filter and the
331 + // Pro receipt templates both put author-controlled HTML through
332 + // WriteHTML(), and wp_kses_post() permits <img src="http(s)://…">.
333 + //
334 + // Emptying the whitelist is the actual control: Mpdf\File\
335 + // StreamWrapperChecker then rejects every `scheme://` src before a
336 + // fetch happens. Nothing legitimate needs one — the logo is
337 + // embedded as a data: URI (no `://`, so the check never fires) and
338 + // local font/temp paths are plain paths. A blocked <img> degrades to
339 + // mPDF's own imageError() handling rather than failing the render.
340 + 'whitelistStreamWrappers' => [],
341 + // Kept as defence in depth: if a filter callback ever re-whitelists
342 + // http(s), requests still verify SSL. This is not what stops the
343 + // fetch — the whitelist above is.
344 + 'curlAllowUnsafeSslRequests' => false,
149 345 ];
346 +
347 + /**
348 + * Filter the mPDF configuration used for receipt generation.
349 + *
350 + * SECURITY NOTE: 'whitelistStreamWrappers' is emptied deliberately.
351 + * Restoring any entry lets HTML that reaches WriteHTML() fetch that
352 + * scheme, which is SSRF for http(s) and LFI for file://. It is
353 + * force-reset to [] after this filter runs, so callbacks cannot widen
354 + * it; resolve trusted assets (e.g. a logo attachment) to a data: URI or
355 + * a validated local path server-side instead.
356 + *
357 + * @param array<string, mixed> $config mPDF configuration.
358 + * @param array<string, mixed> $donation Donation data.
359 + * @param array<string, mixed>|null $donor Donor data.
360 + * @since 1.5.0
361 + */
362 + $config = apply_filters( 'suredonation_receipt_mpdf_config', $config, $donation, $donor );
363 + $config = is_array( $config ) ? $config : [];
364 +
365 + // Enforced post-filter: an empty stream-wrapper whitelist is what gates
366 + // LFI/SSRF in mPDF, so it stays empty regardless of what filter
367 + // callbacks return.
368 + $config['whitelistStreamWrappers'] = [];
369 + $config['curlAllowUnsafeSslRequests'] = false;
370 +
371 + return $config;
150 372 }
151 373
152 374 /**
153 375 * Build the receipt HTML template.
@@ -161,19 +383,24 @@
161 383 private static function build_receipt_html( $donation, $donor, $campaign_title ) {
162 384 $site_name = esc_html( get_bloginfo( 'name' ) );
163 385 $site_url = esc_url( site_url() );
164 386
165 - $donation_id = Helper::get_integer_value( $donation['id'] ?? 0 );
166 - $donor_name = esc_html( Helper::get_string_value( $donor['name'] ?? '' ) );
167 - $donor_email = esc_html( Helper::get_string_value( $donor['email'] ?? '' ) );
168 - $payment_status = esc_html( ucfirst( Helper::get_string_value( $donation['payment_status'] ?? '' ) ) );
169 - $payment_method = esc_html( ucfirst( Helper::get_string_value( $donation['gateway'] ?? '' ) ) );
170 - $transaction_id = esc_html( Helper::get_string_value( $donation['transaction_id'] ?? '' ) );
171 - $currency = Helper::get_string_value( $donation['currency'] ?? 'USD' );
172 - $total = Helper::get_float_value( $donation['amount'] ?? 0 );
173 - $fees_covered = Helper::get_float_value( $donation['fees_covered'] ?? 0 );
174 - $amount = $total - $fees_covered;
175 - $date = Helper::get_string_value( $donation['created_at'] ?? '' );
387 + $donation_id = Helper::get_integer_value( $donation['id'] ?? 0 );
388 + // Prefer the name captured on this specific donation — it is the correct
389 + // identity for a tax receipt and is unaffected by the donor record being
390 + // set-once. Fall back to the donor record only when the donation itself
391 + // carries no name.
392 + $donation_donor_name = Helper::get_string_value( $donation['donor_name'] ?? '' );
393 + $donor_name = esc_html( '' !== $donation_donor_name ? $donation_donor_name : Helper::get_string_value( $donor['name'] ?? '' ) );
394 + $donor_email = esc_html( Helper::get_string_value( $donor['email'] ?? '' ) );
395 + $payment_status = esc_html( ucfirst( Helper::get_string_value( $donation['payment_status'] ?? '' ) ) );
396 + $payment_method = esc_html( ucfirst( Helper::get_string_value( $donation['gateway'] ?? '' ) ) );
397 + $transaction_id = esc_html( Helper::get_string_value( $donation['transaction_id'] ?? '' ) );
398 + $currency = Helper::get_string_value( $donation['currency'] ?? 'USD' );
399 + $total = Helper::get_float_value( $donation['amount'] ?? 0 );
400 + $fees_covered = Helper::get_float_value( $donation['fees_covered'] ?? 0 );
401 + $amount = $total - $fees_covered;
402 + $date = Helper::get_string_value( $donation['created_at'] ?? '' );
176 403
177 404 if ( ! empty( $date ) ) {
178 405 $date_format = Helper::get_string_value( get_option( 'date_format' ) );
179 406 $timestamp = strtotime( $date );
@@ -272,28 +499,23 @@
272 499 * @return string Formatted amount.
273 500 * @since 1.0.0
274 501 */
275 502 private static function format_currency( $amount, $currency = 'USD' ) {
276 - $symbols = [
277 - 'USD' => '$',
278 - 'EUR' => '€',
279 - 'GBP' => '£',
280 - 'CAD' => 'CA$',
281 - 'AUD' => 'A$',
282 - 'INR' => '₹',
283 - 'JPY' => '¥',
284 - ];
285 -
286 - $symbol = $symbols[ strtoupper( $currency ) ] ?? esc_html( $currency ) . ' ';
287 -
288 - return $symbol . number_format( $amount, 2 );
503 + // Delegate to the single source of truth so the currency symbol,
504 + // decimal handling and sign position match every other surface
505 + // (this replaces a divergent local symbol map).
506 + return Payment_Helper::format_amount( $amount, $currency );
289 507 }
290 508
291 509 /**
292 510 * Convert a relative path to an absolute file path.
293 511 *
512 + * Refuses any value that does not resolve to a plain file inside the
513 + * receipts directory, so a pointer that ever became attacker-influenced
514 + * cannot reach an arbitrary path through either consumer.
515 + *
294 516 * @param string $relative_path Relative path within the uploads directory.
295 - * @return string|false Absolute file path or false.
517 + * @return string|false Absolute file path inside the receipts directory, or false.
296 518 * @since 1.0.0
297 519 */
298 520 private static function relative_to_path( $relative_path ) {
299 521 if ( empty( $relative_path ) ) {
@@ -299,9 +521,188 @@
299 521 if ( empty( $relative_path ) ) {
300 522 return false;
301 523 }
302 524
303 - $upload_dir = wp_upload_dir();
525 + // Everything below is containment for a value this function does not
526 + // own. The column is written only by generate() today, and nothing
527 + // sanitizes it on the way into the database, so the stored string is
528 + // trusted purely because no write path currently exposes it. Both
529 + // consumers are destructive if that ever stops being true: this feeds
530 + // wp_delete_file() on every donation delete, and get_or_generate()
531 + // hands the resolved path to the donor-facing receipt download. Check
532 + // it here, once, rather than relying on every future caller.
533 + $normalized = wp_normalize_path( (string) $relative_path );
304 534
305 - return $upload_dir['basedir'] . '/' . $relative_path;
535 + // A null byte truncates the path inside the C filesystem calls.
536 + if ( false !== strpos( $normalized, "\0" ) ) {
537 + return false;
538 + }
539 +
540 + // A literal backslash, refused on the raw value before anything else
541 + // reads it. Normalization treats it as a separator, so the checks below
542 + // would measure a different path from the one this function returns, and
543 + // the realpath comparison would normalize it back again. No value the
544 + // generator has ever written contains one: the stored string is built
545 + // with '/' and sanitize_file_name() strips '\' from the filename.
546 + if ( false !== strpos( (string) $relative_path, '\\' ) ) {
547 + return false;
548 + }
549 +
550 + // Traversal, in any position. This does not stand alone: a segment such
551 + // as '.. ' is not matched here, and Windows strips trailing spaces
552 + // during path canonicalisation. The realpath() cross-check below is
553 + // what covers those, so do not remove it as redundant.
554 + if ( preg_match( '#(^|/)\.\.(/|$)#', $normalized ) ) {
555 + return false;
556 + }
557 +
558 + // Already absolute: a POSIX root, a Windows drive, or a stream wrapper
559 + // such as phar:// or http://. None can be a relative receipt path.
560 + if ( 0 === strpos( $normalized, '/' ) || preg_match( '#^[a-zA-Z]:/#', $normalized ) || preg_match( '#^[a-zA-Z][a-zA-Z0-9+.-]*://#', $normalized ) ) {
561 + return false;
562 + }
563 +
564 + // One wp_upload_dir() call feeds both sides. The upload_dir filter runs
565 + // on every call, so fetching twice lets a filter that varies its answer
566 + // desynchronise the candidate from the directory it is measured against.
567 + $upload_dir = wp_upload_dir();
568 + $base_dir = wp_normalize_path( $upload_dir['basedir'] );
569 + $receipts_dir = $base_dir . '/suredonation/receipts';
570 + $candidate = wp_normalize_path( $base_dir . '/' . $normalized );
571 +
572 + // Receipts live in exactly one directory. With traversal already
573 + // refused above, a prefix test is a containment test.
574 + if ( 0 !== strpos( $candidate, $receipts_dir . '/' ) ) {
575 + return false;
576 + }
577 +
578 + // Inside the directory is not enough: it also holds the .htaccess,
579 + // index.php and web.config that ensure_receipts_dir() writes to keep it
580 + // from being served. Resolving one of those would let a delete strip
581 + // the directory's protection and expose every donor receipt, which is a
582 + // worse outcome than the arbitrary delete this containment exists to
583 + // stop. Every name the generator can produce ends in .pdf.
584 + $basename = basename( $normalized );
585 + if ( '' === $basename || 0 === strpos( $basename, '.' ) || ! preg_match( '#\.pdf\z#i', $basename ) ) {
586 + return false;
587 + }
588 +
589 + // Built from the raw value, because this is what the function returns
590 + // and therefore what the callers act on. Keeping the returned string
591 + // byte-identical to the old behaviour matters (get_or_generate() hands
592 + // it back and it is compared), but the realpath check below has to
593 + // measure that same string: normalization turns a literal backslash
594 + // into a separator, so checking only the normalized form would leave a
595 + // symlink named with one unexamined.
596 + $filepath = $upload_dir['basedir'] . '/' . $relative_path;
597 +
598 + // A symlink can still point out of the directory, and realpath() is the
599 + // only thing that sees it. It resolves to false when the file is not
600 + // there yet, which is a normal state for both callers, so only an
601 + // existing file is cross-checked. Both sides are resolved so that a
602 + // symlinked uploads directory, which is common when media sits on
603 + // another volume, does not cause a false refusal.
604 + $real_path = realpath( $filepath );
605 + if ( false !== $real_path ) {
606 + $real_dir = realpath( $receipts_dir );
607 + if ( false === $real_dir || 0 !== strpos( wp_normalize_path( $real_path ), wp_normalize_path( $real_dir ) . '/' ) ) {
608 + return false;
609 + }
610 + }
611 +
612 + return $filepath;
613 + }
614 +
615 + /**
616 + * Resolve the filename the receipt is stored under on disk.
617 + *
618 + * Extracted so the normalisation rules below are testable without
619 + * rendering a PDF, which needs mPDF present.
620 + *
621 + * @param string $default_filename Generated opaque filename.
622 + * @param array<string, mixed> $donation Donation data.
623 + * @param array<string, mixed>|null $donor Donor data.
624 + * @return string Filename ending in exactly one .pdf extension.
625 + * @since 1.5.1
626 + */
627 + private static function resolve_storage_filename( $default_filename, $donation, $donor ) {
628 + /**
629 + * Filter the receipt PDF filename ON DISK.
630 + *
631 + * This is not the name anyone receives: donors get the file under
632 + * {@see self::get_download_filename()}, so the stored name deliberately
633 + * carries no donation id, no configured prefix and no donor data -- only
634 + * randomness. The receipts directory denies direct access through
635 + * .htaccess, but servers that ignore it (nginx, IIS) serve the file as a
636 + * static asset, which leaves this name as the only thing gating it. Treat
637 + * a filtered value as a capability token and keep it unguessable.
638 + *
639 + * The filtered value is passed through sanitize_file_name() -- which
640 + * strips path separators and collapses '..' -- and forced to a .pdf
641 + * extension; an empty result falls back to the default.
642 + *
643 + * @param string $default_filename Generated filename.
644 + * @param array<string, mixed> $donation Donation data.
645 + * @param array<string, mixed>|null $donor Donor data.
646 + * @since 1.5.0
647 + * @since 1.5.1 Names only the file on disk. To name the copy a donor
648 + * receives, use {@see 'suredonation_receipt_download_filename'}.
649 + */
650 + $filename = apply_filters( 'suredonation_receipt_filename', $default_filename, $donation, $donor );
651 +
652 + // This is the call that makes the value safe: it strips path separators
653 + // and null bytes and collapses '..', so everything after it works on a
654 + // bare filename. It also normalises before the two tests below, which is
655 + // what keeps a filter's intended stem — "receipt.pdf " still ends in
656 + // .pdf once trimmed, and so resolves to receipt.pdf rather than
657 + // receipt-pdf.pdf.
658 + //
659 + // There is a second sanitize_file_name() inside the else. Measured
660 + // against 35 filtered values, including traversal, null bytes and bare
661 + // extension words, it changes no outcome's safety — the single-extension
662 + // guarantee comes from this call plus the dot collapse plus the appended
663 + // .pdf. What it does change is the name: it is why a stem left as a bare
664 + // extension word comes back as unnamed-file-exe.pdf instead of exe.pdf.
665 + // It is kept as defence in depth on a name that lands in a
666 + // web-accessible directory; the tests pin both effects.
667 + $filename = sanitize_file_name( Helper::get_string_value( $filename ) );
668 +
669 + if ( '' === $filename ) {
670 + $filename = $default_filename;
671 + } else {
672 + // Force exactly one extension, and make it .pdf. Appending to the
673 + // filtered value produced a double extension — a filter returning
674 + // "x.php" landed on disk as "x.php.pdf", which sanitize_file_name()
675 + // does not underscore (it early-returns for a two-part name) and
676 + // which some Apache configurations still hand to the PHP handler on
677 + // the strength of the inner extension. Stripping only the last
678 + // segment is not enough either: "x.php.pdf" would survive intact.
679 + //
680 + // So a trailing .pdf is dropped first — that is the normal case, and
681 + // the currently-released Pro's filter returns exactly that shape —
682 + // then every remaining dot is removed and one .pdf added back. That
683 + // is safe for this value specifically: it names the file on disk
684 + // only, is
685 + // documented as a capability token rather than anything a donor
686 + // sees, and the default is already dot-free (sd-receipt-<hex>). The
687 + // donor-facing name comes from get_download_filename() and is
688 + // untouched by this.
689 + // Order matters, and getting it wrong is what the first attempt at
690 + // this did. sanitize_file_name() *re-inserts* an extension when the
691 + // name it is given has none — it runs
692 + // wp_check_filetype( 'test.' . $filename ), so a bare 'exe' comes
693 + // back as 'unnamed-file.exe'. Collapsing the dots first therefore
694 + // handed core a token it turned back into a two-part name, and
695 + // 'exe.pdf' landed as 'unnamed-file.exe.pdf': two extensions, from
696 + // the very code meant to guarantee one.
697 + //
698 + // So sanitize first, collapse whatever dots that leaves, and make
699 + // .pdf the genuinely last operation on a dot-free token.
700 + $filename = (string) preg_replace( '/\.pdf$/i', '', $filename );
701 + $filename = sanitize_file_name( $filename );
702 + $filename = str_replace( '.', '-', $filename );
703 + $filename = '' === $filename ? $default_filename : $filename . '.pdf';
704 + }
705 +
706 + return $filename;
306 707 }
307 708 }