Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- An `APP_URL` without a scheme (`APP_URL=pollora.test`, written by hand in `.env`) no longer breaks the site: every Artisan command failed with `Undefined array key "host"`, and `/wp-admin/` answered a 404 because WordPress built its URLs relative to the current path. The value is read as `http://pollora.test` for `WP_HOME`, `WP_SITEURL`, the cookie path and the console host (#518)

- The home page of a site served by Valet or Herd no longer redirects to itself. Both run the site through their own `server.php`, which includes `public/index.php`, so `SCRIPT_FILENAME` is not the front controller. Pollora took the request for a WordPress entry point and sent it to the home URL, in an endless loop. A front controller PHP has included now counts as Laravel serving the request
- `/cms/wp-admin/`, the address `pollora:install` prints for the admin, answered a 404 under Valet and Herd, which hand every URL that is not a file to the front controller. A request for a `public/` directory that holds an `index.php` is now sent to that file

### Added
- Site Health reports an error when the web server serves `.env`: MAMP, Apache or nginx pointed at the project folder instead of `public/` expose the application key and the database password, and answer the site with a 404. The check requests `/.env` and says to point the document root to `public/`
- `pollora:doctor` warns when `APP_URL` has no scheme, and gives the value to write

## [v13.38.0](https://github.com/Pollora/framework/compare/v13.37.1...v13.38.0) - 2026-10-21
Expand Down
66 changes: 66 additions & 0 deletions src/Doctor/Infrastructure/Checks/PublicRootCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

namespace Pollora\Doctor\Infrastructure\Checks;

use Illuminate\Support\Facades\Http;
use Pollora\Doctor\Domain\Contracts\CheckInterface;
use Pollora\Doctor\Domain\Enums\RunContext;
use Pollora\Doctor\Domain\Models\CheckResult;
use Throwable;

/**
* The web server's document root is public/, not the project folder.
*
* MAMP, Apache and nginx are often pointed at the project folder. The site
* then answers 404 and, worse, `/.env` is served as a text file with the
* application key and the database password. Valet, Herd and `artisan serve`
* always use public/, so only a manual setup can get this wrong.
*
* Only a web request can tell which folder the server exposes, so the check
* runs in Site Health. A server that cannot answer its own request (PHP's
* built-in server with a single worker) skips it rather than guess.
*/
final readonly class PublicRootCheck implements CheckInterface
{
public function id(): string
{
return 'public-root';
}

public function label(): string
{
return 'Document root';
}

public function runsIn(): array
{
return [RunContext::Http];
}

public function run(RunContext $context): CheckResult
{
$url = rtrim(trim((string) config('app.url')), '/');

if ($url === '') {
return CheckResult::skipped('APP_URL is not set.');
}

try {
$response = Http::timeout(3)->withoutRedirecting()->get($url.'/.env');
} catch (Throwable) {
return CheckResult::skipped('The site could not request itself.');
}

if ($response->ok() && str_contains($response->body(), 'APP_KEY=')) {
return CheckResult::error(
'.env is served over HTTP: the web server exposes the project folder, not public/.',
[sprintf('GET %s/.env answered 200 with the application settings', $url)],
'Point the document root to the public/ folder of the project (MAMP: Preferences › Server › Document root), then change APP_KEY and the passwords in .env',
);
}

return CheckResult::ok('.env is not served; the document root is public/.');
}
}
2 changes: 2 additions & 0 deletions src/Doctor/Infrastructure/Providers/DoctorServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Pollora\Doctor\Infrastructure\Checks\PatchesLockCheck;
use Pollora\Doctor\Infrastructure\Checks\PatternCacheCheck;
use Pollora\Doctor\Infrastructure\Checks\PatternFilesCheck;
use Pollora\Doctor\Infrastructure\Checks\PublicRootCheck;
use Pollora\Doctor\Infrastructure\Checks\SymlinkedDirectoryCheck;
use Pollora\Doctor\Infrastructure\Checks\TemplatePlaceholdersCheck;
use Pollora\Doctor\Infrastructure\Checks\WordPressCorePatchCheck;
Expand Down Expand Up @@ -51,6 +52,7 @@ class DoctorServiceProvider extends ServiceProvider
BlockThemeRoutesCheck::class,
LegacyBlocksDirectoryCheck::class,
BlockRegistrationCheck::class,
PublicRootCheck::class,
];

