# Kill Apache/LiteSpeed directory listings on the WordPress uploads tree. # # WHY (measured live 2026-10-02 on waterfilter.my.id): # /wp-content/uploads/ -> 200, Index of /wp-content/uploads/ # /wp-content/uploads/2025/ -> 200, Index of /wp-content/uploads/2025/ # /wp-content/uploads/2025/01/ -> 200, Index of /wp-content/uploads/2025/01/ # Server: LiteSpeed. # # Semrush counted 464 pages with "low text-HTML ratio" and 921 # "resources formatted as page links" on this domain. The listing pages ARE # those resources: a crawled, indexable, text-free HTML page full of file # links. They also expose every uploaded filename, which is a reconnaissance # gift and was the actual cause of the 921-error spike. # # HOW: two independent measures, because either one alone has a failure mode. # 1. .htaccess -> Options -Indexes stops the listing at the web server. # 2. index.html -> a real 200 with content, so even if .htaccess is ignored # (LiteSpeed per-directory config, nginx-style front end) the # URL stops being a thin text-free page. # The index.html must NOT be a redirect to the homepage -- that turns a # directory URL into a duplicate of / and creates a soft-404 loop. if (!defined('ABSPATH')) { exit; } if (!function_exists('wfh_apply_no_listing')) { /** * Do the actual work. * * v1.0 hung this on `admin_init`, which fires on neither a front-end request * nor a `wp eval` call. The file deployed, linted, matched md5, left every * page at 200 -- and fixed nothing. A green deploy that changes nothing is * the worst outcome an SEO fix can have, so the work now runs at load. */ function wfh_apply_no_listing() { // --- 1. .htaccess guard ----------------------------------------------- $ht = ABSPATH . '.htaccess'; if (file_exists($ht)) { $body = file_get_contents($ht); if ($body !== false && strpos($body, 'WFH_NO_INDEXES') === false) { $guard = "\n# WFH_NO_INDEXES -- stop directory listings on the uploads tree.\n" . "# These were crawled as text-free pages (Semrush: 464 low text-HTML\n" . "# ratio, 921 resources-as-page-links on waterfilter.my.id).\n" . "\n" . " Options -Indexes\n" . "\n" . "\n" . " Options -Indexes\n" . "\n"; file_put_contents($ht, $body . $guard, LOCK_EX); } } // --- 2. index.html across the uploads tree ---------------------------- $uploads = wp_upload_dir(); if (!empty($uploads['error']) || empty($uploads['basedir'])) { return; } $base = trailingslashit($uploads['basedir']); if (!is_dir($base)) { return; } // A listed URL must stop being a text-free page, so the file carries // real copy rather than being empty. It must NOT redirect to the // homepage: that turns a directory URL into a duplicate of "/". $html = "\n\n" . "403 Forbidden\n" . "\n" . "

403 Forbidden

\n" . "

Directory access is disabled.

\n"; $targets = [$base]; foreach ((array) glob($base . '*', GLOB_ONLYDIR) as $y) { $targets[] = $y; foreach ((array) glob($y . '/*', GLOB_ONLYDIR) as $m) { $targets[] = $m; } } foreach ($targets as $dir) { $f = rtrim($dir, '/') . '/index.html'; if (!file_exists($f)) { @file_put_contents($f, $html, LOCK_EX); } } } } // Run now, on every load. Both writes are existence-guarded, so steady-state // cost is one file read. wfh_apply_no_listing(); // Keep folders created later covered too. add_action('wp_insert_attachment', 'wfh_apply_no_listing');