Skip to content

Commit 3fade63

Browse files
authored
Merge commit from fork
The standard extension only resets this state in its RSHUTDOWN, which worker requests skip: - http_get_last_response_headers() returned the upstream headers fetched by a previous request; - strtok() without a string continued tokenizing a previous request's string; - the URL rewriter variables accumulated while its output handler never restarted after the first request.
1 parent 72f9366 commit 3fade63

4 files changed

Lines changed: 63 additions & 2 deletions

File tree

‎docs/security.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ These are the surfaces FrankenPHP owns. A vulnerability here is a FrankenPHP vul
4646

4747
- **Request to superglobal mapping** (`cgi.go`, `frankenphp_register_server_vars`): building `$_SERVER`, `REMOTE_ADDR`, `SCRIPT_NAME`, `PATH_INFO`, and the other CGI variables from the request.
4848
- **PHP script-path resolution**: the request path is split on `split_path` (`.php` by default) into `SCRIPT_NAME` / `PATH_INFO`, then joined to the document root with `sanitizedPathJoin` (`filepath.Join(root, filepath.Clean("/"+reqPath))`), which keeps `SCRIPT_FILENAME` from escaping the document root (path traversal). The `php_server` directive additionally sets a default `try_files` rewrite that routes requests to existing files or the front controller, mitigating the classic PHP-FPM pitfall of executing the wrong file.
49-
- **Worker-mode state isolation**: FrankenPHP resets `$_GET`, `$_POST`, `$_COOKIE`, `$_FILES`, `$_SERVER`, and `$_REQUEST` between requests, explicitly clears `$_SESSION` (which would otherwise leak between requests) and the errors returned by `error_get_last()` and `libxml_get_errors()`, but **`$_ENV` is not reset**, and `putenv()` writes, `static` variables, class static properties, globals, and runtime engine settings (`ini_set()`, the default stream context, the default timezone, the working directory, ...) persist across requests on the same thread. Request- or user-specific data left in that state can leak into a later request (see [Worker Mode](worker.md#state-persistence)).
49+
- **Worker-mode state isolation**: FrankenPHP resets `$_GET`, `$_POST`, `$_COOKIE`, `$_FILES`, `$_SERVER`, and `$_REQUEST` between requests, explicitly clears `$_SESSION` (which would otherwise leak between requests) and the errors returned by `error_get_last()` and `libxml_get_errors()`, and the headers returned by `http_get_last_response_headers()`, the `strtok()` state, and the variables added by `output_add_rewrite_var()`, but **`$_ENV` is not reset**, and `putenv()` writes, `static` variables, class static properties, globals, and runtime engine settings (`ini_set()`, the default stream context, the default timezone, the working directory, ...) persist across requests on the same thread. Request- or user-specific data left in that state can leak into a later request (see [Worker Mode](worker.md#state-persistence)).
5050
- **Per-thread environment sandboxing**: `frankenphp_putenv()` / `frankenphp_getenv()` operate on a thread-local `sandboxed_env`. `putenv()` writes stay inside that sandbox and never reach the process-global OS environment, so a secret set by one request cannot leak to child processes (`proc_open`, `exec`, ...), to the Go/Caddy side, or to other requests. A child process that must see such a variable has to be given it explicitly, through the environment argument of `proc_open()` or `exec()` (see [Internals](internals.md#per-thread-environment-sandboxing)).
5151
- **CGO memory boundary**: Go string pinning and `C.CString()` / `free()` lifetimes across the Go ↔ C boundary.
5252
- **Caddy admin API**: the `/frankenphp/workers/restart` and `/frankenphp/threads` endpoints, exposed through Caddy's admin API (which listens on `localhost:2019` by default). Exposing that endpoint beyond localhost is an operator decision.

‎frankenphp.c‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
#include <Zend/zend_alloc.h>
44
#include <Zend/zend_exceptions.h>
55
#include <Zend/zend_interfaces.h>
6+
#include <Zend/zend_smart_str.h>
67
#include <errno.h>
78
#include <ext/spl/spl_exceptions.h>
9+
#include <ext/standard/basic_functions.h>
810
#include <ext/standard/head.h>
911
#include <ext/standard/info.h>
1012
#include <ext/standard/php_fopen_wrappers.h>
@@ -543,6 +545,12 @@ static void frankenphp_reset_last_errors(void) {
543545
PG(last_error_type) = 0;
544546
PG(last_error_lineno) = 0;
545547

548+
#if PHP_VERSION_ID >= 80400
549+
/* http_get_last_response_headers() */
550+
zval_ptr_dtor(&BG(last_http_headers));
551+
ZVAL_UNDEF(&BG(last_http_headers));
552+
#endif
553+
546554
/* Called by name to avoid depending on libxml2 headers */
547555
zend_function *libxml_clear_errors = zend_hash_str_find_ptr(
548556
CG(function_table), ZEND_STRL("libxml_clear_errors"));
@@ -552,6 +560,32 @@ static void frankenphp_reset_last_errors(void) {
552560
}
553561
}
554562

563+
/* Adapted from the url_scanner RSHUTDOWN, which isn't exported */
564+
static void frankenphp_reset_url_scanner(url_adapt_state_ex_t *ctx) {
565+
smart_str_free(&ctx->result);
566+
smart_str_free(&ctx->buf);
567+
smart_str_free(&ctx->tag);
568+
smart_str_free(&ctx->arg);
569+
smart_str_free(&ctx->attr_val);
570+
smart_str_free(&ctx->form_app);
571+
smart_str_free(&ctx->url_app);
572+
ctx->active = 0;
573+
ctx->tag_type = 0;
574+
ctx->attr_type = 0;
575+
}
576+
577+
/* Request state of the standard extension, freed by its RSHUTDOWN */
578+
static void frankenphp_reset_basic_state(void) {
579+
if (BG(strtok_string)) {
580+
zend_string_release(BG(strtok_string));
581+
BG(strtok_string) = NULL;
582+
}
583+
584+
/* output_add_rewrite_var() and session.use_trans_sid */
585+
frankenphp_reset_url_scanner(&BG(url_adapt_session_ex));
586+
frankenphp_reset_url_scanner(&BG(url_adapt_output_ex));
587+
}
588+
555589
static frankenphp_thread_metrics *thread_metrics = NULL;
556590

557591
/* Adapted from php_request_shutdown */
@@ -582,6 +616,7 @@ static void frankenphp_worker_request_shutdown() {
582616
* etc.) */
583617
zend_try { php_output_deactivate(); }
584618
zend_end_try();
619+
frankenphp_reset_basic_state();
585620

586621
/* sapi_deactivate() drops the request body stream without closing it, it
587622
* would otherwise stay open until the worker script ends */

‎frankenphp_test.go‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1260,8 +1260,16 @@ func TestLastErrorsNoLeakBetweenRequests_worker(t *testing.T) {
12601260
LibxmlErrors *[]string `json:"libxml_errors"`
12611261
LibxmlLastError *string `json:"libxml_last_error"`
12621262
InternalErrors *bool `json:"internal_errors"`
1263+
LastResponse *[]string `json:"last_response_headers"`
1264+
Strtok any `json:"strtok"`
1265+
Rewriter *bool `json:"rewriter"`
12631266
}
12641267

1268+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1269+
w.Header().Set("X-Secret", r.URL.Query().Get("s"))
1270+
}))
1271+
t.Cleanup(upstream.Close)
1272+
12651273
get := func(url string, handler func(http.ResponseWriter, *http.Request)) lastErrors {
12661274
body, _ := testGet(url, handler, t)
12671275

@@ -1272,12 +1280,21 @@ func TestLastErrorsNoLeakBetweenRequests_worker(t *testing.T) {
12721280
}
12731281

12741282
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
1275-
leaked := get(fmt.Sprintf("http://example.com/worker-last-errors.php?leak=%d", i), handler)
1283+
fetch := url.QueryEscape(fmt.Sprintf("%s/?s=secret_%d", upstream.URL, i))
1284+
leaked := get(fmt.Sprintf("http://example.com/worker-last-errors.php?leak=%d&fetch=%s", i, fetch), handler)
12761285
require.NotNil(t, leaked.LastError)
12771286
assert.Equal(t, fmt.Sprintf("secret %d", i), *leaked.LastError)
12781287

12791288
e := get("http://example.com/worker-last-errors.php", handler)
12801289
assert.Nil(t, e.LastError)
1290+
assert.Equal(t, false, e.Strtok)
1291+
require.NotNil(t, e.Rewriter)
1292+
assert.True(t, *e.Rewriter, "the URL rewriter must restart in each request")
1293+
1294+
if leaked.LastResponse != nil {
1295+
assert.Contains(t, *leaked.LastResponse, fmt.Sprintf("X-Secret: secret_%d", i))
1296+
assert.Nil(t, e.LastResponse)
1297+
}
12811298

12821299
if leaked.LibxmlErrors == nil {
12831300
t.Log("libxml not available, skipping libxml assertions")

‎testdata/worker-last-errors.php‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,21 @@
1515
if (class_exists(DOMDocument::class)) {
1616
(new DOMDocument())->loadXML("<root>&secret_{$_GET['leak']};</root>");
1717
}
18+
strtok("secret {$_GET['leak']}", ' ');
19+
output_add_rewrite_var('token', "secret_{$_GET['leak']}");
20+
if (isset($_GET['fetch'])) {
21+
file_get_contents($_GET['fetch']);
22+
}
1823
}
1924

2025
echo json_encode([
2126
'last_error' => error_get_last()['message'] ?? null,
27+
// evaluated after last_error: strtok() warns when there is nothing to continue
28+
'strtok' => isset($_GET['leak']) ? null : @strtok(' '),
29+
'rewriter' => isset($_GET['leak']) ? null : output_add_rewrite_var('x', 'y') && in_array('URL-Rewriter', ob_list_handlers(), true),
2230
'libxml_errors' => $hasLibxml ? array_map(fn ($e) => trim($e->message), libxml_get_errors()) : null,
2331
'libxml_last_error' => $hasLibxml ? (libxml_get_last_error()->message ?? null) : null,
2432
'internal_errors' => $hasLibxml ? libxml_use_internal_errors() : null,
33+
'last_response_headers' => function_exists('http_get_last_response_headers') ? http_get_last_response_headers() : null,
2534
]);
2635
};

0 commit comments

Comments
 (0)