Skip to content

Commit dd250e3

Browse files
perf: optimized request headers (#1335)
* Optimizes header registration. * Adds malformed cookie tests. * Sets key to NULL (releasing them is unnecessary) * Adjusts test. * Sanitizes null bytes anyways. * Sorts headers. * trigger * clang-format * More clang-format. * Updates headers and tests. * Adds header test. * Adds more headers. * Updates headers again. * ?Removes comments. * ?Reformats headers * ?Reformats headers * renames header files. * ?Renames test. * ?Fixes assertion. * test * test * test * Moves headers test to main package. * Properly capitalizes headers. * Allows and tests multiple cookie headers. * Fixes comment. * Adds otter back in. * Verifies correct capitalization. * Resets package version. * Removes debug log. * Makes persistent strings also interned and saves them once on the main thread. --------- Co-authored-by: Alliballibaba <alliballibaba@gmail.com>
1 parent 7e39e0a commit dd250e3

11 files changed

Lines changed: 246 additions & 80 deletions

cgi.go

Lines changed: 13 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"path/filepath"
1111
"strings"
1212
"unsafe"
13+
14+
"github.com/dunglas/frankenphp/internal/phpheaders"
1315
)
1416

1517
var knownServerKeys = []string{
@@ -47,7 +49,7 @@ var knownServerKeys = []string{
4749
// TODO: handle this case https://github.com/caddyserver/caddy/issues/3718
4850
// Inspired by https://github.com/caddyserver/caddy/blob/master/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go
4951
func addKnownVariablesToServer(thread *phpThread, request *http.Request, fc *FrankenPHPContext, trackVarsArray *C.zval) {
50-
keys := getKnownVariableKeys(thread)
52+
keys := mainThread.knownServerKeys
5153
// Separate remote IP and port; more lenient than net.SplitHostPort
5254
var ip, port string
5355
if idx := strings.LastIndex(request.RemoteAddr, ":"); idx > -1 {
@@ -158,18 +160,17 @@ func packCgiVariable(key *C.zend_string, value string) C.ht_key_value_pair {
158160
return C.ht_key_value_pair{key, toUnsafeChar(value), C.size_t(len(value))}
159161
}
160162

161-
func addHeadersToServer(request *http.Request, fc *FrankenPHPContext, trackVarsArray *C.zval) {
163+
func addHeadersToServer(request *http.Request, thread *phpThread, fc *FrankenPHPContext, trackVarsArray *C.zval) {
162164
for field, val := range request.Header {
163-
k, ok := headerKeyCache.Get(field)
164-
if !ok {
165-
k = "HTTP_" + headerNameReplacer.Replace(strings.ToUpper(field)) + "\x00"
166-
headerKeyCache.SetIfAbsent(field, k)
167-
}
168-
169-
if _, ok := fc.env[k]; ok {
165+
if k := mainThread.commonHeaders[field]; k != nil {
166+
v := strings.Join(val, ", ")
167+
C.frankenphp_register_single(k, toUnsafeChar(v), C.size_t(len(v)), trackVarsArray)
170168
continue
171169
}
172170

171+
// if the header name could not be cached, it needs to be registered safely
172+
// this is more inefficient but allows additional sanitizing by PHP
173+
k := phpheaders.GetUnCommonHeader(field)
173174
v := strings.Join(val, ", ")
174175
C.frankenphp_register_variable_safe(toUnsafeChar(k), toUnsafeChar(v), C.size_t(len(v)), trackVarsArray)
175176
}
@@ -182,39 +183,17 @@ func addPreparedEnvToServer(fc *FrankenPHPContext, trackVarsArray *C.zval) {
182183
fc.env = nil
183184
}
184185

185-
func getKnownVariableKeys(thread *phpThread) map[string]*C.zend_string {
186-
if thread.knownVariableKeys != nil {
187-
return thread.knownVariableKeys
188-
}
189-
threadServerKeys := make(map[string]*C.zend_string)
190-
for _, k := range knownServerKeys {
191-
threadServerKeys[k] = C.frankenphp_init_persistent_string(toUnsafeChar(k), C.size_t(len(k)))
192-
}
193-
thread.knownVariableKeys = threadServerKeys
194-
return threadServerKeys
195-
}
196-
197186
//export go_register_variables
198187
func go_register_variables(threadIndex C.uintptr_t, trackVarsArray *C.zval) {
199188
thread := phpThreads[threadIndex]
200189
r := thread.getActiveRequest()
201190
fc := r.Context().Value(contextKey).(*FrankenPHPContext)
202191

203192
addKnownVariablesToServer(thread, r, fc, trackVarsArray)
204-
addHeadersToServer(r, fc, trackVarsArray)
205-
addPreparedEnvToServer(fc, trackVarsArray)
206-
}
193+
addHeadersToServer(r, thread, fc, trackVarsArray)
207194

208-
//export go_frankenphp_release_known_variable_keys
209-
func go_frankenphp_release_known_variable_keys(threadIndex C.uintptr_t) {
210-
thread := phpThreads[threadIndex]
211-
if thread.knownVariableKeys == nil {
212-
return
213-
}
214-
for _, v := range thread.knownVariableKeys {
215-
C.frankenphp_release_zend_string(v)
216-
}
217-
thread.knownVariableKeys = nil
195+
// The Prepared Environment is registered last and can overwrite any previous values
196+
addPreparedEnvToServer(fc, trackVarsArray)
218197
}
219198

220199
// splitPos returns the index where path should
@@ -245,8 +224,6 @@ var tlsProtocolStrings = map[uint16]string{
245224
tls.VersionTLS13: "TLSv1.3",
246225
}
247226

248-
var headerNameReplacer = strings.NewReplacer(" ", "_", "-", "_")
249-
250227
// SanitizedPathJoin performs filepath.Join(root, reqPath) that
251228
// is safe against directory traversal attacks. It uses logic
252229
// similar to that in the Go standard library, specifically

frankenphp.c

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,12 @@ void frankenphp_register_trusted_var(zend_string *z_key, char *value,
683683
}
684684
}
685685

686+
void frankenphp_register_single(zend_string *z_key, char *value, size_t val_len,
687+
zval *track_vars_array) {
688+
HashTable *ht = Z_ARRVAL_P(track_vars_array);
689+
frankenphp_register_trusted_var(z_key, value, val_len, ht);
690+
}
691+
686692
/* Register known $_SERVER variables in bulk to avoid cgo overhead */
687693
void frankenphp_register_bulk(
688694
zval *track_vars_array, ht_key_value_pair remote_addr,
@@ -743,10 +749,15 @@ void frankenphp_register_bulk(
743749
request_uri.val_len, ht);
744750
}
745751

746-
/** Persistent strings are ignored by the PHP GC, we have to release these
747-
* ourselves **/
752+
/** Create an immutable zend_string that lasts for the whole process **/
748753
zend_string *frankenphp_init_persistent_string(const char *string, size_t len) {
749-
return zend_string_init(string, len, 1);
754+
/* persistent strings will be ignored by the GC at the end of a request */
755+
zend_string *z_string = zend_string_init(string, len, 1);
756+
757+
/* interned strings will not be ref counted by the GC */
758+
GC_ADD_FLAGS(z_string, IS_STR_INTERNED);
759+
760+
return z_string;
750761
}
751762

752763
void frankenphp_release_zend_string(zend_string *z_string) {
@@ -920,8 +931,6 @@ static void *php_thread(void *arg) {
920931
frankenphp_execute_script(scriptName));
921932
}
922933

923-
go_frankenphp_release_known_variable_keys(thread_index);
924-
925934
#ifdef ZTS
926935
ts_free_thread();
927936
#endif

frankenphp.go

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ import (
4343
"time"
4444
"unsafe"
4545

46-
"github.com/maypok86/otter"
4746
"go.uber.org/zap"
4847
"go.uber.org/zap/zapcore"
4948
// debug on Linux
@@ -524,17 +523,6 @@ func go_ub_write(threadIndex C.uintptr_t, cBuf *C.char, length C.int) (C.size_t,
524523
return C.size_t(i), C.bool(clientHasClosed(r))
525524
}
526525

527-
// There are around 60 common request headers according to https://en.wikipedia.org/wiki/List_of_HTTP_header_fields#Request_fields
528-
// Give some space for custom headers
529-
var headerKeyCache = func() otter.Cache[string, string] {
530-
c, err := otter.MustBuilder[string, string](256).Build()
531-
if err != nil {
532-
panic(err)
533-
}
534-
535-
return c
536-
}()
537-
538526
//export go_apache_request_headers
539527
func go_apache_request_headers(threadIndex C.uintptr_t, hasActiveRequest bool) (*C.go_string, C.size_t) {
540528
thread := phpThreads[threadIndex]
@@ -650,19 +638,17 @@ func go_read_post(threadIndex C.uintptr_t, cBuf *C.char, countBytes C.size_t) (r
650638

651639
//export go_read_cookies
652640
func go_read_cookies(threadIndex C.uintptr_t) *C.char {
653-
r := phpThreads[threadIndex].getActiveRequest()
654-
655-
cookies := r.Cookies()
656-
if len(cookies) == 0 {
641+
cookies := phpThreads[threadIndex].getActiveRequest().Header.Values("Cookie")
642+
cookie := strings.Join(cookies, "; ")
643+
if cookie == "" {
657644
return nil
658645
}
659-
cookieStrings := make([]string, len(cookies))
660-
for i, cookie := range cookies {
661-
cookieStrings[i] = cookie.String()
662-
}
646+
647+
// remove potential null bytes
648+
cookie = strings.ReplaceAll(cookie, "\x00", "")
663649

664650
// freed in frankenphp_free_request_context()
665-
return C.CString(strings.Join(cookieStrings, "; "))
651+
return C.CString(cookie)
666652
}
667653

668654
//export go_log

frankenphp.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ zend_string *frankenphp_init_persistent_string(const char *string, size_t len);
7272
void frankenphp_release_zend_string(zend_string *z_string);
7373
int frankenphp_reset_opcache(void);
7474

75+
void frankenphp_register_single(zend_string *z_key, char *value, size_t val_len,
76+
zval *track_vars_array);
7577
void frankenphp_register_bulk(
7678
zval *track_vars_array, ht_key_value_pair remote_addr,
7779
ht_key_value_pair remote_host, ht_key_value_pair remote_port,

frankenphp_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,31 @@ func testCookies(t *testing.T, opts *testOptions) {
321321
}, opts)
322322
}
323323

324+
func TestMalformedCookie(t *testing.T) {
325+
runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) {
326+
req := httptest.NewRequest("GET", "http://example.com/cookies.php", nil)
327+
req.Header.Add("Cookie", "foo =bar; ===;;==; .dot.=val ;\x00 ; PHPSESSID=1234")
328+
// Muliple Cookie header should be joined https://www.rfc-editor.org/rfc/rfc7540#section-8.1.2.5
329+
req.Header.Add("Cookie", "secondCookie=test; secondCookie=overwritten")
330+
w := httptest.NewRecorder()
331+
handler(w, req)
332+
333+
resp := w.Result()
334+
body, _ := io.ReadAll(resp.Body)
335+
336+
assert.Contains(t, string(body), "'foo_' => 'bar'")
337+
assert.Contains(t, string(body), "'_dot_' => 'val '")
338+
339+
// PHPSESSID should still be present since we remove the null byte
340+
assert.Contains(t, string(body), "'PHPSESSID' => '1234'")
341+
342+
// The cookie in the second headers should be present
343+
// but it should not be overwritten by following values
344+
assert.Contains(t, string(body), "'secondCookie' => 'test'")
345+
346+
}, &testOptions{nbParallelRequests: 1})
347+
}
348+
324349
func TestSession_module(t *testing.T) { testSession(t, nil) }
325350
func TestSession_worker(t *testing.T) {
326351
testSession(t, &testOptions{workerScript: "session.php"})

internal/phpheaders/phpheaders.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package phpheaders
2+
3+
import (
4+
"strings"
5+
6+
"github.com/maypok86/otter"
7+
)
8+
9+
// Translate header names to PHP header names
10+
// All headers in 'commonHeaders' can be cached and registered safely
11+
// All other headers must be sanitized
12+
// Note: net/http will capitalize lowercase headers, so we don't need to worry about case sensitivity
13+
var CommonRequestHeaders = map[string]string{
14+
"Accept": "HTTP_ACCEPT",
15+
"Accept-Charset": "HTTP_ACCEPT_CHARSET",
16+
"Accept-Encoding": "HTTP_ACCEPT_ENCODING",
17+
"Accept-Language": "HTTP_ACCEPT_LANGUAGE",
18+
"Access-Control-Request-Headers": "HTTP_ACCESS_CONTROL_REQUEST_HEADERS",
19+
"Access-Control-Request-Method": "HTTP_ACCESS_CONTROL_REQUEST_METHOD",
20+
"Authorization": "HTTP_AUTHORIZATION",
21+
"Cache-Control": "HTTP_CACHE_CONTROL",
22+
"Connection": "HTTP_CONNECTION",
23+
"Content-Disposition": "HTTP_CONTENT_DISPOSITION",
24+
"Content-Encoding": "HTTP_CONTENT_ENCODING",
25+
"Content-Length": "HTTP_CONTENT_LENGTH",
26+
"Content-Type": "HTTP_CONTENT_TYPE",
27+
"Cookie": "HTTP_COOKIE",
28+
"Date": "HTTP_DATE",
29+
"Device-Memory": "HTTP_DEVICE_MEMORY",
30+
"Dnt": "HTTP_DNT",
31+
"Downlink": "HTTP_DOWNLINK",
32+
"Dpr": "HTTP_DPR",
33+
"Early-Data": "HTTP_EARLY_DATA",
34+
"Ect": "HTTP_ECT",
35+
"Am-I": "HTTP_AM_I",
36+
"Expect": "HTTP_EXPECT",
37+
"Forwarded": "HTTP_FORWARDED",
38+
"From": "HTTP_FROM",
39+
"Host": "HTTP_HOST",
40+
"If-Match": "HTTP_IF_MATCH",
41+
"If-Modified-Since": "HTTP_IF_MODIFIED_SINCE",
42+
"If-None-Match": "HTTP_IF_NONE_MATCH",
43+
"If-Range": "HTTP_IF_RANGE",
44+
"If-Unmodified-Since": "HTTP_IF_UNMODIFIED_SINCE",
45+
"Keep-Alive": "HTTP_KEEP_ALIVE",
46+
"Max-Forwards": "HTTP_MAX_FORWARDS",
47+
"Origin": "HTTP_ORIGIN",
48+
"Pragma": "HTTP_PRAGMA",
49+
"Proxy-Authorization": "HTTP_PROXY_AUTHORIZATION",
50+
"Range": "HTTP_RANGE",
51+
"Referer": "HTTP_REFERER",
52+
"Rtt": "HTTP_RTT",
53+
"Save-Data": "HTTP_SAVE_DATA",
54+
"Sec-Ch-Ua": "HTTP_SEC_CH_UA",
55+
"Sec-Ch-Ua-Arch": "HTTP_SEC_CH_UA_ARCH",
56+
"Sec-Ch-Ua-Bitness": "HTTP_SEC_CH_UA_BITNESS",
57+
"Sec-Ch-Ua-Full-Version": "HTTP_SEC_CH_UA_FULL_VERSION",
58+
"Sec-Ch-Ua-Full-Version-List": "HTTP_SEC_CH_UA_FULL_VERSION_LIST",
59+
"Sec-Ch-Ua-Mobile": "HTTP_SEC_CH_UA_MOBILE",
60+
"Sec-Ch-Ua-Model": "HTTP_SEC_CH_UA_MODEL",
61+
"Sec-Ch-Ua-Platform": "HTTP_SEC_CH_UA_PLATFORM",
62+
"Sec-Ch-Ua-Platform-Version": "HTTP_SEC_CH_UA_PLATFORM_VERSION",
63+
"Sec-Fetch-Dest": "HTTP_SEC_FETCH_DEST",
64+
"Sec-Fetch-Mode": "HTTP_SEC_FETCH_MODE",
65+
"Sec-Fetch-Site": "HTTP_SEC_FETCH_SITE",
66+
"Sec-Fetch-User": "HTTP_SEC_FETCH_USER",
67+
"Sec-Gpc": "HTTP_SEC_GPC",
68+
"Service-Worker-Navigation-Preload": "HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD",
69+
"Te": "HTTP_TE",
70+
"Priority": "HTTP_PRIORITY",
71+
"Trailer": "HTTP_TRAILER",
72+
"Transfer-Encoding": "HTTP_TRANSFER_ENCODING",
73+
"Upgrade": "HTTP_UPGRADE",
74+
"Upgrade-Insecure-Requests": "HTTP_UPGRADE_INSECURE_REQUESTS",
75+
"User-Agent": "HTTP_USER_AGENT",
76+
"Via": "HTTP_VIA",
77+
"Viewport-Width": "HTTP_VIEWPORT_WIDTH",
78+
"Want-Digest": "HTTP_WANT_DIGEST",
79+
"Warning": "HTTP_WARNING",
80+
"Width": "HTTP_WIDTH",
81+
"X-Forwarded-For": "HTTP_X_FORWARDED_FOR",
82+
"X-Forwarded-Host": "HTTP_X_FORWARDED_HOST",
83+
"X-Forwarded-Proto": "HTTP_X_FORWARDED_PROTO",
84+
"A-Im": "HTTP_A_IM",
85+
"Accept-Datetime": "HTTP_ACCEPT_DATETIME",
86+
"Content-Md5": "HTTP_CONTENT_MD5",
87+
"Http2-Settings": "HTTP_HTTP2_SETTINGS",
88+
"Prefer": "HTTP_PREFER",
89+
"X-Requested-With": "HTTP_X_REQUESTED_WITH",
90+
"Front-End-Https": "HTTP_FRONT_END_HTTPS",
91+
"X-Http-Method-Override": "HTTP_X_HTTP_METHOD_OVERRIDE",
92+
"X-Att-Deviceid": "HTTP_X_ATT_DEVICEID",
93+
"X-Wap-Profile": "HTTP_X_WAP_PROFILE",
94+
"Proxy-Connection": "HTTP_PROXY_CONNECTION",
95+
"X-Uidh": "HTTP_X_UIDH",
96+
"X-Csrf-Token": "HTTP_X_CSRF_TOKEN",
97+
"X-Request-Id": "HTTP_X_REQUEST_ID",
98+
"X-Correlation-Id": "HTTP_X_CORRELATION_ID",
99+
// Additional CDN/Framework headers
100+
"Cloudflare-Visitor": "HTTP_CLOUDFLARE_VISITOR",
101+
"Cloudfront-Viewer-Address": "HTTP_CLOUDFRONT_VIEWER_ADDRESS",
102+
"Cloudfront-Viewer-Country": "HTTP_CLOUDFRONT_VIEWER_COUNTRY",
103+
"X-Amzn-Trace-Id": "HTTP_X_AMZN_TRACE_ID",
104+
"X-Cloud-Trace-Context": "HTTP_X_CLOUD_TRACE_CONTEXT",
105+
"Cf-Ray": "HTTP_CF_RAY",
106+
"Cf-Visitor": "HTTP_CF_VISITOR",
107+
"Cf-Request-Id": "HTTP_CF_REQUEST_ID",
108+
"Cf-Ipcountry": "HTTP_CF_IPCOUNTRY",
109+
"X-Device-Type": "HTTP_X_DEVICE_TYPE",
110+
"X-Network-Info": "HTTP_X_NETWORK_INFO",
111+
"X-Client-Id": "HTTP_X_CLIENT_ID",
112+
"X-Livewire": "HTTP_X_LIVEWIRE",
113+
}
114+
115+
// Cache up to 256 uncommon headers
116+
// This is ~2.5x faster than converting the header each time
117+
var headerKeyCache = func() otter.Cache[string, string] {
118+
c, err := otter.MustBuilder[string, string](256).Build()
119+
if err != nil {
120+
panic(err)
121+
}
122+
123+
return c
124+
}()
125+
126+
var headerNameReplacer = strings.NewReplacer(" ", "_", "-", "_")
127+
128+
func GetUnCommonHeader(key string) string {
129+
phpHeaderKey, ok := headerKeyCache.Get(key)
130+
if !ok {
131+
phpHeaderKey = "HTTP_" + headerNameReplacer.Replace(strings.ToUpper(key)) + "\x00"
132+
headerKeyCache.SetIfAbsent(key, phpHeaderKey)
133+
}
134+
135+
return phpHeaderKey
136+
}

0 commit comments

Comments
 (0)