frankenphp icon indicating copy to clipboard operation
frankenphp copied to clipboard

Doesn't return 404 on nonexisting files

Open filips123 opened this issue 4 months ago • 12 comments

What happened?

If you have an index.php file in the website root, any request to nonexisting files will be executed by index.php and return 200. This will result in incorrect responses serving the actual website content on nonexisting paths with the incorrect status code (200) instead of 404.

If this is the intended behaviour, is it possible to disable it?

Build Type

Docker (Debian Trixie)

Worker Mode

No

Operating System

GNU/Linux

CPU Architecture

x86_64

PHP configuration

System 	Linux 2e987d8e2f6e 6.12.43+deb13-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.43-1 (2025-08-27) x86_64
Build Date 	Aug 28 2025 18:15:48
Build System 	Linux - Docker
Build Provider 	https://github.com/docker-library/php
Configure Command 	'./configure' '--build=x86_64-linux-gnu' '--with-config-file-path=/usr/local/etc/php' '--with-config-file-scan-dir=/usr/local/etc/php/conf.d' '--enable-option-checking=fatal' '--with-mhash' '--with-pic' '--enable-mbstring' '--enable-mysqlnd' '--with-password-argon2' '--with-sodium=shared' '--with-pdo-sqlite=/usr' '--with-sqlite3=/usr' '--with-curl' '--with-iconv' '--with-openssl' '--with-readline' '--with-zlib' '--enable-phpdbg' '--enable-phpdbg-readline' '--with-pear' '--with-libdir=lib/x86_64-linux-gnu' '--enable-embed' '--enable-zts' '--disable-zend-signals' 'build_alias=x86_64-linux-gnu' 'PHP_UNAME=Linux - Docker' 'PHP_BUILD_PROVIDER=https://github.com/docker-library/php'
Server API 	FrankenPHP
Virtual Directory Support 	enabled
Configuration File (php.ini) Path 	/usr/local/etc/php
Loaded Configuration File 	(none)
Scan this dir for additional .ini files 	/usr/local/etc/php/conf.d
Additional .ini files parsed 	/usr/local/etc/php/conf.d/docker-php-ext-opcache.ini, /usr/local/etc/php/conf.d/docker-php-ext-sodium.ini
PHP API 	20240924
PHP Extension 	20240924
Zend Extension 	420240924
Zend Extension Build 	API420240924,TS
PHP Extension Build 	API20240924,TS
PHP Integer Size 	64 bits
Debug Build 	no
Thread Safety 	enabled
Thread API 	POSIX Threads
Zend Signal Handling 	disabled
Zend Memory Manager 	enabled
Zend Multibyte Support 	provided by mbstring
Zend Max Execution Timers 	enabled
IPv6 Support 	enabled
DTrace Support 	disabled
Registered PHP Streams	https, ftps, compress.zlib, php, file, glob, data, http, ftp, phar
Registered Stream Socket Transports	tcp, udp, unix, udg, ssl, tls, tlsv1.0, tlsv1.1, tlsv1.2, tlsv1.3
Registered Stream Filters	zlib.*, convert.iconv.*, string.rot13, string.toupper, string.tolower, convert.*, consumed, dechunk

Relevant log output

For example, when the browser tries to request /favicon.ico that doesn't exist, index.php will be executed and return the main website content as HTML:

caddy-1  | {"level":"info","ts":1757529977.5878327,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"1.2.3.4","remote_port":"50513","client_ip":"1.2.3.4","proto":"HTTP/2.0","method":"GET","host":"example.com","uri":"/favicon.ico","headers":{"Sec-Fetch-Dest":["image"],"Sec-Fetch-Site":["same-origin"],"Cache-Control":["no-cache"],"Accept":["image/avif,image/jxl,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5"],"Pragma":["no-cache"],"User-Agent":["Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0"],"Priority":["u=6"],"Accept-Encoding":["gzip, deflate, br, zstd"],"Sec-Fetch-Mode":["no-cors"],"Te":["trailers"],"Accept-Language":["sl,en-GB;q=0.8,en-US;q=0.5,en;q=0.3"],"Referer":["https://example.com/test"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"h2","server_name":"example.com"}},"bytes_read":0,"user_id":"","duration":0.003590894,"size":80152,"status":200,"resp_headers":{"Server":["Caddy"],"Alt-Svc":["h3=\":443\"; ma=2592000"],"X-Powered-By":["PHP/8.4.12"],"Content-Type":["text/html; charset=UTF-8"]}}

filips123 avatar Sep 10 '25 18:09 filips123

