From 581859ba3233168805f9bd0d321d41ec749c032f Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Wed, 18 Jun 2025 21:10:27 +0200 Subject: [PATCH 1/9] fix #1637 --- caddy/module.go | 5 ++++- worker.go | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/caddy/module.go b/caddy/module.go index aef54df48c..55bc95e453 100644 --- a/caddy/module.go +++ b/caddy/module.go @@ -148,7 +148,10 @@ func (f *FrankenPHPModule) ServeHTTP(w http.ResponseWriter, r *http.Request, _ c if documentRoot == "" && frankenphp.EmbeddedAppPath != "" { documentRoot = frankenphp.EmbeddedAppPath } - documentRootOption = frankenphp.WithRequestDocumentRoot(documentRoot, *f.ResolveRootSymlink) + //If we do not have a resolved document root, then we cannot resolve the symlink of our cwd because it may + //resolve to a different directory than the one we are currently in. + //This is especially important if there are workers running. + documentRootOption = frankenphp.WithRequestDocumentRoot(documentRoot, false) } else { documentRoot = f.resolvedDocumentRoot documentRootOption = frankenphp.WithRequestResolvedDocumentRoot(documentRoot) diff --git a/worker.go b/worker.go index 3c9455b775..37b41d43b6 100644 --- a/worker.go +++ b/worker.go @@ -4,6 +4,7 @@ package frankenphp import "C" import ( "fmt" + "path/filepath" "strings" "sync" "time" @@ -74,7 +75,11 @@ func getWorkerKey(name string, filename string) string { } func newWorker(o workerOpt) (*worker, error) { - absFileName, err := fastabs.FastAbs(o.fileName) + absFileName, err := filepath.EvalSymlinks(o.fileName) + if err != nil { + return nil, fmt.Errorf("worker filename is invalid %q: %w", o.fileName, err) + } + absFileName, err = fastabs.FastAbs(absFileName) if err != nil { return nil, fmt.Errorf("worker filename is invalid %q: %w", o.fileName, err) } From fd60f48c97ae9493f85fe85e860556eefb7517b2 Mon Sep 17 00:00:00 2001 From: Robert Landers Date: Wed, 18 Jun 2025 21:21:56 +0200 Subject: [PATCH 2/9] add a comment --- worker.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/worker.go b/worker.go index 37b41d43b6..e02b103cd0 100644 --- a/worker.go +++ b/worker.go @@ -75,6 +75,9 @@ func getWorkerKey(name string, filename string) string { } func newWorker(o workerOpt) (*worker, error) { + //Order is important! + //This order ensures that FrankenPHP started from inside a symlinked directory will properly resolve any paths. + //If it is started from outside a symlinked directory, it is resolved to the same path that we use in the Caddy module. absFileName, err := filepath.EvalSymlinks(o.fileName) if err != nil { return nil, fmt.Errorf("worker filename is invalid %q: %w", o.fileName, err) From 137f74cfbc50bee72a7930ab22e8de336802db29 Mon Sep 17 00:00:00 2001 From: henderkes Date: Wed, 31 Dec 2025 12:38:55 +0100 Subject: [PATCH 3/9] @dunglas suggestion --- caddy/module.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/caddy/module.go b/caddy/module.go index a5e0b9847a..c3d9dd4d03 100644 --- a/caddy/module.go +++ b/caddy/module.go @@ -181,9 +181,9 @@ func (f *FrankenPHPModule) ServeHTTP(w http.ResponseWriter, r *http.Request, _ c if documentRoot == "" && frankenphp.EmbeddedAppPath != "" { documentRoot = frankenphp.EmbeddedAppPath } - //If we do not have a resolved document root, then we cannot resolve the symlink of our cwd because it may - //resolve to a different directory than the one we are currently in. - //This is especially important if there are workers running. + // If we do not have a resolved document root, then we cannot resolve the symlink of our cwd because it may + // resolve to a different directory than the one we are currently in. + // This is especially important if there are workers running. documentRootOption = frankenphp.WithRequestDocumentRoot(documentRoot, false) } else { documentRoot = f.resolvedDocumentRoot From 96a36928ea63d0f0aadd1e4ec96d05da9d6f7060 Mon Sep 17 00:00:00 2001 From: henderkes Date: Wed, 31 Dec 2025 14:07:16 +0100 Subject: [PATCH 4/9] add tests (last one is failing) --- caddy/caddy_test.go | 266 +++++++++++++++++++++++ cgo.go | 2 +- internal/testext/exttest.go | 9 +- testdata/symlinks/public | 1 + testdata/symlinks/test/document-root.php | 13 ++ testdata/symlinks/test/index.php | 13 ++ testdata/symlinks/test/nested/index.php | 13 ++ 7 files changed, 312 insertions(+), 5 deletions(-) create mode 120000 testdata/symlinks/public create mode 100644 testdata/symlinks/test/document-root.php create mode 100644 testdata/symlinks/test/index.php create mode 100644 testdata/symlinks/test/nested/index.php diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index e359e3d4bc..15a0562cc3 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -1500,3 +1500,269 @@ func TestLog(t *testing.T) { "", ) } + +// TestSymlinkNeighboringWorkerScript tests executing a worker script from within a symlinked directory +// Scenario: neighboring worker script +// +// Given frankenphp located in the test folder +// When I execute `frankenphp php-server --listen localhost:8080 -w index.php` from `public` +// Then I expect to see the worker script executed successfully +func TestSymlinkNeighboringWorkerScript(t *testing.T) { + cwd, _ := os.Getwd() + publicDir := filepath.Join(cwd, "..", "testdata", "symlinks", "public") + + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + + frankenphp { + worker `+publicDir+`/index.php 1 + } + } + + localhost:`+testPort+` { + route { + php { + root `+publicDir+` + resolve_root_symlink true + } + } + } + `, "caddyfile") + + tester.AssertGetResponse("http://localhost:"+testPort+"/index.php", http.StatusOK, "Request: 0\n") +} + +// TestSymlinkNestedWorkerScript tests executing a nested worker script through a symlinked directory +// Scenario: nested worker script +// +// Given frankenphp located in the test folder +// When I execute `frankenphp --listen localhost:8080 -w nested/index.php` from `public` +// Then I expect to see the worker script executed successfully +func TestSymlinkNestedWorkerScript(t *testing.T) { + cwd, _ := os.Getwd() + publicDir := filepath.Join(cwd, "..", "testdata", "symlinks", "public") + + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + + frankenphp { + worker `+publicDir+`/nested/index.php 1 + } + } + + localhost:`+testPort+` { + route { + php { + root `+publicDir+` + resolve_root_symlink true + } + } + } + `, "caddyfile") + + tester.AssertGetResponse("http://localhost:"+testPort+"/nested/index.php", http.StatusOK, "Nested request: 0\n") +} + +// TestSymlinkOutsideSymlinkedFolder tests executing a worker script referenced from outside the symlinked directory +// Scenario: outside the symlinked folder +// +// Given frankenphp located in the root folder +// When I execute `frankenphp --listen localhost:8080 -w public/index.php` from the root folder +// Then I expect to see the worker script executed successfully +func TestSymlinkOutsideSymlinkedFolder(t *testing.T) { + cwd, _ := os.Getwd() + publicDir := filepath.Join(cwd, "..", "testdata", "symlinks", "public") + + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + + frankenphp { + worker { + name outside_worker + file `+publicDir+`/index.php + num 1 + } + } + } + + localhost:`+testPort+` { + route { + php { + root `+publicDir+` + resolve_root_symlink true + } + } + } + `, "caddyfile") + + tester.AssertGetResponse("http://localhost:"+testPort+"/index.php", http.StatusOK, "Request: 0\n") +} + +// TestSymlinkSpecifiedRootDirectory tests executing a worker script with an explicitly specified root directory +// Scenario: specified root directory +// +// Given frankenphp located in the root folder +// When I execute `frankenphp --listen localhost:8080 -w public/index.php -r public` from the root folder +// Then I expect to see the worker script executed successfully +func TestSymlinkSpecifiedRootDirectory(t *testing.T) { + cwd, _ := os.Getwd() + publicDir := filepath.Join(cwd, "..", "testdata", "symlinks", "public") + + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + + frankenphp { + worker { + name specified_root_worker + file `+publicDir+`/index.php + num 1 + } + } + } + + localhost:`+testPort+` { + route { + php { + root `+publicDir+` + resolve_root_symlink true + } + } + } + `, "caddyfile") + + tester.AssertGetResponse("http://localhost:"+testPort+"/index.php", http.StatusOK, "Request: 0\n") +} + +// TestSymlinkResolveRootSymlink tests that resolve_root_symlink directive works correctly +func TestSymlinkResolveRootSymlink(t *testing.T) { + cwd, _ := os.Getwd() + testDir := filepath.Join(cwd, "..", "testdata", "symlinks", "test") + publicDir := filepath.Join(cwd, "..", "testdata", "symlinks", "public") + + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + + frankenphp { + worker `+publicDir+`/document-root.php 1 + } + } + + localhost:`+testPort+` { + route { + php { + root `+publicDir+` + resolve_root_symlink true + } + } + } + `, "caddyfile") + + // DOCUMENT_ROOT should be the resolved path (testDir) + tester.AssertGetResponse("http://localhost:"+testPort+"/document-root.php", http.StatusOK, "DOCUMENT_ROOT="+testDir+"\n") +} + +// TestSymlinkNoResolveRootSymlink tests that symlinks are preserved when resolve_root_symlink is false (non-worker mode) +// Note: This test uses document-root.php in non-worker mode to verify DOCUMENT_ROOT contains the symlink path +func TestSymlinkNoResolveRootSymlink(t *testing.T) { + cwd, _ := os.Getwd() + publicDir := filepath.Join(cwd, "..", "testdata", "symlinks", "public") + + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + } + + localhost:`+testPort+` { + route { + php { + root `+publicDir+` + resolve_root_symlink false + } + } + } + `, "caddyfile") + + // DOCUMENT_ROOT should be the symlink path (publicDir) when resolve_root_symlink is false + tester.AssertGetResponse("http://localhost:"+testPort+"/document-root.php", http.StatusOK, "DOCUMENT_ROOT="+publicDir+"\n") +} + +// TestSymlinkWorkerScriptFailsWithoutWorkerMode tests that accessing a worker script +// without configuring it as a worker actually results in an error +func TestSymlinkWorkerScriptFailsWithoutWorkerMode(t *testing.T) { + cwd, _ := os.Getwd() + publicDir := filepath.Join(cwd, "..", "testdata", "symlinks", "public") + + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + } + + localhost:`+testPort+` { + route { + php { + root `+publicDir+` + } + } + } + `, "caddyfile") + + // Accessing the worker script without worker configuration MUST fail + // The script checks $_SERVER['FRANKENPHP_WORKER'] and dies if not set + tester.AssertGetResponse("http://localhost:"+testPort+"/index.php", http.StatusOK, "Error: This script must be run in worker mode (FRANKENPHP_WORKER not set to '1')\n") +} + +// TestSymlinkMultipleRequests tests that symlinked workers handle multiple sequential requests correctly +func TestSymlinkMultipleRequests(t *testing.T) { + cwd, _ := os.Getwd() + publicDir := filepath.Join(cwd, "..", "testdata", "symlinks", "public") + + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + } + + localhost:`+testPort+` { + route { + php { + root `+publicDir+` + resolve_root_symlink true + worker index.php 1 + } + } + } + `, "caddyfile") + + // Make multiple requests - each should increment the counter + for i := 0; i < 5; i++ { + tester.AssertGetResponse("http://localhost:"+testPort+"/index.php", http.StatusOK, fmt.Sprintf("Request: %d\n", i)) + } +} diff --git a/cgo.go b/cgo.go index 2ec9586308..870346ec44 100644 --- a/cgo.go +++ b/cgo.go @@ -3,7 +3,7 @@ package frankenphp // #cgo darwin pkg-config: libxml-2.0 // #cgo CFLAGS: -Wall -Werror // #cgo linux CFLAGS: -D_GNU_SOURCE -// #cgo LDFLAGS: -lphp -lm -lutil +// #cgo LDFLAGS: -lphp-zts-85 -lm -lutil // #cgo linux LDFLAGS: -ldl -lresolv // #cgo darwin LDFLAGS: -Wl,-rpath,/usr/local/lib -liconv -ldl import "C" diff --git a/internal/testext/exttest.go b/internal/testext/exttest.go index abebee4c1d..89ee714f45 100644 --- a/internal/testext/exttest.go +++ b/internal/testext/exttest.go @@ -5,19 +5,20 @@ package testext // #cgo CFLAGS: -I/usr/local/include -I/usr/local/include/php -I/usr/local/include/php/main -I/usr/local/include/php/TSRM -I/usr/local/include/php/Zend -I/usr/local/include/php/ext -I/usr/local/include/php/ext/date/lib // #cgo linux CFLAGS: -D_GNU_SOURCE // #cgo darwin CFLAGS: -I/opt/homebrew/include -// #cgo LDFLAGS: -L/usr/local/lib -L/usr/lib -lphp -lm -lutil +// #cgo LDFLAGS: -L/usr/local/lib -L/usr/lib -lphp-zts-85 -lm -lutil // #cgo linux LDFLAGS: -ldl -lresolv // #cgo darwin LDFLAGS: -Wl,-rpath,/usr/local/lib -L/opt/homebrew/lib -L/opt/homebrew/opt/libiconv/lib -liconv -ldl // #include "extension.h" import "C" import ( - "github.com/dunglas/frankenphp" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "io" "net/http/httptest" "testing" "unsafe" + + "github.com/dunglas/frankenphp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func testRegisterExtension(t *testing.T) { diff --git a/testdata/symlinks/public b/testdata/symlinks/public new file mode 120000 index 0000000000..30d74d2584 --- /dev/null +++ b/testdata/symlinks/public @@ -0,0 +1 @@ +test \ No newline at end of file diff --git a/testdata/symlinks/test/document-root.php b/testdata/symlinks/test/document-root.php new file mode 100644 index 0000000000..9d9ddccaeb --- /dev/null +++ b/testdata/symlinks/test/document-root.php @@ -0,0 +1,13 @@ + Date: Wed, 31 Dec 2025 14:11:19 +0100 Subject: [PATCH 5/9] fix case of relative worker script in a symlinked dir --- caddy/module.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/caddy/module.go b/caddy/module.go index c3d9dd4d03..6416362694 100644 --- a/caddy/module.go +++ b/caddy/module.go @@ -138,6 +138,15 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error { } f.resolvedDocumentRoot = root + + // Also resolve symlinks in worker file paths when resolve_root_symlink is true + for i, wc := range f.Workers { + if !filepath.IsAbs(wc.FileName) { + continue + } + resolvedPath, _ := filepath.EvalSymlinks(wc.FileName) + f.Workers[i].FileName = resolvedPath + } } } From d1cec496894f34b96582423796a5bd0516a914ee Mon Sep 17 00:00:00 2001 From: henderkes Date: Wed, 31 Dec 2025 14:12:07 +0100 Subject: [PATCH 6/9] oopsie with libphp, I wish we didn't hardcode -lphp :/ --- cgo.go | 2 +- internal/testext/exttest.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cgo.go b/cgo.go index 870346ec44..2ec9586308 100644 --- a/cgo.go +++ b/cgo.go @@ -3,7 +3,7 @@ package frankenphp // #cgo darwin pkg-config: libxml-2.0 // #cgo CFLAGS: -Wall -Werror // #cgo linux CFLAGS: -D_GNU_SOURCE -// #cgo LDFLAGS: -lphp-zts-85 -lm -lutil +// #cgo LDFLAGS: -lphp -lm -lutil // #cgo linux LDFLAGS: -ldl -lresolv // #cgo darwin LDFLAGS: -Wl,-rpath,/usr/local/lib -liconv -ldl import "C" diff --git a/internal/testext/exttest.go b/internal/testext/exttest.go index 89ee714f45..1a8477d4a8 100644 --- a/internal/testext/exttest.go +++ b/internal/testext/exttest.go @@ -5,7 +5,7 @@ package testext // #cgo CFLAGS: -I/usr/local/include -I/usr/local/include/php -I/usr/local/include/php/main -I/usr/local/include/php/TSRM -I/usr/local/include/php/Zend -I/usr/local/include/php/ext -I/usr/local/include/php/ext/date/lib // #cgo linux CFLAGS: -D_GNU_SOURCE // #cgo darwin CFLAGS: -I/opt/homebrew/include -// #cgo LDFLAGS: -L/usr/local/lib -L/usr/lib -lphp-zts-85 -lm -lutil +// #cgo LDFLAGS: -L/usr/local/lib -L/usr/lib -lphp -lm -lutil // #cgo linux LDFLAGS: -ldl -lresolv // #cgo darwin LDFLAGS: -Wl,-rpath,/usr/local/lib -L/opt/homebrew/lib -L/opt/homebrew/opt/libiconv/lib -liconv -ldl // #include "extension.h" From efc56273a4a3a485e397be8a719ef3dd5627223a Mon Sep 17 00:00:00 2001 From: Marc Date: Thu, 1 Jan 2026 00:54:04 +0100 Subject: [PATCH 7/9] Update testdata/symlinks/test/document-root.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kévin Dunglas Signed-off-by: Marc --- testdata/symlinks/test/document-root.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testdata/symlinks/test/document-root.php b/testdata/symlinks/test/document-root.php index 9d9ddccaeb..c21b2fc7fc 100644 --- a/testdata/symlinks/test/document-root.php +++ b/testdata/symlinks/test/document-root.php @@ -1,6 +1,6 @@ Date: Thu, 1 Jan 2026 00:54:22 +0100 Subject: [PATCH 8/9] Update testdata/symlinks/test/index.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kévin Dunglas Signed-off-by: Marc --- testdata/symlinks/test/index.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testdata/symlinks/test/index.php b/testdata/symlinks/test/index.php index ae92ea4c4b..15aa1a9cf1 100644 --- a/testdata/symlinks/test/index.php +++ b/testdata/symlinks/test/index.php @@ -1,6 +1,6 @@ Date: Thu, 1 Jan 2026 00:54:34 +0100 Subject: [PATCH 9/9] Update testdata/symlinks/test/nested/index.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kévin Dunglas Signed-off-by: Marc --- testdata/symlinks/test/nested/index.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testdata/symlinks/test/nested/index.php b/testdata/symlinks/test/nested/index.php index a15bfa616e..3d07bc8d89 100644 --- a/testdata/symlinks/test/nested/index.php +++ b/testdata/symlinks/test/nested/index.php @@ -1,6 +1,6 @@