fix: set $_SERVER variables: 'SCRIPT_NAME', 'PHP_SELF', and 'PATH_INFO' (#2317)

fixes
https://github.com/php/frankenphp/issues/2274#issuecomment-4142767490
closes https://github.com/php/frankenphp/issues/1133

apache/nginx/caddy pass PHP_SELF as SCRIPT_NAME + PATH_INFO, but our
PATH_INFO wasn't working because our matcher stripped the rest of the
path.

request url: localhost/index.php/en
```
# was non-worker:
SCRIPT_NAME: /index.php
PATH_INFO: 
PHP_SELF: /index.php
REQUEST_URL: /en

# was fastcgi:
SCRIPT_NAME: /index.php
PATH_INFO:  /en
PHP_SELF: /index.php/en
REQUEST_URL: /en

# was php_server worker
SCRIPT_NAME:
PATH_INFO:
PHP_SELF: /en
REQUEST_URL: /en

# now is always:
SCRIPT_NAME: /index.php
PATH_INFO: /en
PHP_SELF: /index.php/en
REQUEST_URL: /en
```

---------

Signed-off-by: Marc <m@pyc.ac>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Marc
2026-04-20 17:42:04 +07:00
committed by GitHub
parent d5491f1769
commit 2da08d635f
7 changed files with 245 additions and 24 deletions
+2
View File
@@ -1,6 +1,8 @@
/caddy/frankenphp/Build
/caddy/frankenphp/Caddyfile.test
/caddy/frankenphp/frankenphp
/caddy/frankenphp/frankenphp.exe
/caddy/frankenphp/public
/dist
/github_conf
/internal/testserver/testserver
+189
View File
@@ -486,6 +486,195 @@ func TestPHPServerDirectiveDisableFileServer(t *testing.T) {
tester.AssertGetResponse("http://localhost:"+testPort+"/not-found.txt", http.StatusOK, "I am by birth a Genevese (i not set)")
}
func TestPHPServerGlobals(t *testing.T) {
documentRoot, _ := filepath.Abs("../testdata")
scriptFilename := filepath.Join(documentRoot, "server-globals.php")
tester := caddytest.NewTester(t)
initServer(t, tester, `
{
skip_install_trust
admin localhost:2999
http_port `+testPort+`
https_port 9443
}
localhost:`+testPort+` {
root ../testdata
php_server {
index server-globals.php
}
}
`, "caddyfile")
// Request to /en: no matching file, falls through to server-globals.php worker
// SCRIPT_NAME should be /server-globals.php, PHP_SELF should be /server-globals.php (no /en), PATH_INFO empty
tester.AssertGetResponse(
"http://localhost:"+testPort+"/en",
http.StatusOK,
fmt.Sprintf(`SCRIPT_NAME: /server-globals.php
SCRIPT_FILENAME: %s
PHP_SELF: /server-globals.php
PATH_INFO:
DOCUMENT_ROOT: %s
DOCUMENT_URI: /server-globals.php
REQUEST_URI: /en
`, scriptFilename, documentRoot),
)
// Request to /server-globals.php/en: explicit PHP file with path info
// SCRIPT_NAME should be /server-globals.php, PHP_SELF should be /server-globals.php/en, PATH_INFO should be /en
tester.AssertGetResponse(
"http://localhost:"+testPort+"/server-globals.php/en",
http.StatusOK,
fmt.Sprintf(`SCRIPT_NAME: /server-globals.php
SCRIPT_FILENAME: %s
PHP_SELF: /server-globals.php/en
PATH_INFO: /en
DOCUMENT_ROOT: %s
DOCUMENT_URI: /server-globals.php
REQUEST_URI: /server-globals.php/en
`, scriptFilename, documentRoot),
)
}
func TestWorkerPHPServerGlobals(t *testing.T) {
documentRoot, _ := filepath.Abs("../testdata")
documentRoot2, _ := filepath.Abs("../caddy")
scriptFilename := documentRoot + string(filepath.Separator) + "server-globals.php"
testPortNum, _ := strconv.Atoi(testPort)
testPortTwo := strconv.Itoa(testPortNum + 1)
testPortThree := strconv.Itoa(testPortNum + 2)
tester := caddytest.NewTester(t)
initServer(t, tester, `
{
skip_install_trust
admin localhost:2999
frankenphp {
worker {
file ../testdata/server-globals.php
num 1
}
}
}
http://localhost:`+testPort+` {
php_server {
root ../testdata
index server-globals.php
}
}
http://localhost:`+testPortTwo+` {
php_server {
root ../testdata
index server-globals.php
worker {
file server-globals.php
num 1
}
}
}
http://localhost:`+testPortThree+` {
php_server {
root ./
index server-globals.php
worker {
file ../testdata/server-globals.php
num 1
match *
}
}
}
`, "caddyfile")
// === Site 1: global worker with php_server ===
// because we don't specify a php file, PATH_INFO should be empty
tester.AssertGetResponse(
"http://localhost:"+testPort+"/en",
http.StatusOK,
fmt.Sprintf(`SCRIPT_NAME: /server-globals.php
SCRIPT_FILENAME: %s
PHP_SELF: /server-globals.php
PATH_INFO:
DOCUMENT_ROOT: %s
DOCUMENT_URI: /server-globals.php
REQUEST_URI: /en
`, scriptFilename, documentRoot),
)
tester.AssertGetResponse(
"http://localhost:"+testPort+"/server-globals.php/en",
http.StatusOK,
fmt.Sprintf(`SCRIPT_NAME: /server-globals.php
SCRIPT_FILENAME: %s
PHP_SELF: /server-globals.php/en
PATH_INFO: /en
DOCUMENT_ROOT: %s
DOCUMENT_URI: /server-globals.php
REQUEST_URI: /server-globals.php/en
`, scriptFilename, documentRoot),
)
// === Site 2: php_server with its own worker ===
// because the request does not specify a php file, PATH_INFO should be empty
tester.AssertGetResponse(
"http://localhost:"+testPortTwo+"/en",
http.StatusOK,
fmt.Sprintf(`SCRIPT_NAME: /server-globals.php
SCRIPT_FILENAME: %s
PHP_SELF: /server-globals.php
PATH_INFO:
DOCUMENT_ROOT: %s
DOCUMENT_URI: /server-globals.php
REQUEST_URI: /en
`, scriptFilename, documentRoot),
)
tester.AssertGetResponse(
"http://localhost:"+testPortTwo+"/server-globals.php/en",
http.StatusOK,
fmt.Sprintf(`SCRIPT_NAME: /server-globals.php
SCRIPT_FILENAME: %s
PHP_SELF: /server-globals.php/en
PATH_INFO: /en
DOCUMENT_ROOT: %s
DOCUMENT_URI: /server-globals.php
REQUEST_URI: /server-globals.php/en
`, scriptFilename, documentRoot),
)
// === Site 3: php_server with its own match worker ===
tester.AssertGetResponse(
"http://localhost:"+testPortThree+"/en",
http.StatusOK,
fmt.Sprintf(`SCRIPT_NAME:
SCRIPT_FILENAME: %s
PHP_SELF:
PATH_INFO:
DOCUMENT_ROOT: %s
DOCUMENT_URI:
REQUEST_URI: /en
`, scriptFilename, documentRoot2),
)
tester.AssertGetResponse(
"http://localhost:"+testPortThree+"/server-globals.php/en",
http.StatusOK,
fmt.Sprintf(`SCRIPT_NAME:
SCRIPT_FILENAME: %s
PHP_SELF:
PATH_INFO:
DOCUMENT_ROOT: %s
DOCUMENT_URI:
REQUEST_URI: /server-globals.php/en
`, scriptFilename, documentRoot2),
)
}
func TestMetrics(t *testing.T) {
var wg sync.WaitGroup
tester := caddytest.NewTester(t)
+2 -2
View File
@@ -559,7 +559,7 @@ func parsePhpServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error)
}),
}
rewriteHandler := rewrite.Rewrite{
URI: "{http.matchers.file.relative}",
URI: "{http.matchers.file.relative}{http.matchers.file.remainder}",
}
rewriteRoute := caddyhttp.Route{
MatcherSetsRaw: []caddy.ModuleMap{rewriteMatcherSet},
@@ -573,7 +573,7 @@ func parsePhpServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error)
// match only requests that are for PHP files
var pathList []string
for _, ext := range extensions {
pathList = append(pathList, "*"+ext)
pathList = append(pathList, "*"+ext, "*"+ext+"/*")
}
phpMatcherSet := caddy.ModuleMap{
"path": h.JSON(pathList),
+2 -2
View File
@@ -171,7 +171,7 @@ func cmdPHPServer(fs caddycmd.Flags) (int, error) {
}, nil),
}
rewriteHandler := rewrite.Rewrite{
URI: "{http.matchers.file.relative}",
URI: "{http.matchers.file.relative}{http.matchers.file.remainder}",
}
rewriteRoute := caddyhttp.Route{
MatcherSetsRaw: []caddy.ModuleMap{rewriteMatcherSet},
@@ -182,7 +182,7 @@ func cmdPHPServer(fs caddycmd.Flags) (int, error) {
// match only requests that are for PHP files
var pathList []string
for _, ext := range extensions {
pathList = append(pathList, "*"+ext)
pathList = append(pathList, "*"+ext, "*"+ext+"/*")
}
phpMatcherSet := caddy.ModuleMap{
"path": caddyconfig.JSON(pathList, nil),
+21 -12
View File
@@ -111,7 +111,7 @@ func addKnownVariablesToServer(fc *frankenPHPContext, trackVarsArray *C.zval) {
requestURI = fc.requestURI
}
requestPath := ensureLeadingSlash(request.URL.Path)
phpSelf := fc.scriptName + fc.pathInfo
C.frankenphp_register_server_vars(trackVarsArray, C.frankenphp_server_vars{
// approximate total length to avoid array re-hashing:
@@ -129,8 +129,8 @@ func addKnownVariablesToServer(fc *frankenPHPContext, trackVarsArray *C.zval) {
document_root_len: C.size_t(len(fc.documentRoot)),
path_info: toUnsafeChar(fc.pathInfo),
path_info_len: C.size_t(len(fc.pathInfo)),
php_self: toUnsafeChar(requestPath),
php_self_len: C.size_t(len(requestPath)),
php_self: toUnsafeChar(phpSelf),
php_self_len: C.size_t(len(phpSelf)),
document_uri: toUnsafeChar(fc.docURI),
document_uri_len: C.size_t(len(fc.docURI)),
script_filename: toUnsafeChar(fc.scriptFilename),
@@ -208,17 +208,26 @@ func splitCgiPath(fc *frankenPHPContext) {
if splitPos := splitPos(path, splitPath); splitPos > -1 {
fc.docURI = path[:splitPos]
fc.pathInfo = path[splitPos:]
// Strip PATH_INFO from SCRIPT_NAME
fc.scriptName = strings.TrimSuffix(path, fc.pathInfo)
// Ensure the SCRIPT_NAME has a leading slash for compliance with RFC3875
// Info: https://tools.ietf.org/html/rfc3875#section-4.1.13
if fc.scriptName != "" && !strings.HasPrefix(fc.scriptName, "/") {
fc.scriptName = "/" + fc.scriptName
}
}
// If a worker is already assigned explicitly, derive SCRIPT_NAME from its filename
if fc.worker != nil {
fc.scriptFilename = fc.worker.fileName
docRootWithSep := fc.documentRoot + string(filepath.Separator)
if strings.HasPrefix(fc.worker.fileName, docRootWithSep) {
fc.scriptName = filepath.ToSlash(strings.TrimPrefix(fc.worker.fileName, fc.documentRoot))
} else {
fc.docURI = ""
fc.pathInfo = ""
}
return
}
// Strip PATH_INFO from SCRIPT_NAME
// Ensure the SCRIPT_NAME has a leading slash for compliance with RFC3875
// Info: https://tools.ietf.org/html/rfc3875#section-4.1.13
fc.scriptName = ensureLeadingSlash(strings.TrimSuffix(path, fc.pathInfo))
// TODO: is it possible to delay this and avoid saving everything in the context?
// SCRIPT_FILENAME is the absolute path of SCRIPT_NAME
fc.scriptFilename = sanitizedPathJoin(fc.documentRoot, fc.scriptName)
+1 -8
View File
@@ -86,14 +86,7 @@ func NewRequestWithContext(r *http.Request, opts ...RequestOption) (*http.Reques
}
}
// If a worker is already assigned explicitly, use its filename and skip parsing path variables
if fc.worker != nil {
fc.scriptFilename = fc.worker.fileName
} else {
// If no worker was assigned, split the path into the "traditional" CGI path variables.
// This needs to already happen here in case a worker script still matches the path.
splitCgiPath(fc)
}
splitCgiPath(fc)
fc.requestURI = r.URL.RequestURI()
+28
View File
@@ -0,0 +1,28 @@
<?php
ignore_user_abort(true);
$server_vars = [
'SCRIPT_NAME',
'SCRIPT_FILENAME',
'PHP_SELF',
'PATH_INFO',
'DOCUMENT_ROOT',
'DOCUMENT_URI',
'REQUEST_URI',
];
$handler = static function() use ($server_vars) {
foreach ($server_vars as $var) {
$value = $_SERVER[$var] ?? '(not set)';
echo $value !== '' ? "$var: $value\n" : "$var:\n";
}
};
if (isset($_SERVER['FRANKENPHP_WORKER'])) {
for ($nbRequests = 0, $running = true; $running; ++$nbRequests) {
$running = \frankenphp_handle_request($handler);
gc_collect_cycles();
}
} else {
$handler();
}