I wrote the subject line of the fix commit for this attachment folder as a single sentence, and that sentence already sums up the whole story: "the attachment directory was protected by an Apache error, not by its deny rule". The folder was closed from the start. It just was not closed by the rule I had written to close it.
The context is a form that can be submitted anonymously and can carry attachments. On that server, attachment uploads are the only anonymous write path, so I gathered the whole surface into a single PHP file with four rules: the file's content is checked against its extension, storage names are generated, the folder is denied through .htaccess, and a batch of uploads is accepted whole or not at all.
The attachment folder is created inside the WordPress uploads basedir, and the comment in the code gives the reason plainly: Apache serves this tree, so a deny rule is enforced. Attachments land in a directory of their own, denied by .htaccess and holding an index.html, so even a correct guess at a name should be refused by the web server. A directory that cannot be protected is treated as an error rather than something to fall back from, because storing an anonymous upload somewhere readable is worse than refusing the attachment. The file header adds that nothing is executable in that folder by design, with the deny rule as the belt and the generated name as the braces, and that the same server also hosts other clients.
The rules the code wrote to .htaccess
The .htaccess content is assembled as a PHP string. In the first version, the rules that string produced read like this:
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
</IfModule>
php_flag engine offThe first two blocks are the usual deny pattern: Require all denied for an Apache that loads mod_authz_core, and Order plus Deny from all for one that does not. The last line stands on its own with no guard at all. Under mod_php, php_flag engine off switches the PHP engine off for that folder, and lines like it are a staple of hardening advice for upload folders.
The catch is that php_flag is only legal under mod_php, and this host runs PHP over FastCGI. The php_flag and php_value directives are registered by the PHP module for Apache. When PHP runs as a separate FastCGI process, that module is usually not loaded, and the word php_flag means nothing to Apache. By default, an unknown directive in .htaccess is a configuration error. So Apache failed to parse the file and returned 500 for everything in the folder.
In the error log, this kind of failure usually reads Invalid command 'php_flag', perhaps misspelled or defined by a module not included in the server configuration. My notes do not keep this server's log, so I am giving that line as the general shape of the message, not as a quote from this case. When that message shows up for an .htaccess, the whole file is out of effect.
A 500 that reads as protection
From the outside, the result looked close to what I wanted. Everything in the directory returned 500, so no attachment could be fetched over the web, and that reads as a protected folder. In fact the protection was an accident, and the deny rule was never reached.
The trouble lies in the question being asked. If a check only asks whether a file can be downloaded, a 500 and a 403 both answer no, even though they mean very different things. A 403 status means the deny rule answered. A 500 status means Apache never finished reading the folder's configuration, and none of the rules in it are in effect.
My fix commit explains it with a sentence saying that when Apache aborts on a bad directive it never reaches the Require all denied below it either. One detail in that sentence does not match the file itself. In the first version of the string, php_flag was the last line, below both deny blocks. So it was not the order that defeated the deny. A single unknown directive makes Apache reject the whole .htaccess, including the rules written above it, and this case shows it on its own: the Require all denied block sat above php_flag, and the directory still returned 500. Moving a risky line to the bottom saves nothing.
Guarded per module, then a real 403
The fix is small. The standalone php_flag engine off line was removed, and the same directive was wrapped in three separate <IfModule> blocks, one per PHP module name:
- . "php_flag engine off\n";
+ . "<IfModule mod_php.c>\n php_flag engine off\n</IfModule>\n"
+ . "<IfModule mod_php7.c>\n php_flag engine off\n</IfModule>\n"
+ . "<IfModule mod_php8.c>\n php_flag engine off\n</IfModule>\n";Above that string there is now a comment containing the line php_flag has to be guarded. The contents of an <IfModule> block are only read when the named module is loaded. On this host mod_php is clearly not loaded, and that is not a guess: if it were, php_flag would be a legal directive and Apache would not have failed to parse the file. So all three blocks are now skipped, and php_flag engine off does nothing at all on this host. What closes the folder is its deny rule, plus a design that never put anything executable there in the first place.
I verified the result on production with a probe file in the directory. After the fix it returns 403 with the deny rule actually applied, where it returned 500 before.
One more part of the same file matters here. The .htaccess is deliberately written every time rather than once, because a restore or a sync that dropped it would silently expose everything stored in the folder. The check compares md5 hashes:
if (!file_exists($htaccess) || md5_file($htaccess) !== md5($rules)) {By the code's logic, once the contents of $rules change, the old file no longer passes the md5 comparison and the code writes it again the next time it runs. My notes do not record how the old file on the server was actually replaced, so I am leaving that as code logic, not as a recorded event.
A 2MB limit that rejects before the code runs
The attachment spec arrived later. In the 3 August revision round, that item was still deliberately left open with a note that file uploads had no spec. The spec then came from the client's reply: up to three files, 2MB, in PDF, Word, PNG or JPG, and the client dropped PowerPoint from the list I had proposed.
The host's own upload cap is also 2MB. max_bytes in the code is deliberately matched to the host's upload_max_filesize, because PHP discards a larger file before the module's code gets a chance to run. Writing a higher number in the code would not make the host accept a bigger file.
The slipperier one is post_max_size. A POST over it arrives with an empty $_POST, so the form's action never routes and the handler never runs at all. The comment in the form template describes what that looks like to the sender: a form that seems to do nothing. That is why the limits are stated on the form, and the same limits are enforced in main.js too, with this comment:
// The file rules are checked here as well as on the server because they
// cannot always be checked there: a POST over the host's post_max_size
// arrives with an empty $_POST, so the action never routes and the handler
// never runs. Blocking it before submit is the only way the sender gets
// told anything at all.Browser-side validation is usually just a convenience, because the server checks again anyway. For a submission over post_max_size, that server-side check never happens.
Look for the 403, not just a failed download
A check on a private folder should demand the exact status: a 403, from a deny rule that is actually in effect, which is what production finally showed. A 500 on a folder that should be denied is not a variant of safe. It is a sign that something failed before your rule got to answer.
For directives that only one module knows, such as php_flag and php_value, wrap them in <IfModule> before they go into an .htaccess, especially when the file is generated by code and might move to a host that runs PHP some other way. Line order cannot be relied on either, because an unknown directive takes down the whole file, including the rules above it.
The attachment folder was closed before the fix and after it. The difference is that after the fix, I know what is closing it.