Skip to content

Commit eefb6bb

Browse files
committed
fix(worker) reset session data
1 parent d946e56 commit eefb6bb

File tree

3 files changed

+177
-0
lines changed

3 files changed

+177
-0
lines changed

frankenphp.c

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,15 @@ static void frankenphp_reset_super_globals() {
144144
zval *files = &PG(http_globals)[TRACK_VARS_FILES];
145145
zval_ptr_dtor_nogc(files);
146146
memset(files, 0, sizeof(*files));
147+
148+
/* $_SESSION must be explicitly deleted from the symbol table.
149+
* Unlike other superglobals, $_SESSION is stored in EG(symbol_table)
150+
* with a reference to PS(http_session_vars). The session RSHUTDOWN
151+
* only decrements the refcount but doesn't remove it from the symbol
152+
* table, causing data to leak between requests. */
153+
zend_string *session_var_name = ZSTR_INIT_LITERAL("_SESSION", 0);
154+
zend_delete_global_variable(session_var_name);
155+
zend_string_release(session_var_name);
147156
}
148157
zend_end_try();
149158

frankenphp_test.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"strings"
2828
"sync"
2929
"testing"
30+
"time"
3031

3132
"github.com/dunglas/frankenphp"
3233
"github.com/dunglas/frankenphp/internal/fastabs"
@@ -1306,3 +1307,108 @@ func TestIniPreLoopPreserved_worker(t *testing.T) {
13061307
realServer: true,
13071308
})
13081309
}
1310+
1311+
func TestSessionNoLeakBetweenRequests_worker(t *testing.T) {
1312+
runTest(t, func(_ func(http.ResponseWriter, *http.Request), ts *httptest.Server, i int) {
1313+
// Client A: Set a secret value in session
1314+
clientA := &http.Client{}
1315+
resp1, err := clientA.Get(ts.URL + "/session-leak.php?action=set&value=secret_A&client_id=clientA")
1316+
assert.NoError(t, err)
1317+
body1, _ := io.ReadAll(resp1.Body)
1318+
_ = resp1.Body.Close()
1319+
1320+
body1Str := string(body1)
1321+
t.Logf("Client A set session: %s", body1Str)
1322+
assert.Contains(t, body1Str, "SESSION_SET")
1323+
assert.Contains(t, body1Str, "secret=secret_A")
1324+
1325+
// Client B: Check that session is empty (no cookie, should not see Client A's data)
1326+
clientB := &http.Client{}
1327+
resp2, err := clientB.Get(ts.URL + "/session-leak.php?action=check_empty")
1328+
assert.NoError(t, err)
1329+
body2, _ := io.ReadAll(resp2.Body)
1330+
_ = resp2.Body.Close()
1331+
1332+
body2Str := string(body2)
1333+
t.Logf("Client B check empty: %s", body2Str)
1334+
assert.Contains(t, body2Str, "SESSION_CHECK")
1335+
assert.Contains(t, body2Str, "SESSION_EMPTY=true",
1336+
"Client B should have empty session, not see Client A's data.\nResponse: %s", body2Str)
1337+
assert.NotContains(t, body2Str, "secret_A",
1338+
"Client A's secret should not leak to Client B.\nResponse: %s", body2Str)
1339+
1340+
// Client C: Read session without cookie (should also be empty)
1341+
clientC := &http.Client{}
1342+
resp3, err := clientC.Get(ts.URL + "/session-leak.php?action=get")
1343+
assert.NoError(t, err)
1344+
body3, _ := io.ReadAll(resp3.Body)
1345+
_ = resp3.Body.Close()
1346+
1347+
body3Str := string(body3)
1348+
t.Logf("Client C get session: %s", body3Str)
1349+
assert.Contains(t, body3Str, "SESSION_READ")
1350+
assert.Contains(t, body3Str, "secret=NOT_FOUND",
1351+
"Client C should not find any secret.\nResponse: %s", body3Str)
1352+
assert.Contains(t, body3Str, "client_id=NOT_FOUND",
1353+
"Client C should not find any client_id.\nResponse: %s", body3Str)
1354+
1355+
}, &testOptions{
1356+
workerScript: "session-leak.php",
1357+
nbWorkers: 1,
1358+
nbParallelRequests: 1,
1359+
realServer: true,
1360+
})
1361+
}
1362+
1363+
func TestSessionNoLeakAfterExit_worker(t *testing.T) {
1364+
runTest(t, func(_ func(http.ResponseWriter, *http.Request), ts *httptest.Server, i int) {
1365+
// Client A: Set a secret value in session and call exit(1)
1366+
clientA := &http.Client{}
1367+
resp1, err := clientA.Get(ts.URL + "/session-leak.php?action=set_and_exit&value=exit_secret&client_id=exitClient")
1368+
assert.NoError(t, err)
1369+
body1, _ := io.ReadAll(resp1.Body)
1370+
_ = resp1.Body.Close()
1371+
1372+
body1Str := string(body1)
1373+
t.Logf("Client A set and exit: %s", body1Str)
1374+
// The response may be incomplete due to exit(1)
1375+
assert.Contains(t, body1Str, "BEFORE_EXIT")
1376+
1377+
// Wait a bit for the worker to restart after exit(1)
1378+
time.Sleep(100 * time.Millisecond)
1379+
1380+
// Client B: Check that session is empty (should not see Client A's data)
1381+
clientB := &http.Client{}
1382+
resp2, err := clientB.Get(ts.URL + "/session-leak.php?action=check_empty")
1383+
assert.NoError(t, err)
1384+
body2, _ := io.ReadAll(resp2.Body)
1385+
_ = resp2.Body.Close()
1386+
1387+
body2Str := string(body2)
1388+
t.Logf("Client B check empty after exit: %s", body2Str)
1389+
assert.Contains(t, body2Str, "SESSION_CHECK")
1390+
assert.Contains(t, body2Str, "SESSION_EMPTY=true",
1391+
"Client B should have empty session after Client A's exit(1).\nResponse: %s", body2Str)
1392+
assert.NotContains(t, body2Str, "exit_secret",
1393+
"Client A's secret should not leak to Client B after exit(1).\nResponse: %s", body2Str)
1394+
1395+
// Client C: Try to read session (should also be empty)
1396+
clientC := &http.Client{}
1397+
resp3, err := clientC.Get(ts.URL + "/session-leak.php?action=get")
1398+
assert.NoError(t, err)
1399+
body3, _ := io.ReadAll(resp3.Body)
1400+
_ = resp3.Body.Close()
1401+
1402+
body3Str := string(body3)
1403+
t.Logf("Client C get session after exit: %s", body3Str)
1404+
assert.Contains(t, body3Str, "SESSION_READ")
1405+
assert.Contains(t, body3Str, "secret=NOT_FOUND",
1406+
"Client C should not find any secret after exit(1).\nResponse: %s", body3Str)
1407+
1408+
}, &testOptions{
1409+
workerScript: "session-leak.php",
1410+
nbWorkers: 1,
1411+
nbParallelRequests: 1,
1412+
realServer: true,
1413+
})
1414+
}

