From 243421c3d9a4a8e251c74cafca123aef6a9ec1d1 Mon Sep 17 00:00:00 2001 From: George Zhang Date: Sun, 12 Oct 2025 14:54:50 +0800 Subject: [PATCH 1/5] feat: extract URL from X-Original-Path/Query headers Support header-based URL forwarding from proxyd Stage 2. If X-Original-Path header is present, reconstruct URL from headers. Otherwise fall back to r.URL (backward compatible). This enables clean separation between public API paths and internal service endpoints while maintaining consistency with other services. Context: - Public API paths like /fast are request metadata, not REST resources - Proxyd forwards these as X-Original-Path/Query headers - Internal services can serve at consistent endpoints (e.g., /api) - Backward compatible: if headers absent, uses r.URL as before --- rpcserver/jsonrpc_server.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/rpcserver/jsonrpc_server.go b/rpcserver/jsonrpc_server.go index 2350518..57c9433 100644 --- a/rpcserver/jsonrpc_server.go +++ b/rpcserver/jsonrpc_server.go @@ -277,8 +277,16 @@ func (h *JSONRPCHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } ctx = context.WithValue(ctx, signerKey{}, signer) } - // r.URL - ctx = context.WithValue(ctx, urlKey{}, r.URL) + // Extract URL from headers (Stage 2) or use r.URL directly (Stage 1) + reqURL := r.URL + if originalPath := r.Header.Get("X-Original-Path"); originalPath != "" { + // Stage 2: Reconstruct URL from headers sent by proxyd + reqURL = &url.URL{ + Path: originalPath, + RawQuery: r.Header.Get("X-Original-Query"), + } + } + ctx = context.WithValue(ctx, urlKey{}, reqURL) // read request var req jsonRPCRequest From 8b6d7d54cb1848bd5ad41952ff96bf7f8bf2f5d2 Mon Sep 17 00:00:00 2001 From: George Zhang Date: Sun, 12 Oct 2025 15:09:11 +0800 Subject: [PATCH 2/5] test: add URL extraction test for Stage 1 and Stage 2 --- rpcserver/jsonrpc_server_test.go | 59 ++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/rpcserver/jsonrpc_server_test.go b/rpcserver/jsonrpc_server_test.go index e6bed00..79f95eb 100644 --- a/rpcserver/jsonrpc_server_test.go +++ b/rpcserver/jsonrpc_server_test.go @@ -208,3 +208,62 @@ func TestJSONRPCServerReadyzError(t *testing.T) { fmt.Println(rr.Body.String()) require.Equal(t, "not ready\n", rr.Body.String()) } + +func TestURLExtraction(t *testing.T) { + // Handler that captures URL from context + var capturedURL string + handlerMethod := func(ctx context.Context) (string, error) { + url := GetURL(ctx) + capturedURL = url.Path + "?" + url.RawQuery + return capturedURL, nil + } + + handler, err := NewJSONRPCHandler(map[string]interface{}{ + "test": handlerMethod, + }, JSONRPCHandlerOpts{}) + require.NoError(t, err) + + t.Run("Stage 1: Direct URL (no headers)", func(t *testing.T) { + body := bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"test","params":[]}`)) + request, err := http.NewRequest(http.MethodPost, "/fast?hint=calldata", body) + require.NoError(t, err) + request.Header.Add("Content-Type", "application/json") + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, request) + + require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, "/fast?hint=calldata", capturedURL) + }) + + t.Run("Stage 2: Header-based URL", func(t *testing.T) { + body := bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"test","params":[]}`)) + request, err := http.NewRequest(http.MethodPost, "/", body) + require.NoError(t, err) + request.Header.Add("Content-Type", "application/json") + request.Header.Add("X-Original-Path", "/fast") + request.Header.Add("X-Original-Query", "hint=calldata&builder=flashbots") + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, request) + + require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, "/fast?hint=calldata&builder=flashbots", capturedURL) + }) + + t.Run("Stage 2: Headers override actual URL", func(t *testing.T) { + body := bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"test","params":[]}`)) + request, err := http.NewRequest(http.MethodPost, "/api?wrong=params", body) + require.NoError(t, err) + request.Header.Add("Content-Type", "application/json") + request.Header.Add("X-Original-Path", "/fast") + request.Header.Add("X-Original-Query", "hint=hash") + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, request) + + require.Equal(t, http.StatusOK, rr.Code) + // Headers take precedence + require.Equal(t, "/fast?hint=hash", capturedURL) + }) +} From 2abae1935cdf664fdaf54c2cba31afbce725ffa1 Mon Sep 17 00:00:00 2001 From: George Zhang Date: Sun, 12 Oct 2025 15:15:29 +0800 Subject: [PATCH 3/5] fix: handle independent path/query headers Proxyd may send X-Original-Path and X-Original-Query headers independently: - Only X-Original-Query when path is / (root) - Only X-Original-Path when there's no query string Updated logic to selectively replace path and query instead of requiring both headers to be present together. Added tests for edge cases. --- rpcserver/jsonrpc_server.go | 24 ++++++++++++++++++++---- rpcserver/jsonrpc_server_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/rpcserver/jsonrpc_server.go b/rpcserver/jsonrpc_server.go index 57c9433..27d9ed6 100644 --- a/rpcserver/jsonrpc_server.go +++ b/rpcserver/jsonrpc_server.go @@ -278,12 +278,28 @@ func (h *JSONRPCHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx = context.WithValue(ctx, signerKey{}, signer) } // Extract URL from headers (Stage 2) or use r.URL directly (Stage 1) + // Proxyd may send X-Original-Path and X-Original-Query independently reqURL := r.URL - if originalPath := r.Header.Get("X-Original-Path"); originalPath != "" { - // Stage 2: Reconstruct URL from headers sent by proxyd + originalPath := r.Header.Get("X-Original-Path") + originalQuery := r.Header.Get("X-Original-Query") + + // Only create new URL if at least one header is present + if originalPath != "" || originalQuery != "" { + // Start with actual URL values + path := r.URL.Path + query := r.URL.RawQuery + + // Replace with header values if present + if originalPath != "" { + path = originalPath + } + if originalQuery != "" { + query = originalQuery + } + reqURL = &url.URL{ - Path: originalPath, - RawQuery: r.Header.Get("X-Original-Query"), + Path: path, + RawQuery: query, } } ctx = context.WithValue(ctx, urlKey{}, reqURL) diff --git a/rpcserver/jsonrpc_server_test.go b/rpcserver/jsonrpc_server_test.go index 79f95eb..b01a172 100644 --- a/rpcserver/jsonrpc_server_test.go +++ b/rpcserver/jsonrpc_server_test.go @@ -266,4 +266,34 @@ func TestURLExtraction(t *testing.T) { // Headers take precedence require.Equal(t, "/fast?hint=hash", capturedURL) }) + + t.Run("Stage 2: Only query header (path is root)", func(t *testing.T) { + // Proxyd doesn't send X-Original-Path when path is "/" + body := bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"test","params":[]}`)) + request, err := http.NewRequest(http.MethodPost, "/", body) + require.NoError(t, err) + request.Header.Add("Content-Type", "application/json") + request.Header.Add("X-Original-Query", "hint=hash") + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, request) + + require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, "/?hint=hash", capturedURL) + }) + + t.Run("Stage 2: Only path header (no query)", func(t *testing.T) { + // Proxyd doesn't send X-Original-Query when there's no query string + body := bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"test","params":[]}`)) + request, err := http.NewRequest(http.MethodPost, "/api", body) + require.NoError(t, err) + request.Header.Add("Content-Type", "application/json") + request.Header.Add("X-Original-Path", "/fast") + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, request) + + require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, "/fast?", capturedURL) + }) } From ce2503b716052fa05e6f79c70c67a00a4fdca74f Mon Sep 17 00:00:00 2001 From: George Zhang Date: Sun, 12 Oct 2025 15:21:36 +0800 Subject: [PATCH 4/5] test: verify arbitrary paths work (/whatever, /anything, etc) Added explicit test per Tymur's requirement that simple methods should work with /fast, /whatever, and any other path - not just hardcoded paths. Tests /whatever, /anything, /custom-path, /v1/special to demonstrate the implementation is path-agnostic. --- rpcserver/jsonrpc_server_test.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/rpcserver/jsonrpc_server_test.go b/rpcserver/jsonrpc_server_test.go index b01a172..ab4a564 100644 --- a/rpcserver/jsonrpc_server_test.go +++ b/rpcserver/jsonrpc_server_test.go @@ -296,4 +296,25 @@ func TestURLExtraction(t *testing.T) { require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, "/fast?", capturedURL) }) + + t.Run("Stage 2: Arbitrary paths work (/whatever, /anything, etc)", func(t *testing.T) { + // Verify that any path works, not just /fast (Tymur's requirement) + testPaths := []string{"/whatever", "/anything", "/custom-path", "/v1/special"} + + for _, testPath := range testPaths { + body := bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"test","params":[]}`)) + request, err := http.NewRequest(http.MethodPost, "/", body) + require.NoError(t, err) + request.Header.Add("Content-Type", "application/json") + request.Header.Add("X-Original-Path", testPath) + request.Header.Add("X-Original-Query", "param=value") + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, request) + + require.Equal(t, http.StatusOK, rr.Code) + expectedURL := testPath + "?param=value" + require.Equal(t, expectedURL, capturedURL, "Failed for path: %s", testPath) + } + }) } From 512ce1187362992653483653f4478232c52d46c6 Mon Sep 17 00:00:00 2001 From: George Zhang Date: Sun, 12 Oct 2025 15:29:25 +0800 Subject: [PATCH 5/5] test: optimize to 4 high-ROI tests covering all cases Consolidated from 6 to 4 tests with maximum value density: - Backward compatibility (no headers) - Both headers with arbitrary path (/whatever proves not hardcoded) - Edge case: only query header - Edge case: only path header Removed redundant tests while maintaining full coverage. --- rpcserver/jsonrpc_server_test.go | 52 +++++--------------------------- 1 file changed, 8 insertions(+), 44 deletions(-) diff --git a/rpcserver/jsonrpc_server_test.go b/rpcserver/jsonrpc_server_test.go index ab4a564..ab4d1db 100644 --- a/rpcserver/jsonrpc_server_test.go +++ b/rpcserver/jsonrpc_server_test.go @@ -223,7 +223,7 @@ func TestURLExtraction(t *testing.T) { }, JSONRPCHandlerOpts{}) require.NoError(t, err) - t.Run("Stage 1: Direct URL (no headers)", func(t *testing.T) { + t.Run("No headers: uses r.URL (backward compat)", func(t *testing.T) { body := bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"test","params":[]}`)) request, err := http.NewRequest(http.MethodPost, "/fast?hint=calldata", body) require.NoError(t, err) @@ -236,38 +236,23 @@ func TestURLExtraction(t *testing.T) { require.Equal(t, "/fast?hint=calldata", capturedURL) }) - t.Run("Stage 2: Header-based URL", func(t *testing.T) { + t.Run("Both headers: reconstructs URL (works with any path)", func(t *testing.T) { + // Test with /whatever instead of /fast to prove it's not hardcoded body := bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"test","params":[]}`)) request, err := http.NewRequest(http.MethodPost, "/", body) require.NoError(t, err) request.Header.Add("Content-Type", "application/json") - request.Header.Add("X-Original-Path", "/fast") - request.Header.Add("X-Original-Query", "hint=calldata&builder=flashbots") - - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, request) - - require.Equal(t, http.StatusOK, rr.Code) - require.Equal(t, "/fast?hint=calldata&builder=flashbots", capturedURL) - }) - - t.Run("Stage 2: Headers override actual URL", func(t *testing.T) { - body := bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"test","params":[]}`)) - request, err := http.NewRequest(http.MethodPost, "/api?wrong=params", body) - require.NoError(t, err) - request.Header.Add("Content-Type", "application/json") - request.Header.Add("X-Original-Path", "/fast") - request.Header.Add("X-Original-Query", "hint=hash") + request.Header.Add("X-Original-Path", "/whatever") + request.Header.Add("X-Original-Query", "hint=hash&builder=flashbots") rr := httptest.NewRecorder() handler.ServeHTTP(rr, request) require.Equal(t, http.StatusOK, rr.Code) - // Headers take precedence - require.Equal(t, "/fast?hint=hash", capturedURL) + require.Equal(t, "/whatever?hint=hash&builder=flashbots", capturedURL) }) - t.Run("Stage 2: Only query header (path is root)", func(t *testing.T) { + t.Run("Only query header: uses r.URL.Path", func(t *testing.T) { // Proxyd doesn't send X-Original-Path when path is "/" body := bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"test","params":[]}`)) request, err := http.NewRequest(http.MethodPost, "/", body) @@ -282,7 +267,7 @@ func TestURLExtraction(t *testing.T) { require.Equal(t, "/?hint=hash", capturedURL) }) - t.Run("Stage 2: Only path header (no query)", func(t *testing.T) { + t.Run("Only path header: uses r.URL.RawQuery", func(t *testing.T) { // Proxyd doesn't send X-Original-Query when there's no query string body := bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"test","params":[]}`)) request, err := http.NewRequest(http.MethodPost, "/api", body) @@ -296,25 +281,4 @@ func TestURLExtraction(t *testing.T) { require.Equal(t, http.StatusOK, rr.Code) require.Equal(t, "/fast?", capturedURL) }) - - t.Run("Stage 2: Arbitrary paths work (/whatever, /anything, etc)", func(t *testing.T) { - // Verify that any path works, not just /fast (Tymur's requirement) - testPaths := []string{"/whatever", "/anything", "/custom-path", "/v1/special"} - - for _, testPath := range testPaths { - body := bytes.NewReader([]byte(`{"jsonrpc":"2.0","id":1,"method":"test","params":[]}`)) - request, err := http.NewRequest(http.MethodPost, "/", body) - require.NoError(t, err) - request.Header.Add("Content-Type", "application/json") - request.Header.Add("X-Original-Path", testPath) - request.Header.Add("X-Original-Query", "param=value") - - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, request) - - require.Equal(t, http.StatusOK, rr.Code) - expectedURL := testPath + "?param=value" - require.Equal(t, expectedURL, capturedURL, "Failed for path: %s", testPath) - } - }) }