PHP File Upload Security: Block .htaccess Execution

PHP file upload security depends on a boundary that treats every filename, MIME label, and byte as untrusted. The safest design stores uploads outside the web root, assigns server-generated names, allows only the file types the product needs, and serves accepted files through a controlled download path. For Apache deployments, the upload directory must not be able to change handler rules through an uploaded .htaccess file.

This guide focuses on defensive configuration and validation. It replaces an older version that mixed server-specific assumptions with unverified incident claims and unsafe test payloads. The controls below follow the OWASP File Upload Cheat Sheet, the PHP upload documentation, and Apache’s own configuration guidance.

On this page
  1. The .htaccess risk depends on Apache configuration
  2. Build the storage boundary before checking file contents
  3. Validate the PHP upload on the server
  4. Keep Apache from executing uploaded content
  5. Test failure paths with harmless fixtures
  6. Know what the checks do not prove
  7. Deployment checklist

The .htaccess risk depends on Apache configuration#

An .htaccess file is a per-directory Apache configuration file. Apache documents that the directives accepted from these files are controlled by AllowOverride and AllowOverrideList. The default for AllowOverride is None, but hosting stacks and application templates can enable broader override classes. Apache also recommends using the main server configuration instead of .htaccess when the operator controls that configuration, for both security and performance reasons. See the Apache .htaccess tutorial.

The dangerous condition is therefore specific: an attacker can place a configuration file in a web-accessible upload directory, and Apache is configured to honor directives there that alter handlers or access rules. Blocking only filenames ending in .php does not address that condition. A configuration file can affect how another extension is handled, and an extension blocklist can miss aliases enabled by a particular server stack.

Do not put the primary defense inside the writable upload directory. An uploaded file must not be able to replace or countermand the rule intended to contain it. Enforce AllowOverride None for the upload location in the main Apache configuration, or avoid the condition entirely by storing files outside the document root.

Build the storage boundary before checking file contents#

OWASP recommends defense in depth rather than one magic-file test. Start with an allowlist tied to a real product requirement. An avatar service may need JPEG and PNG; it does not need every format a browser can display. Decode the submitted filename before checking its extension, reject ambiguous names, cap both filename length and upload size, and replace the client filename with an application-generated identifier.

Storage placement matters more than a clever regular expression. OWASP’s preferred order is a separate host, then a location outside the web root, then a tightly controlled web-root directory only when the first two choices are unavailable. For public retrieval, map an application identifier to the stored object instead of exposing the storage path. That indirection also gives the application one place to apply authorization, response headers, rate limits, and deletion rules.

The same boundary should cover generated previews and extracted files. A safe original upload can still trigger risk in a parser or converter. Keep those libraries current, apply resource limits, and run high-risk processing with minimal access. Broader design guidance belongs with your other secure coding patterns, not in an upload-specific blocklist.

Validate the PHP upload on the server#

PHP’s upload documentation warns that the browser-provided MIME type in $_FILES is not checked by PHP and must not be trusted. The client-side MAX_FILE_SIZE field is also only a convenience. Enforce limits in server configuration and application code, inspect the temporary file on the server, and check the upload error before moving anything. The relevant PHP references are POST method uploads and finfo_file().

This bounded example accepts only small JPEG and PNG uploads. It deliberately ignores the original filename and returns only the generated storage name. The caller must supply a storage directory outside the web root and must create it with least-privilege ownership before this code runs.

<?php
function storeImageUpload(array $file, string $storageDir): string
{
    if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
        throw new RuntimeException('The upload did not complete.');
    }

    $temporary = $file['tmp_name'] ?? '';
    if (!is_string($temporary) || !is_uploaded_file($temporary)) {
        throw new RuntimeException('The temporary file is not an HTTP upload.');
    }

    $size = $file['size'] ?? 0;
    if (!is_int($size) || $size < 1 || $size > 5 * 1024 * 1024) {
        throw new RuntimeException('The file size is outside the accepted range.');
    }

    $mime = (new finfo(FILEINFO_MIME_TYPE))->file($temporary);
    $extensions = [
        'image/jpeg' => 'jpg',
        'image/png' => 'png',
    ];
    if (!is_string($mime) || !isset($extensions[$mime])) {
        throw new RuntimeException('The detected file type is not accepted.');
    }

    $storedName = bin2hex(random_bytes(32)) . '.' . $extensions[$mime];
    $destination = rtrim($storageDir, DIRECTORY_SEPARATOR)
        . DIRECTORY_SEPARATOR . $storedName;

    if (!move_uploaded_file($temporary, $destination)) {
        throw new RuntimeException('The validated upload could not be stored.');
    }

    return $storedName;
}