testdata/session-leak.php

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
require_once __DIR__.'/_executor.php';
4+
5+
return function () {
6+
$action = $_GET['action'] ?? 'check';
7+
$output = [];
8+
9+
switch ($action) {
10+
case 'set':
11+
// Set a value in session
12+
session_start();
13+
$_SESSION['secret'] = $_GET['value'] ?? 'default_secret';
14+
$_SESSION['client_id'] = $_GET['client_id'] ?? 'unknown';
15+
session_write_close();
16+
$output[] = 'SESSION_SET';
17+
$output[] = 'secret=' . $_SESSION['secret'];
18+
break;
19+
20+
case 'get':
21+
// Read session and return values
22+
session_start();
23+
$output[] = 'SESSION_READ';
24+
$output[] = 'secret=' . ($_SESSION['secret'] ?? 'NOT_FOUND');
25+
$output[] = 'client_id=' . ($_SESSION['client_id'] ?? 'NOT_FOUND');
26+
$output[] = 'session_id=' . session_id();
27+
session_write_close();
28+
break;
29+
30+
case 'set_and_exit':
31+
// Set a value in session and exit without calling session_write_close
32+
session_start();
33+
$_SESSION['secret'] = $_GET['value'] ?? 'exit_secret';
34+
$_SESSION['client_id'] = $_GET['client_id'] ?? 'exit_client';
35+
// Intentionally NOT calling session_write_close() before exit
36+
$output[] = 'BEFORE_EXIT';
37+
echo implode("\n", $output);
38+
flush();
39+
exit(1);
40+
break;
41+
42+
case 'check_empty':
43+
// Check that session is empty (no leak from other clients)
44+
// Note: We intentionally do NOT call session_start() here.
45+
// $_SESSION should be empty without starting a session.
46+
// If data leaks from previous requests, this test will catch it.
47+
$output[] = 'SESSION_CHECK';
48+
if (empty($_SESSION)) {
49+
$output[] = 'SESSION_EMPTY=true';
50+
} else {
51+
$output[] = 'SESSION_EMPTY=false';
52+
$output[] = 'LEAKED_DATA=' . json_encode($_SESSION);
53+
}
54+
$output[] = 'session_id=' . session_id();
55+
break;
56+
57+
default:
58+
$output[] = 'UNKNOWN_ACTION';
59+
}
60+
61+
echo implode("\n", $output);
62+
};

0 commit comments

Comments
 (0)