public function register(): void
Expand Down
10 changes: 9 additions & 1 deletion src/Route/UI/Http/Controllers/FrontendController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
namespace Pollora\Route\UI\Http\Controllers;

use Illuminate\Foundation\Exceptions\RegisterErrorViewPaths;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\View;
use Pollora\Route\Application\Services\AnsweringTemplate;
use Pollora\Route\Domain\Enums\TemplateOutcome;
use Pollora\Route\Domain\Models\TemplateResolution;
use Pollora\Route\UI\Http\Responses\PublicDirectoryIndexRedirect;
use Pollora\Route\UI\Http\Responses\SetupRequiredResponse;
use Pollora\View\Domain\Contracts\TemplateFinderInterface;

Expand Down Expand Up @@ -48,8 +50,14 @@ public function __construct(
/**
* Handle the request using WordPress template hierarchy.
*/
public function handle(Request $request): Response
public function handle(Request $request): Response|RedirectResponse
{
// A directory of public/ (the admin at /cms/wp-admin/) has an index.php
// a document-root server runs by itself; Valet and Herd do not
if (($redirect = PublicDirectoryIndexRedirect::for($request, public_path())) instanceof RedirectResponse) {
return $redirect;
}

// WordPress loads once the database is configured: until then there is
// no hierarchy to ask (Pollora/pollora#78)
if (! $this->wordPressIsLoaded()) {
Expand Down
49 changes: 49 additions & 0 deletions src/Route/UI/Http/Responses/PublicDirectoryIndexRedirect.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace Pollora\Route\UI\Http\Responses;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;

/**
* Send a request for a `public/` directory to the `index.php` inside it.
*
* A web server with a document root answers `/cms/wp-admin/` by running that
* directory's index.php. Valet and Herd hand every URL that is not a file to
* the front controller instead, so the request reached the WordPress fallback
* and answered 404 — on the very address `pollora:install` prints for the
* admin. The redirect reaches the same file explicitly, which every server
* serves.
*/
final class PublicDirectoryIndexRedirect
{
public static function for(Request $request, string $publicPath): ?RedirectResponse
{
if (! $request->isMethod('GET') && ! $request->isMethod('HEAD')) {
return null;
}

$path = rawurldecode($request->getPathInfo());

if ($path === '/' || ! str_ends_with($path, '/') || str_contains($path, '..')) {
return null;
}

$root = realpath($publicPath);
$directory = realpath($publicPath.$path);

if ($root === false || $directory === false || ! str_starts_with($directory, $root.DIRECTORY_SEPARATOR)) {
return null;
}

if (! is_file($directory.DIRECTORY_SEPARATOR.'index.php')) {
return null;
}

$query = $request->getQueryString();

return new RedirectResponse($request->getBaseUrl().$request->getPathInfo().'index.php'.($query !== null && $query !== '' ? '?'.$query : ''));
}
}
9 changes: 8 additions & 1 deletion src/WordPress/QueryTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,12 @@ abstract private function withWordPressErrorHandling(callable $callback): void;
* The test is which script PHP is running, not a list of filenames: any
* entry point WordPress grows later is covered without being named, and a
* project adding its own is too.
*
* A server may start another script that includes the front controller:
* Valet and Herd run their own `server.php`, which requires
* `public/index.php`, so `SCRIPT_FILENAME` is not the front controller
* there. The request is still Laravel's, and a front controller PHP has
* included proves it — WordPress's entry points never include it.
*/
private function laravelIsServingTheRequest(): bool
{
Expand All @@ -193,6 +199,7 @@ private function laravelIsServingTheRequest(): bool
return true;
}

return $running === $frontController;
return $running === $frontController
|| in_array($frontController, array_map(realpath(...), get_included_files()), true);
}
}
34 changes: 34 additions & 0 deletions tests/Feature/Doctor/InstallChecksTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Composer\InstalledVersions;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Facades\Http;
use Pollora\Application\Domain\Contracts\DebugDetectorInterface;
use Pollora\Discovery\Application\Services\DiscoveryManager;
use Pollora\Discovery\Domain\Contracts\DiscoveryLocationInterface;
Expand All @@ -15,6 +16,7 @@
use Pollora\Doctor\Infrastructure\Checks\DiscoveryCacheCheck;
use Pollora\Doctor\Infrastructure\Checks\EnvironmentFileCheck;
use Pollora\Doctor\Infrastructure\Checks\PatchesLockCheck;
use Pollora\Doctor\Infrastructure\Checks\PublicRootCheck;
use Pollora\Doctor\Infrastructure\Checks\WordPressCorePatchCheck;
use Spatie\StructureDiscoverer\Cache\DiscoverCacheDriver;

Expand Down Expand Up @@ -179,6 +181,38 @@ function writeLock(string $project, array $patches): void
});
});