finfo_file() is useful for detecting a file’s media type, but MIME detection does not establish that content is harmless. For image-only workflows, decode and re-encode with a maintained library under resource limits. Use format-specific inspection for other formats, and do not send private files to a public scanner without an explicit privacy decision.

Keep Apache from executing uploaded content#

The cleanest rule is architectural: the web server cannot execute what it cannot reach as application code. Keep uploads outside the document root and let a download handler read only the object selected by an internal identifier. Enforce authorization before reading the object, and return it only through the response behavior the application has explicitly chosen.

If deployment constraints force uploads under the document root, enforce the boundary in the main Apache configuration. Disable per-directory overrides for that location and deny requests for server-side script extensions independently of application validation. Exact handler rules depend on whether the deployment uses mod_php, CGI, FastCGI, or a proxy, so copy-pasting a single .htaccess fragment is not a portable fix. Verify the active virtual-host configuration and loaded modules. Apache’s guidance makes the trust direction clear: centrally controlled configuration should constrain writable content, not the reverse.

Never rely on a defensive .htaccess file placed inside the same writable directory. That file shares the attacker’s write boundary. The application should also reject reserved configuration names such as .htaccess, but filename rejection remains a secondary control after storage and server separation.

Test failure paths with harmless fixtures#

Security tests do not need a web shell or a live production target. Exercise the upload handler in an isolated test environment with inert files and assert the outcome at each boundary:

  • A small valid JPEG or PNG is accepted, renamed, and stored outside the public tree.
  • A text fixture renamed with an image extension is rejected after server-side type detection.
  • A harmless file with a double extension and a file named .htaccess are rejected by filename policy.
  • An oversized fixture is rejected before expensive parsing.
  • Two files with the same client filename receive distinct generated names.
  • A direct HTTP request to the private storage location cannot retrieve or execute the object.
  • The controlled download route returns only authorized objects with the intended response headers.

Also test parser failures, disk-full behavior, permission errors, and interrupted moves. A response that says “upload successful” must not be returned when storage failed. Keep logs free of uploaded contents and client filesystem paths. For related review habits, see practical secure coding techniques.

Know what the checks do not prove#

An allowed extension, matching MIME value, or valid image decode does not prove that a file is safe for every downstream consumer. Random names do not replace authorization. Storing files outside the web root prevents direct script execution through the normal web path, but background processors still need isolation.

Server configuration also varies. Apache controls do not apply to Nginx, object storage, or a CDN in the same way. Review the complete delivery path, including thumbnail workers, backup tooling, administrative previews, and any service that extracts metadata. Record the accepted formats and threat model so later feature work does not quietly widen the boundary.

Deployment checklist#

  1. Define the smallest set of formats the feature must accept.
  2. Store uploads outside the web root or on a separate static host.
  3. Generate storage names on the server; never use the client path as a destination.
  4. Enforce authentication, authorization, request-size, file-size, and rate limits.
  5. Check PHP upload errors, verify the temporary upload, and inspect content server-side.
  6. Disable Apache overrides for writable upload locations in centrally managed configuration.
  7. Apply format-specific parsing, resource limits, and malware controls where needed.
  8. Serve files through a controlled mapping with safe response headers.
  9. Test rejected inputs and infrastructure failures with harmless fixtures.
  10. Monitor unexpected filenames, repeated failures, and changes to storage permissions.

The important decision is where trust changes. Client metadata is a hint, application validation is one layer, and the web-server/storage boundary is the backstop. When each layer fails closed, an uploaded configuration file cannot redefine how the server treats the rest of the directory.