From a9af0d7ceb60f7be813786c69fa62bab74ad249d Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Fri, 2 Oct 2026 17:12:45 +0200 Subject: [PATCH] fix: answer 404 for WordPress content directories --- public/.htaccess | 11 ++++++++ tests/install/checks.php | 61 ++++++++++++++++++++++++++++++++++++++++ tests/install/run.php | 12 ++++++-- 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/public/.htaccess b/public/.htaccess index 26d32e45995..2c1fbe81a42 100755 --- a/public/.htaccess +++ b/public/.htaccess @@ -13,6 +13,17 @@ RewriteCond %{HTTP:x-xsrf-token} . RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + # WordPress Content Directories Are Not Pages... + # + # A directory, or the empty index.php WordPress ships in some of them + # ("Silence is golden"), answered a blank 200 or a 403: either way it told + # a visitor the directory exists. The front controller answers instead, + # with the site's own 404. Files in them (CSS, JS, images) are served as + # before. + RewriteCond %{REQUEST_FILENAME} -d [OR] + RewriteCond %{REQUEST_FILENAME} /index\.php$ + RewriteRule ^(cms/wp-content|content)(/|$) index.php [L] + # Redirect Trailing Slashes If Not A Folder... # # Only GET and HEAD: a redirected POST or OPTIONS is lost. The REST API is diff --git a/tests/install/checks.php b/tests/install/checks.php index c342d5dec3b..c72e371e34b 100644 --- a/tests/install/checks.php +++ b/tests/install/checks.php @@ -691,3 +691,64 @@ function checkViewPathPrecedence(): void : "the archive answered 200 with {$bytes} bytes — the theme root's index.php stub rendered"; }); } + +// ───────────────────────────────────────────────────────────────────────────── +// 1.9 — WordPress content directories answer 404 (Pollora/framework#294) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * A directory under the content paths is not a page. + * + * WordPress ships an empty index.php ("Silence is golden") in wp-content, + * wp-content/plugins and wp-content/themes. Apache served it directly: a blank + * 200, which told anyone probing that the directory exists. A directory without + * one answered 403, which said the same. The skeleton's .htaccess now sends + * both to the front controller, so the site's own 404 answers — and a file in + * those directories must still be served, or every plugin's CSS would break. + */ +function checkContentDirectories(): void +{ + section('1.9 — Content directories answer 404'); + + $directories = [ + '/cms/wp-content/', + '/cms/wp-content/index.php', + '/cms/wp-content/plugins/', + '/cms/wp-content/themes/', + '/content/', + '/content/plugins/', + '/content/uploads/', + ]; + + foreach ($directories as $path) { + test("{$path} answers the site's 404", function () use ($path) { + $response = http(siteUrl($path)); + + if ($response['status'] === 200 && trim($response['body']) === '') { + return "a blank 200 — WordPress's empty index.php was served directly"; + } + + return rendersHtml($response, 404); + }); + } + + test('A file in a content directory is still served', function () { + $response = http(siteUrl('/cms/wp-includes/css/dashicons.min.css')); + + if ($response['status'] !== 200) { + return "a core stylesheet answered {$response['status']}"; + } + + // The rule is scoped to the content directories; a stylesheet in one of + // them must not be caught either. + $plugin = wpEval('$p = glob(WP_PLUGIN_DIR . "/*/*.css") ?: glob(WP_PLUGIN_DIR . "/*/*/*.css") ?: glob(WP_PLUGIN_DIR . "/*/*/*/*.css"); echo $p ? plugins_url(basename($p[0]), $p[0]) : "";'); + + if ($plugin === '') { + return true; + } + + $status = http($plugin)['status']; + + return $status === 200 ? true : "a plugin stylesheet ({$plugin}) answered {$status}"; + }); +} diff --git a/tests/install/run.php b/tests/install/run.php index 2b5c69f49d1..12adec01400 100755 --- a/tests/install/run.php +++ b/tests/install/run.php @@ -19,7 +19,7 @@ * checks run the checks against the site as it stands, without * reinstalling; seeds install-test* fixtures, drops nothing. * Takes an optional group — rendering, rewrites, theme, - * updates or views — to run just that one + * updates, views or directories — to run just that one * * Every scenario but `checks` drops the database. POLLORA_INSTALL_TESTS=1 is * required to confirm the site is disposable. @@ -67,6 +67,7 @@ checkThemeResolution(); checkThemeUpdateGuard(); checkViewPathPrecedence(); + checkContentDirectories(); break; case 'no-theme': @@ -112,6 +113,7 @@ checkThemeResolution(); checkThemeUpdateGuard(); checkViewPathPrecedence(); + checkContentDirectories(); break; case 'checks': @@ -124,8 +126,8 @@ // per fix, and the full pass is far too slow for that. $only = $argv[2] ?? null; - if ($only !== null && ! in_array($only, ['rendering', 'rewrites', 'theme', 'updates', 'views'], true)) { - fwrite(STDERR, "\nUnknown group '{$only}': expected rendering, rewrites, theme, updates or views\n\n"); + if ($only !== null && ! in_array($only, ['rendering', 'rewrites', 'theme', 'updates', 'views', 'directories'], true)) { + fwrite(STDERR, "\nUnknown group '{$only}': expected rendering, rewrites, theme, updates, views or directories\n\n"); exit(2); } @@ -148,6 +150,10 @@ if ($only === null || $only === 'views') { checkViewPathPrecedence(); } + + if ($only === null || $only === 'directories') { + checkContentDirectories(); + } break; } } catch (\Throwable $e) {