fix: concurrent env access (#1409)

This commit is contained in:
Alexander Stecher
2025-03-01 14:45:04 +01:00
committed by GitHub
parent 3ba4e257a1
commit c57f741d83
15 changed files with 268 additions and 74 deletions

View File

@@ -716,3 +716,35 @@ func testSingleIniConfiguration(tester *caddytest.Tester, key string, value stri
)
}
}
func TestOsEnv(t *testing.T) {
os.Setenv("ENV1", "value1")
os.Setenv("ENV2", "value2")
tester := caddytest.NewTester(t)
tester.InitServer(`
{
skip_install_trust
admin localhost:2999
http_port `+testPort+`
frankenphp {
num_threads 2
php_ini variables_order "EGPCS"
worker ../testdata/env/env.php 1
}
}
localhost:`+testPort+` {
route {
root ../testdata
php
}
}
`, "caddyfile")
tester.AssertGetResponse(
"http://localhost:"+testPort+"/env/env.php?keys[]=ENV1&keys[]=ENV2",
http.StatusOK,
"ENV1=value1,ENV2=value2",
)
}

115
env.go
View File

@@ -1,5 +1,9 @@
package frankenphp
// #cgo nocallback frankenphp_init_persistent_string
// #cgo nocallback frankenphp_add_assoc_str_ex
// #cgo noescape frankenphp_init_persistent_string
// #cgo noescape frankenphp_add_assoc_str_ex
// #include "frankenphp.h"
import "C"
import (
@@ -8,67 +12,106 @@ import (
"unsafe"
)
func initializeEnv() map[string]*C.zend_string {
env := os.Environ()
envMap := make(map[string]*C.zend_string, len(env))
for _, envVar := range env {
key, val, _ := strings.Cut(envVar, "=")
envMap[key] = C.frankenphp_init_persistent_string(toUnsafeChar(val), C.size_t(len(val)))
}
return envMap
}
// get the main thread env or the thread specific env
func getSandboxedEnv(thread *phpThread) map[string]*C.zend_string {
if thread.sandboxedEnv != nil {
return thread.sandboxedEnv
}
return mainThread.sandboxedEnv
}
func clearSandboxedEnv(thread *phpThread) {
if thread.sandboxedEnv == nil {
return
}
for key, val := range thread.sandboxedEnv {
valInMainThread, ok := mainThread.sandboxedEnv[key]
if !ok || val != valInMainThread {
C.free(unsafe.Pointer(val))
}
}
thread.sandboxedEnv = nil
}
// if an env var already exists, it needs to be freed
func removeEnvFromThread(thread *phpThread, key string) {
valueInThread, existsInThread := thread.sandboxedEnv[key]
if !existsInThread {
return
}
valueInMainThread, ok := mainThread.sandboxedEnv[key]
if !ok || valueInThread != valueInMainThread {
C.free(unsafe.Pointer(valueInThread))
}
delete(thread.sandboxedEnv, key)
}
// copy the main thread env to the thread specific env
func cloneSandboxedEnv(thread *phpThread) {
if thread.sandboxedEnv != nil {
return
}
thread.sandboxedEnv = make(map[string]*C.zend_string, len(mainThread.sandboxedEnv))
for key, value := range mainThread.sandboxedEnv {
thread.sandboxedEnv[key] = value
}
}
//export go_putenv
func go_putenv(str *C.char, length C.int) C.bool {
func go_putenv(threadIndex C.uintptr_t, str *C.char, length C.int) C.bool {
thread := phpThreads[threadIndex]
envString := C.GoStringN(str, length)
cloneSandboxedEnv(thread)
// Check if '=' is present in the string
if key, val, found := strings.Cut(envString, "="); found {
removeEnvFromThread(thread, key)
thread.sandboxedEnv[key] = C.frankenphp_init_persistent_string(toUnsafeChar(val), C.size_t(len(val)))
return os.Setenv(key, val) == nil
}
// No '=', unset the environment variable
removeEnvFromThread(thread, envString)
return os.Unsetenv(envString) == nil
}
//export go_getfullenv
func go_getfullenv(threadIndex C.uintptr_t) (*C.go_string, C.size_t) {
func go_getfullenv(threadIndex C.uintptr_t, trackVarsArray *C.zval) {
thread := phpThreads[threadIndex]
env := getSandboxedEnv(thread)
env := os.Environ()
goStrings := make([]C.go_string, len(env)*2)
for i, envVar := range env {
key, val, _ := strings.Cut(envVar, "=")
goStrings[i*2] = C.go_string{C.size_t(len(key)), thread.pinString(key)}
goStrings[i*2+1] = C.go_string{C.size_t(len(val)), thread.pinString(val)}
for key, val := range env {
C.frankenphp_add_assoc_str_ex(trackVarsArray, toUnsafeChar(key), C.size_t(len(key)), val)
}
value := unsafe.SliceData(goStrings)
thread.Pin(value)
return value, C.size_t(len(env))
}
//export go_getenv
func go_getenv(threadIndex C.uintptr_t, name *C.go_string) (C.bool, *C.go_string) {
func go_getenv(threadIndex C.uintptr_t, name *C.char) (C.bool, *C.zend_string) {
thread := phpThreads[threadIndex]
// Create a byte slice from C string with a specified length
envName := C.GoStringN(name.data, C.int(name.len))
// Get the environment variable value
envValue, exists := os.LookupEnv(envName)
envValue, exists := getSandboxedEnv(thread)[C.GoString(name)]
if !exists {
// Environment variable does not exist
return false, nil // Return 0 to indicate failure
}
// Convert Go string to C string
value := &C.go_string{C.size_t(len(envValue)), thread.pinString(envValue)}
thread.Pin(value)
return true, value // Return 1 to indicate success
}
//export go_sapi_getenv
func go_sapi_getenv(threadIndex C.uintptr_t, name *C.go_string) *C.char {
envName := C.GoStringN(name.data, C.int(name.len))
envValue, exists := os.LookupEnv(envName)
if !exists {
return nil
}
return phpThreads[threadIndex].pinCString(envValue)
return true, envValue // Return 1 to indicate success
}

View File

@@ -160,18 +160,12 @@ static void frankenphp_worker_request_shutdown() {
}
PHPAPI void get_full_env(zval *track_vars_array) {
struct go_getfullenv_return full_env = go_getfullenv(thread_index);
for (int i = 0; i < full_env.r1; i++) {
go_string key = full_env.r0[i * 2];
go_string val = full_env.r0[i * 2 + 1];
// create PHP string for the value
zend_string *val_str = zend_string_init(val.data, val.len, 0);
// add to the associative array
add_assoc_str_ex(track_vars_array, key.data, key.len, val_str);
go_getfullenv(thread_index, track_vars_array);
}
void frankenphp_add_assoc_str_ex(zval *track_vars_array, char *key,
size_t keylen, zend_string *val) {
add_assoc_str_ex(track_vars_array, key, keylen, val);
}
/* Adapted from php_request_startup() */
@@ -219,8 +213,20 @@ static int frankenphp_worker_request_startup() {
php_hash_environment();
/* zend_is_auto_global will force a re-import of the $_SERVER global */
zend_is_auto_global(ZSTR_KNOWN(ZEND_STR_AUTOGLOBAL_SERVER));
/* disarm the $_ENV auto_global to prevent it from being reloaded in worker
* mode */
if (zend_hash_str_exists(&EG(symbol_table), "_ENV", 4)) {
zend_auto_global *env_global;
if ((env_global = zend_hash_find_ptr(
CG(auto_globals), ZSTR_KNOWN(ZEND_STR_AUTOGLOBAL_ENV))) !=
NULL) {
env_global->armed = 0;
}
}
/* Unfinish the request */
frankenphp_server_context *ctx = SG(server_context);
ctx->finished = false;
@@ -282,7 +288,7 @@ PHP_FUNCTION(frankenphp_putenv) {
RETURN_FALSE;
}
if (go_putenv(setting, (int)setting_len)) {
if (go_putenv(thread_index, setting, (int)setting_len)) {
RETURN_TRUE;
} else {
RETURN_FALSE;
@@ -308,13 +314,11 @@ PHP_FUNCTION(frankenphp_getenv) {
return;
}
go_string gname = {name_len, name};
struct go_getenv_return result = go_getenv(thread_index, &gname);
struct go_getenv_return result = go_getenv(thread_index, name);
if (result.r0) {
// Return the single environment variable as a string
RETVAL_STRINGL(result.r1->data, result.r1->len);
RETVAL_STR(result.r1);
} else {
// Environment variable does not exist
RETVAL_FALSE;
@@ -748,6 +752,7 @@ void frankenphp_register_bulk(
zend_string *frankenphp_init_persistent_string(const char *string, size_t len) {
/* persistent strings will be ignored by the GC at the end of a request */
zend_string *z_string = zend_string_init(string, len, 1);
zend_string_hash_val(z_string);
/* interned strings will not be ref counted by the GC */
GC_ADD_FLAGS(z_string, IS_STR_INTERNED);
@@ -755,10 +760,6 @@ zend_string *frankenphp_init_persistent_string(const char *string, size_t len) {
return z_string;
}
void frankenphp_release_zend_string(zend_string *z_string) {
zend_string_release(z_string);
}
static void
frankenphp_register_variable_from_request_info(zend_string *zKey, char *value,
bool must_be_present,
@@ -844,9 +845,13 @@ static void frankenphp_log_message(const char *message, int syslog_type_int) {
}
static char *frankenphp_getenv(const char *name, size_t name_len) {
go_string gname = {name_len, (char *)name};
struct go_getenv_return result = go_getenv(thread_index, (char *)name);
return go_sapi_getenv(thread_index, &gname);
if (result.r0) {
return result.r1->val;
}
return NULL;
}
sapi_module_struct frankenphp_sapi_module = {

View File

@@ -69,9 +69,10 @@ void frankenphp_register_variables_from_request_info(
void frankenphp_register_variable_safe(char *key, char *var, size_t val_len,
zval *track_vars_array);
zend_string *frankenphp_init_persistent_string(const char *string, size_t len);
void frankenphp_release_zend_string(zend_string *z_string);
int frankenphp_reset_opcache(void);
int frankenphp_get_current_memory_limit();
void frankenphp_add_assoc_str_ex(zval *track_vars_array, char *key,
size_t keylen, zend_string *val);
void frankenphp_register_single(zend_string *z_key, char *value, size_t val_len,
zval *track_vars_array);

View File

@@ -45,6 +45,7 @@ type testOptions struct {
realServer bool
logger *zap.Logger
initOpts []frankenphp.Option
phpIni map[string]string
}
func runTest(t *testing.T, test func(func(http.ResponseWriter, *http.Request), *httptest.Server, int), opts *testOptions) {
@@ -67,6 +68,9 @@ func runTest(t *testing.T, test func(func(http.ResponseWriter, *http.Request), *
initOpts = append(initOpts, frankenphp.WithWorkers(testDataDir+opts.workerScript, opts.nbWorkers, opts.env, opts.watch))
}
initOpts = append(initOpts, opts.initOpts...)
if opts.phpIni != nil {
initOpts = append(initOpts, frankenphp.WithPhpIni(opts.phpIni))
}
err := frankenphp.Init(initOpts...)
require.Nil(t, err)
@@ -671,13 +675,13 @@ func TestEnv(t *testing.T) {
testEnv(t, &testOptions{})
}
func TestEnvWorker(t *testing.T) {
testEnv(t, &testOptions{workerScript: "test-env.php"})
testEnv(t, &testOptions{workerScript: "env/test-env.php"})
}
func testEnv(t *testing.T, opts *testOptions) {
assert.NoError(t, os.Setenv("EMPTY", ""))
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/test-env.php?var=%d", i), nil)
req := httptest.NewRequest("GET", fmt.Sprintf("http://example.com/env/test-env.php?var=%d", i), nil)
w := httptest.NewRecorder()
handler(w, req)
@@ -685,7 +689,7 @@ func testEnv(t *testing.T, opts *testOptions) {
body, _ := io.ReadAll(resp.Body)
// execute the script as regular php script
cmd := exec.Command("php", "testdata/test-env.php", strconv.Itoa(i))
cmd := exec.Command("php", "testdata/env/test-env.php", strconv.Itoa(i))
stdoutStderr, err := cmd.CombinedOutput()
if err != nil {
// php is not installed or other issue, use the hardcoded output below:
@@ -696,6 +700,46 @@ func testEnv(t *testing.T, opts *testOptions) {
}, opts)
}
func TestEnvIsResetInNonWorkerMode(t *testing.T) {
assert.NoError(t, os.Setenv("test", ""))
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
putResult := fetchBody("GET", fmt.Sprintf("http://example.com/env/putenv.php?key=test&put=%d", i), handler)
assert.Equal(t, fmt.Sprintf("test=%d", i), putResult, "putenv and then echo getenv")
getResult := fetchBody("GET", "http://example.com/env/putenv.php?key=test", handler)
assert.Equal(t, "test=", getResult, "putenv should be reset across requests")
}, &testOptions{})
}
// TODO: should it actually get reset in worker mode?
func TestEnvIsNotResetInWorkerMode(t *testing.T) {
assert.NoError(t, os.Setenv("index", ""))
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
putResult := fetchBody("GET", fmt.Sprintf("http://example.com/env/remember-env.php?index=%d", i), handler)
assert.Equal(t, "success", putResult, "putenv and then echo getenv")
getResult := fetchBody("GET", "http://example.com/env/remember-env.php", handler)
assert.Equal(t, "success", getResult, "putenv should not be reset across worker requests")
}, &testOptions{workerScript: "env/remember-env.php"})
}
// reproduction of https://github.com/dunglas/frankenphp/issues/1061
func TestModificationsToEnvPersistAcrossRequests(t *testing.T) {
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
for j := 0; j < 3; j++ {
result := fetchBody("GET", "http://example.com/env/overwrite-env.php", handler)
assert.Equal(t, "custom_value", result, "a var directly added to $_ENV should persist")
}
}, &testOptions{
workerScript: "env/overwrite-env.php",
phpIni: map[string]string{"variables_order": "EGPCS"},
})
}
func TestFileUpload_module(t *testing.T) { testFileUpload(t, &testOptions{}) }
func TestFileUpload_worker(t *testing.T) {
testFileUpload(t, &testOptions{workerScript: "file-upload.php"})

View File

@@ -27,6 +27,7 @@ type phpMainThread struct {
phpIni map[string]string
commonHeaders map[string]*C.zend_string
knownServerKeys map[string]*C.zend_string
sandboxedEnv map[string]*C.zend_string
}
var (
@@ -44,6 +45,7 @@ func initPHPThreads(numThreads int, numMaxThreads int, phpIni map[string]string)
numThreads: numThreads,
maxThreads: numMaxThreads,
phpIni: phpIni,
sandboxedEnv: initializeEnv(),
}
// initialize the first thread

View File

@@ -22,6 +22,7 @@ type phpThread struct {
handlerMu sync.Mutex
handler threadHandler
state *threadState
sandboxedEnv map[string]*C.zend_string
}
// interface that defines how the callbacks from the C thread should be handled

10
testdata/env/env.php vendored Normal file
View File

@@ -0,0 +1,10 @@
<?php
require_once __DIR__ . '/../_executor.php';
return function () use (&$rememberedKey) {
$keys = $_GET['keys'];
// echoes ENV1=value1,ENV2=value2
echo join(',', array_map(fn($key) => "$key=" . $_ENV[$key], $keys));
};

3
testdata/env/import-env.php vendored Normal file
View File

@@ -0,0 +1,3 @@
<?php
return $_ENV['custom_key'];

12
testdata/env/overwrite-env.php vendored Normal file
View File

@@ -0,0 +1,12 @@
<?php
require_once __DIR__.'/../_executor.php';
// modify $_ENV in the global symbol table
// the modification should persist through the worker's lifetime
$_ENV['custom_key'] = 'custom_value';
return function () use (&$rememberedIndex) {
$custom_key = require __DIR__.'/import-env.php';
echo $custom_key;
};

17
testdata/env/putenv.php vendored Normal file
View File

@@ -0,0 +1,17 @@
<?php
require_once __DIR__.'/../_executor.php';
return function () use (&$rememberedKey) {
$key = $_GET['key'];
$put = $_GET['put'] ?? null;
if(isset($put)){
putenv("$key=$put");
}
$get = getenv($key);
$asStr = $get === false ? '' : $get;
echo "$key=$asStr";
};

20
testdata/env/remember-env.php vendored Normal file
View File

@@ -0,0 +1,20 @@
<?php
require_once __DIR__.'/../_executor.php';
$rememberedIndex = 0;
return function () use (&$rememberedIndex) {
$indexFromRequest = $_GET['index'] ?? null;
if(isset($indexFromRequest)){
$rememberedIndex = (int)$indexFromRequest;
putenv("index=$rememberedIndex");
}
$indexInEnv = (int)getenv('index');
if($indexInEnv === $rememberedIndex){
echo 'success';
return;
}
echo "failure: '$indexInEnv' is not '$rememberedIndex'";
};

View File

@@ -1,6 +1,6 @@
<?php
require_once __DIR__.'/_executor.php';
require_once __DIR__.'/../_executor.php';
return function() {
$var = 'MY_VAR_' . ($_GET['var'] ?? '');

View File

@@ -61,6 +61,9 @@ func (handler *regularThread) name() string {
}
func (handler *regularThread) waitForRequest() string {
// clear any previously sandboxed env
clearSandboxedEnv(handler.thread)
handler.state.markAsWaiting(true)
var r *http.Request

View File

@@ -99,6 +99,7 @@ func setupWorkerScript(handler *workerThread, worker *worker) {
}
handler.fakeRequest = r
clearSandboxedEnv(handler.thread)
if c := logger.Check(zapcore.DebugLevel, "starting"); c != nil {
c.Write(zap.String("worker", worker.fileName), zap.Int("thread", handler.thread.threadIndex))
}