| @@ -71,9 +71,25 @@ | ||
| 71 | 71 | |
| 72 | 72 | /** |
| 73 | 73 | * Ensure the receipts directory exists with security files. |
| 74 | 74 | * |
| 75 | - * @return bool True if directory exists or was created. | |
| 75 | + * The denial files only bind where the server reads them: .htaccess on | |
| 76 | + * Apache and LiteSpeed, web.config on IIS. nginx and Caddy read neither, so | |
| 77 | + * there the unguessable 128-bit filename is the only control — which is why | |
| 78 | + * the generator proceeds regardless of what this returns. A host that | |
| 79 | + * cannot write a denial file should not lose the ability to issue receipts; | |
| 80 | + * refusing would trade a real feature for a control those servers were | |
| 81 | + * never going to enforce anyway. | |
| 82 | + * | |
| 83 | + * There is no receipt download route in this plugin. Delivery — email | |
| 84 | + * attachment and authenticated streaming download alike — lives entirely | |
| 85 | + * in Pro; an earlier version of this note claimed a REST streaming | |
| 86 | + * endpoint here, and none exists. | |
| 87 | + * | |
| 88 | + * @return bool True when the directory exists *and* every denial file this | |
| 89 | + * method writes is present. Callers that need to know whether | |
| 90 | + * the directory is actually hardened can act on it; the | |
| 91 | + * generator deliberately does not, for the reason above. | |
| 76 | 92 | * @since 1.0.0 |
| 77 | 93 | */ |
| 78 | 94 | public static function ensure_receipts_dir() { |
| 79 | 95 | $dir = self::get_receipts_dir(); |
| @@ -81,8 +97,18 @@ | ||
| 81 | 97 | if ( ! file_exists( $dir ) ) { |
| 82 | 98 | wp_mkdir_p( $dir ); |
| 83 | 99 | } |
| 84 | 100 | |
| 101 | + // Bail before writing if the directory is not there. Every write below | |
| 102 | + // is unconditional on the directory existing, so on a host where | |
| 103 | + // wp_mkdir_p() fails — an unwritable uploads path, an open_basedir | |
| 104 | + // restriction — each one emitted a PHP warning into the log before | |
| 105 | + // failing anyway. Nothing downstream is worse off: the caller already | |
| 106 | + // treats a false return as "not hardened". | |
| 107 | + if ( ! is_dir( $dir ) ) { | |
| 108 | + return false; | |
| 109 | + } | |
| 110 | + | |
| 85 | 111 | // Add .htaccess to prevent direct access. |
| 86 | 112 | $htaccess = $dir . '/.htaccess'; |
| 87 | 113 | if ( ! file_exists( $htaccess ) ) { |
| 88 | 114 | file_put_contents( $htaccess, "deny from all\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents |
| @@ -93,9 +119,69 @@ | ||
| 93 | 119 | if ( ! file_exists( $index ) ) { |
| 94 | 120 | file_put_contents( $index, "<?php\n// Silence is golden.\n" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents |
| 95 | 121 | } |
| 96 | 122 | |
| 97 | - return is_dir( $dir ); | |
| 123 | + // IIS reads neither .htaccess nor index.php as an access rule, so it | |
| 124 | + // needs its own denial. Written unconditionally rather than detected: | |
| 125 | + // the file is inert on Apache/nginx/LiteSpeed, and detecting the server | |
| 126 | + // from $_SERVER['SERVER_SOFTWARE'] is unreliable behind a proxy. | |
| 127 | + // | |
| 128 | + // requestFiltering under system.webServer/security, matching SureMail's | |
| 129 | + // attachments directory — minus its <handlers><remove> block. That block | |
| 130 | + // is belt-and-braces on top of two controls already here, and IIS raises | |
| 131 | + // a configuration error when <remove> names a handler the site does not | |
| 132 | + // have (the names are host-specific — SureMail hardcodes | |
| 133 | + // 'php-7.4.33'). Shipping something that can itself 500 the directory | |
| 134 | + // is the exact failure this rewrite exists to remove, and it cannot be | |
| 135 | + // tested from here, so it is left out: hiddenSegments already refuses | |
| 136 | + // every request to the path, and fileExtensions already refuses | |
| 137 | + // executables. | |
| 138 | + // | |
| 139 | + // The first attempt used | |
| 140 | + // system.webServer/authorization, which is not a real section — the | |
| 141 | + // ASP.NET element misplaced. IIS answers 500.19 on an unrecognised | |
| 142 | + // section, so the directory ended up "protected" by a configuration | |
| 143 | + // error rather than by the control this claims, and would have opened | |
| 144 | + // the moment somebody fixed the 500. requestFiltering also needs no | |
| 145 | + // extra role service, where URL Authorization 500s unless it is | |
| 146 | + // installed. | |
| 147 | + $web_config = $dir . '/web.config'; | |
| 148 | + if ( ! file_exists( $web_config ) ) { | |
| 149 | + $config = '<?xml version="1.0" encoding="UTF-8"?>' . "\n" | |
| 150 | + . '<configuration>' . "\n" | |
| 151 | + . ' <system.webServer>' . "\n" | |
| 152 | + . ' <security>' . "\n" | |
| 153 | + . ' <requestFiltering>' . "\n" | |
| 154 | + . ' <hiddenSegments>' . "\n" | |
| 155 | + . ' <add segment="receipts" />' . "\n" | |
| 156 | + . ' </hiddenSegments>' . "\n" | |
| 157 | + . ' <fileExtensions allowUnlisted="true">' . "\n" | |
| 158 | + . ' <add fileExtension=".php" allowed="false" />' . "\n" | |
| 159 | + . ' <add fileExtension=".phtml" allowed="false" />' . "\n" | |
| 160 | + . ' <add fileExtension=".php3" allowed="false" />' . "\n" | |
| 161 | + . ' <add fileExtension=".php4" allowed="false" />' . "\n" | |
| 162 | + . ' <add fileExtension=".php5" allowed="false" />' . "\n" | |
| 163 | + . ' <add fileExtension=".php7" allowed="false" />' . "\n" | |
| 164 | + . ' <add fileExtension=".phar" allowed="false" />' . "\n" | |
| 165 | + . ' <add fileExtension=".phps" allowed="false" />' . "\n" | |
| 166 | + . ' <add fileExtension=".pht" allowed="false" />' . "\n" | |
| 167 | + . ' <add fileExtension=".phpt" allowed="false" />' . "\n" | |
| 168 | + . ' <add fileExtension=".inc" allowed="false" />' . "\n" | |
| 169 | + . ' </fileExtensions>' . "\n" | |
| 170 | + . ' </requestFiltering>' . "\n" | |
| 171 | + . ' </security>' . "\n" | |
| 172 | + . ' </system.webServer>' . "\n" | |
| 173 | + . '</configuration>' . "\n"; | |
| 174 | + file_put_contents( $web_config, $config ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents | |
| 175 | + } | |
| 176 | + | |
| 177 | + // The directory has to exist *and* be protected. Returning only | |
| 178 | + // is_dir() reported success on a directory whose hardening writes had | |
| 179 | + // silently failed — an open receipts directory holding donor names, | |
| 180 | + // addresses and amounts, with the caller told everything was fine. | |
| 181 | + // web.config included: it was left out, so on IIS — the one platform it | |
| 182 | + // exists for — a failed write still reported success. | |
| 183 | + return is_dir( $dir ) && file_exists( $htaccess ) && file_exists( $index ) && file_exists( $web_config ); | |
| 98 | 184 | } |
| 99 | 185 | |
| 100 | 186 | /** |
| 101 | 187 | * Get the temp directory for mPDF. |