From cf7a49d0c5055bda56abf7df18d29eaff7c4bfd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Dunglas?= Date: Fri, 30 Oct 2026 15:52:50 +0100 Subject: [PATCH] fix: return 404 when the script can't be opened Like php-fpm and php-cgi, FrankenPHP now opens the primary script before running it and answers 404 "No input file specified." (403 "Access denied." when it isn't readable) instead of compiling a missing file, which answered 200 with a PHP error. php_server relied on Caddy's first_exist_fallback never falling back to a missing index.php. Caddy fixed that policy in caddyserver/caddy@4ec5a5c, so without this change, php_server sites without an index.php answer unknown paths with a 200. --- frankenphp.c | 32 +++++++++++++++++++++++++++++++- frankenphp_test.go | 8 ++++++++ server_test.go | 2 +- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/frankenphp.c b/frankenphp.c index 4066845349..44a10f7d8f 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -1703,6 +1703,34 @@ static void set_thread_name(char *thread_name) { #endif } +/* Like php-fpm, answer a script that can't be opened instead of compiling it */ +static bool open_primary_script(zend_file_handle *file_handle) { + bool orig_display_errors = PG(display_errors); + PG(display_errors) = 0; + zend_result result = zend_stream_open(file_handle); + PG(display_errors) = orig_display_errors; + if (EXPECTED(result == SUCCESS)) { + return true; + } + + int open_errno = errno; + zend_try { + if (open_errno == EACCES) { + SG(sapi_headers).http_response_code = 403; + PUTS("Access denied.\n"); + } else { + SG(sapi_headers).http_response_code = 404; + PUTS("No input file specified.\n"); + } + } + zend_catch {} + zend_end_try(); + + /* a worker whose script disappeared must count as failing */ + EG(exit_status) = 255; + return false; +} + static inline void reset_sandboxed_environment() { if (sandboxed_env != NULL) { zend_hash_release(sandboxed_env); @@ -1790,7 +1818,9 @@ static void *php_thread(void *arg) { EG(exit_status) = 0; /* Execute the PHP script, potential bailout to zend_catch */ - php_execute_script(&file_handle); + if (EXPECTED(open_primary_script(&file_handle))) { + php_execute_script(&file_handle); + } #ifndef PHP_WIN32 if (UNEXPECTED(is_forked_child)) { _exit(EG(exit_status)); diff --git a/frankenphp_test.go b/frankenphp_test.go index 84199ba81d..717a715a3e 100644 --- a/frankenphp_test.go +++ b/frankenphp_test.go @@ -171,6 +171,14 @@ func testHelloWorld(t *testing.T, opts *testOptions) { }, opts) } +func TestMissingScript(t *testing.T) { + runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) { + body, resp := testGet("http://example.com/missing.php", handler, t) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Equal(t, "No input file specified.\n", body) + }, nil) +} + func TestEnvVarsInPhpIni(t *testing.T) { runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, _ int) { body, _ := testGet("http://example.com/ini.php?key=opcache.enable", handler, t) diff --git a/server_test.go b/server_test.go index 3ad2032bbe..5b40834ef0 100644 --- a/server_test.go +++ b/server_test.go @@ -126,7 +126,7 @@ func TestServer(t *testing.T) { assert.Equal(t, "requests:1", body3, "could not access the worker by matcher on server 2") assert.Equal(t, "requests:2", body4, "could not access the worker by matcher on server 2") assert.Contains(t, body5, "I am by birth a Genevese (i not set)", "could not access the worker by path on server 2") - assert.Contains(t, body6, "Failed opening required", "worker is scoped to server 1, so should not be found on server 2") + assert.Equal(t, "No input file specified.\n", body6, "worker is scoped to server 1, so should not be found on server 2") }) t.Run("worker_env_inheritance", func(t *testing.T) {