describe('Document root', function (): void {
it('is an error when .env is served, the folder exposed being the project', function (): void {
config(['app.url' => 'http://pollora.test']);
Http::fake(['pollora.test/.env' => Http::response("APP_NAME=Pollora\nAPP_KEY=base64:abc\n")]);

$result = (new PublicRootCheck)->run(RunContext::Http);

expect($result->status->value)->toBe('error')
->and($result->fix)->toContain('public/');
});

it('passes when .env is not served, or when a catch-all page answers 200 without it', function (int $status, string $body): void {
config(['app.url' => 'http://pollora.test']);
Http::fake(['pollora.test/.env' => Http::response($body, $status)]);

expect((new PublicRootCheck)->run(RunContext::Http)->status->value)->toBe('ok');
})->with([[404, 'Not found'], [200, '<html>home</html>']]);

it('is skipped when the site cannot request itself', function (): void {
config(['app.url' => 'http://pollora.test']);
Http::fake(fn () => throw new RuntimeException('timeout'));

expect((new PublicRootCheck)->run(RunContext::Http)->status->value)->toBe('skipped');
});

it('is skipped without an APP_URL', function (): void {
config(['app.url' => '']);

expect((new PublicRootCheck)->run(RunContext::Http)->status->value)->toBe('skipped');
});
});

describe('Discovery cache', function (): void {
function discoveryCheck(bool $cacheEnabled, array $missing): DiscoveryCacheCheck
{
Expand Down
48 changes: 48 additions & 0 deletions tests/Unit/Route/PublicDirectoryIndexRedirectTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

use Illuminate\Http\Request;
use Pollora\Route\UI\Http\Responses\PublicDirectoryIndexRedirect;

beforeEach(function (): void {
$this->public = sys_get_temp_dir().'/pollora-public-'.bin2hex(random_bytes(6));
mkdir($this->public.'/cms/wp-admin', 0777, true);
mkdir($this->public.'/cms/empty', 0777, true);
touch($this->public.'/index.php');
touch($this->public.'/cms/wp-admin/index.php');
touch($this->public.'/cms/wp-login.php');
});

afterEach(function (): void {
exec('rm -rf '.escapeshellarg($this->public));
});

describe('PublicDirectoryIndexRedirect', function (): void {
it('sends a directory URL to the index.php inside it', function (): void {
$response = PublicDirectoryIndexRedirect::for(Request::create('/cms/wp-admin/'), $this->public);

expect($response?->getTargetUrl())->toBe('/cms/wp-admin/index.php');
});

it('keeps the query string', function (): void {
$response = PublicDirectoryIndexRedirect::for(Request::create('/cms/wp-admin/?page=x'), $this->public);

expect($response?->getTargetUrl())->toBe('/cms/wp-admin/index.php?page=x');
});

it('leaves alone what is not a directory with an index.php', function (string $uri): void {
expect(PublicDirectoryIndexRedirect::for(Request::create($uri), $this->public))->toBeNull();
})->with([
'directory without index' => '/cms/empty/',
'no trailing slash' => '/cms/wp-admin',
'a file' => '/cms/wp-login.php',
'the root' => '/',
'a WordPress page' => '/sample-page/',
'outside public' => '/../',
]);

it('only redirects reads', function (): void {
expect(PublicDirectoryIndexRedirect::for(Request::create('/cms/wp-admin/', 'POST'), $this->public))->toBeNull();
});
});
10 changes: 10 additions & 0 deletions tests/Unit/WordPress/LaravelServingRequestTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ public function publicPath(string $path = ''): string
expect(servingProbe()->serving())->toBeFalse();
});

it("says yes when a server script such as Valet's server.php includes the front controller", function (): void {
$server = $this->dir.'/server.php';
touch($server);
$_SERVER['SCRIPT_FILENAME'] = $server;

require $this->dir.'/public/index.php';

expect(servingProbe()->serving())->toBeTrue();
});

it('says yes when there is nothing to compare against', function (): void {
// Better to do the work than to skip something the request needs.
unset($_SERVER['SCRIPT_FILENAME']);
Expand Down
Loading