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