This is by design. Your index.php should know which routes exist and which ones do not. Thus if you receive a request for a route that does not exist (likely because a file didn't exist), your application should return a 404 error.

If you just return your main website for any url, then if anyone posts a link to your website with a fake url http://example.com and http://example.com/not-the-homepage return the same content, Google will ding you for duplicate content issues as well.

withinboredom avatar Sep 11 '25 08:09 withinboredom

Is it possible to disable this? This doesn't happen with other PHP webservers by default, and it is annoying having to set up additional URL handling for simple file-based websites.

filips123 avatar Sep 11 '25 08:09 filips123

You can modify the configuration however you would like. The default is configured for modern apps using Symfony/Laravel/WordPress which use a router-based system (the inverse of other servers where you have to configure it to use a router-based approach). If you want to use file-based systems, you would have to modify it.

I’m not sure what that would look like, but it is certainly possible.

withinboredom avatar Sep 11 '25 08:09 withinboredom

If I understand that config correctly, just removing the @indexFiles part and setting index.php in the Caddy's file_server instead should be enough, right?

Maybe this could be added to the documentation?

filips123 avatar Sep 11 '25 09:09 filips123

If you don’t mind sharing the configuration after you find one that works, I will be happy to add it to the documentation, or a PR from yourself is welcome as well — it is just Markdown in the docs folder.

withinboredom avatar Sep 11 '25 09:09 withinboredom

I think it would look something like this?

app.example.com {
    root /path/to/app/public
    route {
	# Add trailing slash for directory requests
	@canonicalPath {
		file {path}/index.php
		not path */
	}
	redir @canonicalPath {path}/ 308
	# FrankenPHP!
	@phpFiles path *.php
	php @phpFiles
	file_server
    }
}

Though that might mean you have to specify index.php, which may not be what you want? Dunno.

withinboredom avatar Sep 11 '25 09:09 withinboredom

This configuration seems to work fine:

	route {
		# Add trailing slash for directory requests
		@canonicalPath {
			file {path}/index.php
			not path */
		}
		redir @canonicalPath {path}/ 308

		# Strip index files when directly accessed
		@stripIndex {
			path_regexp path ^(.*?)/index\.(php|html)$
  			file
		}
		redir @stripIndex {re.path.1}/ 308

		# Handle PHP and HTML index files
		try_files {path} {path}/index.php {path}/index.html =404

		# FrankenPHP!
		@phpFiles path *.php
		php @phpFiles
		file_server
	}

Edit: @canonicalPath doesn't work properly yet when the directory contains index.html...

I've added an additional @stripIndex rule so it redirects /something/index.php to /something/ to avoid duplicated content. This isn't in the default php_server config, but maybe it would be useful to add it?

The main thing was to add a custom try_files that tries index files first and then returns 404. Without that, index.php won't be served by default. I've also tried setting the index option of Caddy's file_server instead, but that returned the content without the HTML mime type, and I've found the try_files-based approach here.

Also, Caddy's php_fastcgi supports setting try_files that overrides its own handler logic. However, this option doesn't exist for file_server. There is worker.match, but that seems specific to the worker mode. Would it be possible to also add such option to file_server to disable split_path and configure try_files? So the config could be quite shorter without having to handle @canonicalPath and other stuff separately.

filips123 avatar Sep 11 '25 10:09 filips123

I didn't manage to make @canonicalPath work with both index.php and index.html. Appearently adding / to directory requests should normally be handled by Caddy, but try_files disables this.

So, I tried to remove that custom try_files and then add index to file_server:

		file_server {
			index index.php index.html index.txt
		}

This restores the / redirection, but it doesn't actually execute PHP when requested directly. It just returns the source PHP file without executing it and without HTML mime type. Is it possible to make Caddy also execute PHP properly on such requests?

filips123 avatar Sep 11 '25 11:09 filips123

As mentioned above, having index.php in file_server doesn't work, and I don't know why. First problem is probably that @phpFiles doesn't match the URL without .php. I've tried to add another rule that should match and execute index.php when it exists:

	@phpIndex {
		path */
		file {path}/index.php
	}
	php @phpIndex

However, when requesting such paths, I get this PHP error:

Warning: Unknown: Failed to open stream: Success in Unknown on line 0

Fatal error: Failed opening required '/sites/example' (include_path='.:/usr/local/lib/php') in Unknown on line 0

After some trial-and-error, I managed to create the following config that seems to work:

	# Add trailing slash for directory requests
	# Handled internally by Caddy for HTML indexes
	@canonicalPath {
		file {path}/index.php
		not path */
	}
	redir @canonicalPath {path}/ 308

	# Strip index files when directly accessed
	# This rule is not included in `php_server` by default, but it might also be useful there
	@stripIndex {
		path_regexp path ^(.*?)/index\.(php|html|txt)$
		file
	}
	redir @stripIndex {re.path.1}/ 308

	# Load index on directory request if it exists
	@hasIndex file {path}/index.php
	rewrite @hasIndex {path}/index.php

	@phpFiles {
		path *.php
		file
	}
	php @phpFiles

	file_server

I had to add a rewrite to serve directories as index.php. However, because of that rewrite, Caddy's canonical URLs don't apply to such paths, so manual @canonicalPath rule is still needed.

This makes the config quite long compared to just the default php_server (even excluding my additional @stripIndex rule), and having to manually duplicate Caddy's logic for canonical paths doesn't seem so nice. So, is there any way to make the config shorter? If serving index.php could be done without rewrite, @canonicalPath could probably be removed as Caddy would internally handle it. Or as another option in the file_server to switch to basic file-based handling?

filips123 avatar Sep 11 '25 12:09 filips123

TBH, when I first read this issue, I was like "that sounds pretty simple" and this looks like the opposite of simple. We have a php_server directive, but maybe we should have a php_file_server directive for old-style systems?

withinboredom avatar Sep 11 '25 15:09 withinboredom

Yeah, such option would be quite nice. Although most modern PHP websites probably use router-based approach, there are still quite a lot of file-based websites, especially if you have a mostly static website and use PHP for some smaller dynamic enhancements.

filips123 avatar Sep 11 '25 20:09 filips123

Hah, I ran into the same thing a few months ago when I moved a legacy app from apache. I ended up with a slightly simpler solution that covered my use case well enough, where I just handled the directories in which the index files could exist separately from the main index.php.

henderkes avatar Sep 12 '25 02:09 henderkes