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 +284 -28 1.5.0 → 1.6.1 View file →
@@ -48,9 +48,10 @@
48 48
49 49 if ( ! empty( $existing_relative ) ) {
50 50 $existing_path = self::relative_to_path( Helper::get_string_value( $existing_relative ) );
51 51
52 - 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 ) ) {
53 54 return $existing_path;
54 55 }
55 56 }
56 57
@@ -111,33 +112,12 @@
111 112 // Ensure receipts directory exists.
112 113 Pdf_Utils::ensure_receipts_dir();
113 114
114 115 $receipts_dir = Pdf_Utils::get_receipts_dir();
115 - $default_filename = sprintf( 'suredonation-receipt-%d-%s.pdf', $donation_id, wp_generate_password( 8, false ) );
116 + $default_filename = self::generate_storage_filename();
116 117
117 - /**
118 - * Filter the receipt PDF filename.
119 - *
120 - * The default filename carries a random suffix; the receipts directory
121 - * is access-protected, but the suffix keeps individual filenames
122 - * unguessable as defense in depth. The filtered value is passed through
123 - * sanitize_file_name() and forced to a .pdf extension; an empty result
124 - * falls back to the default.
125 - *
126 - * @param string $default_filename Generated filename.
127 - * @param array<string, mixed> $donation Donation data.
128 - * @param array<string, mixed>|null $donor Donor data.
129 - * @since 1.5.0
130 - */
131 - $filename = apply_filters( 'suredonation_receipt_filename', $default_filename, $donation, $donor );
132 - $filename = sanitize_file_name( Helper::get_string_value( $filename ) );
118 + $filename = self::resolve_storage_filename( $default_filename, $donation, $donor );
133 119
134 - if ( '' === $filename ) {
135 - $filename = $default_filename;
136 - } elseif ( 'pdf' !== strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) ) ) {
137 - $filename .= '.pdf';
138 - }
139 -
140 120 $filepath = $receipts_dir . '/' . $filename;
141 121
142 122 try {
143 123 $mpdf = new \Mpdf\Mpdf( self::get_mpdf_config( $donation, $donor ) );
@@ -180,8 +160,91 @@
180 160 return $filepath;
181 161 }
182 162
183 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 + /**
184 247 * Whether a receipt should be generated or served for a donation.
185 248 *
186 249 * @param array<string, mixed> $donation Donation data.
187 250 * @return bool
@@ -209,15 +272,25 @@
209 272 * Used by the personal-data eraser: the receipt is generated from the donor's
210 273 * name/email/address, so an erasure must remove the file from disk, not just
211 274 * the database columns.
212 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 + *
213 281 * @since 1.2.0
214 282 * @param string $relative_path Relative path within the uploads directory.
215 - * @return bool True when no file remains (deleted or never existed), false when it survived deletion.
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.
216 284 */
217 285 public static function delete_receipt( $relative_path ) {
218 286 $filepath = self::relative_to_path( $relative_path );
219 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.
220 293 if ( false === $filepath || ! file_exists( $filepath ) ) {
221 294 return true;
222 295 }
223 296
@@ -435,10 +508,14 @@
435 508
436 509 /**
437 510 * Convert a relative path to an absolute file path.
438 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 + *
439 516 * @param string $relative_path Relative path within the uploads directory.
440 - * @return string|false Absolute file path or false.
517 + * @return string|false Absolute file path inside the receipts directory, or false.
441 518 * @since 1.0.0
442 519 */
443 520 private static function relative_to_path( $relative_path ) {
444 521 if ( empty( $relative_path ) ) {
@@ -444,9 +521,188 @@
444 521 if ( empty( $relative_path ) ) {
445 522 return false;
446 523 }
447 524
448 - $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 );
449 534
450 - 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;
451 707 }
452 708 }