From 1aa143d291af457ba6ce959a0f046c586d220892 Mon Sep 17 00:00:00 2001 From: Daniel Liu <139250065@qq.com> Date: Fri, 10 Apr 2026 09:12:52 +0800 Subject: [PATCH] fix(console,internal/ethapi,node,rpc): restrict debug_setHead to local transports Add a local-only RPC API classification and use it to keep debug_setHead available over in-process RPC and IPC while hiding it from HTTP and WebSocket transports. This also updates the admin RPC startup path and adds regression coverage for console visibility, transport exposure, and local-only API leakage. --- cmd/XDC/consolecmd.go | 68 ++++++++---- cmd/XDC/consolecmd_test.go | 88 ++++++++++++++- cmd/XDC/run_test.go | 19 ++++ console/console.go | 91 ++++++++++++---- console/console_test.go | 175 +++++++++++++++++++++++++++++- internal/ethapi/api.go | 14 ++- internal/ethapi/api_local_test.go | 93 ++++++++++++++++ internal/ethapi/backend.go | 5 + internal/jsre/pretty.go | 44 +++++++- node/api.go | 6 +- node/api_test.go | 60 ++++++++++ node/node.go | 25 ++++- rpc/types.go | 1 + 13 files changed, 630 insertions(+), 59 deletions(-) create mode 100644 internal/ethapi/api_local_test.go diff --git a/cmd/XDC/consolecmd.go b/cmd/XDC/consolecmd.go index 30f9295b5851..54b02958fc15 100644 --- a/cmd/XDC/consolecmd.go +++ b/cmd/XDC/consolecmd.go @@ -18,6 +18,7 @@ package main import ( "fmt" + "net/url" "os" "os/signal" "path/filepath" @@ -85,10 +86,11 @@ func localConsole(ctx *cli.Context) error { utils.Fatalf("Failed to attach to the inproc XDC: %v", err) } config := console.Config{ - DataDir: utils.MakeDataDir(ctx), - DocRoot: ctx.String(utils.JSpathFlag.Name), - Client: client, - Preload: utils.MakeConsolePreloads(ctx), + DataDir: utils.MakeDataDir(ctx), + DocRoot: ctx.String(utils.JSpathFlag.Name), + Client: client, + LocalTransport: true, + Preload: utils.MakeConsolePreloads(ctx), } console, err := console.New(config) @@ -132,15 +134,16 @@ func remoteConsole(ctx *cli.Context) error { endpoint = fmt.Sprintf("%s/XDC.ipc", path) } - client, err := dialRPC(endpoint) + client, localTransport, err := dialRPC(endpoint) if err != nil { utils.Fatalf("Unable to attach to remote XDC: %v", err) } config := console.Config{ - DataDir: utils.MakeDataDir(ctx), - DocRoot: ctx.String(utils.JSpathFlag.Name), - Client: client, - Preload: utils.MakeConsolePreloads(ctx), + DataDir: utils.MakeDataDir(ctx), + DocRoot: ctx.String(utils.JSpathFlag.Name), + Client: client, + LocalTransport: localTransport, + Preload: utils.MakeConsolePreloads(ctx), } console, err := console.New(config) @@ -164,15 +167,39 @@ func remoteConsole(ctx *cli.Context) error { // dialRPC returns a RPC client which connects to the given endpoint. // The check for empty endpoint implements the defaulting logic // for "XDC attach" and "XDC monitor" with no argument. -func dialRPC(endpoint string) (*rpc.Client, error) { +func dialRPC(endpoint string) (*rpc.Client, bool, error) { + endpoint, localTransport := resolveConsoleEndpoint(endpoint) + client, err := rpc.Dial(endpoint) + return client, localTransport, err +} + +func resolveConsoleEndpoint(endpoint string) (string, bool) { if endpoint == "" { - endpoint = node.DefaultIPCEndpoint(clientIdentifier) - } else if strings.HasPrefix(endpoint, "rpc:") || strings.HasPrefix(endpoint, "ipc:") { - // Backwards compatibility with geth < 1.5 which required - // these prefixes. - endpoint = endpoint[4:] + return node.DefaultIPCEndpoint(clientIdentifier), true + } + if strings.HasPrefix(endpoint, "ipc:") { + // Backwards compatibility with geth < 1.5 which required these prefixes. + return endpoint[4:], true + } + // Backwards compatibility with geth < 1.5 which required this prefix. + // Strip the legacy prefix, then classify the resulting endpoint based + // on its actual transport instead of assuming it is local. + endpoint = strings.TrimPrefix(endpoint, "rpc:") + if endpoint == "stdio" { + return endpoint, false + } + u, err := url.Parse(endpoint) + if err != nil { + return endpoint, false + } + switch u.Scheme { + case "http", "https", "ws", "wss", "stdio": + return endpoint, false + case "": + return endpoint, true + default: + return endpoint, false } - return rpc.Dial(endpoint) } // ephemeralConsole starts a new XDC node, attaches an ephemeral JavaScript @@ -190,10 +217,11 @@ func ephemeralConsole(ctx *cli.Context) error { utils.Fatalf("Failed to attach to the inproc XDC: %v", err) } config := console.Config{ - DataDir: utils.MakeDataDir(ctx), - DocRoot: ctx.String(utils.JSpathFlag.Name), - Client: client, - Preload: utils.MakeConsolePreloads(ctx), + DataDir: utils.MakeDataDir(ctx), + DocRoot: ctx.String(utils.JSpathFlag.Name), + Client: client, + LocalTransport: true, + Preload: utils.MakeConsolePreloads(ctx), } console, err := console.New(config) diff --git a/cmd/XDC/consolecmd_test.go b/cmd/XDC/consolecmd_test.go index 5d5ebb47d623..01fed72ce7f1 100644 --- a/cmd/XDC/consolecmd_test.go +++ b/cmd/XDC/consolecmd_test.go @@ -19,6 +19,7 @@ package main import ( "crypto/rand" "math/big" + "net" "path/filepath" "runtime" "strconv" @@ -96,7 +97,7 @@ func TestIPCAttachWelcome(t *testing.T) { func TestHTTPAttachWelcome(t *testing.T) { coinbase := "0x8605cdbbdb6d264aa742e77020dcbc58fcdce182" - port := strconv.Itoa(trulyRandInt(1024, 65536)) // Yeah, sometimes this will fail, sorry :P + port := strconv.Itoa(freeTCPPort(t)) datadir := t.TempDir() XDC := runXDC(t, "--datadir", datadir, "--XDCx-datadir", datadir+"/XDCx", @@ -112,7 +113,7 @@ func TestHTTPAttachWelcome(t *testing.T) { func TestWSAttachWelcome(t *testing.T) { coinbase := "0x8605cdbbdb6d264aa742e77020dcbc58fcdce182" - port := strconv.Itoa(trulyRandInt(1024, 65536)) // Yeah, sometimes this will fail, sorry :P + port := strconv.Itoa(freeTCPPort(t)) datadir := t.TempDir() XDC := runXDC(t, "--datadir", datadir, "--XDCx-datadir", datadir+"/XDCx", @@ -160,6 +161,89 @@ at block: 0 ({{niltime}}){{if ipc}} attach.ExpectExit() } +func TestResolveConsoleEndpoint(t *testing.T) { + tests := []struct { + name string + endpoint string + wantLocal bool + wantPrefix string + }{ + {name: "default ipc endpoint", endpoint: "", wantLocal: true, wantPrefix: ""}, + {name: "explicit ipc path", endpoint: "/tmp/XDC.ipc", wantLocal: true, wantPrefix: "/tmp/XDC.ipc"}, + {name: "legacy ipc prefix", endpoint: "ipc:/tmp/XDC.ipc", wantLocal: true, wantPrefix: "/tmp/XDC.ipc"}, + {name: "legacy rpc prefix", endpoint: "rpc:/tmp/XDC.ipc", wantLocal: true, wantPrefix: "/tmp/XDC.ipc"}, + {name: "windows drive path stays unsupported", endpoint: `C:\\Users\\tester\\XDC.ipc`, wantLocal: false, wantPrefix: `C:\\Users\\tester\\XDC.ipc`}, + {name: "windows drive slash path stays unsupported", endpoint: "C:/Users/tester/XDC.ipc", wantLocal: false, wantPrefix: "C:/Users/tester/XDC.ipc"}, + {name: "legacy rpc windows drive path stays unsupported", endpoint: `rpc:C:\\Users\\tester\\XDC.ipc`, wantLocal: false, wantPrefix: `C:\\Users\\tester\\XDC.ipc`}, + {name: "legacy rpc http prefix", endpoint: "rpc:http://localhost:8545", wantPrefix: "http://localhost:8545", wantLocal: false}, + {name: "legacy rpc ws prefix", endpoint: "rpc:ws://localhost:8546", wantPrefix: "ws://localhost:8546", wantLocal: false}, + {name: "stdio endpoint", endpoint: "stdio", wantLocal: false, wantPrefix: "stdio"}, + {name: "legacy rpc stdio prefix", endpoint: "rpc:stdio", wantLocal: false, wantPrefix: "stdio"}, + {name: "http endpoint", endpoint: "http://localhost:8545", wantLocal: false, wantPrefix: "http://localhost:8545"}, + {name: "ws endpoint", endpoint: "ws://localhost:8546", wantLocal: false, wantPrefix: "ws://localhost:8546"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + gotEndpoint, gotLocal := resolveConsoleEndpoint(test.endpoint) + if gotLocal != test.wantLocal { + t.Fatalf("unexpected local transport classification: got %v want %v", gotLocal, test.wantLocal) + } + if test.wantPrefix == "" { + if !strings.HasSuffix(gotEndpoint, "XDC.ipc") { + t.Fatalf("expected default IPC endpoint, got %q", gotEndpoint) + } + return + } + if gotEndpoint != test.wantPrefix { + t.Fatalf("unexpected resolved endpoint: got %q want %q", gotEndpoint, test.wantPrefix) + } + }) + } +} + +func TestDialRPCRejectsWindowsDrivePaths(t *testing.T) { + tests := []struct { + name string + endpoint string + }{ + {name: "windows drive path", endpoint: `C:\\Users\\tester\\XDC.ipc`}, + {name: "windows drive slash path", endpoint: "C:/Users/tester/XDC.ipc"}, + {name: "legacy rpc windows drive path", endpoint: `rpc:C:\\Users\\tester\\XDC.ipc`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client, local, err := dialRPC(test.endpoint) + if client != nil { + client.Close() + t.Fatal("expected dialRPC to reject Windows drive-letter path") + } + if err == nil { + t.Fatal("expected dialRPC to fail for Windows drive-letter path") + } + if local { + t.Fatal("expected Windows drive-letter path to stay classified as non-local") + } + if !strings.Contains(err.Error(), `no known transport for URL scheme "c"`) { + t.Fatalf("unexpected dialRPC error: %v", err) + } + }) + } +} + +func freeTCPPort(t *testing.T) int { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to allocate test port: %v", err) + } + defer listener.Close() + + return listener.Addr().(*net.TCPAddr).Port +} + // trulyRandInt generates a crypto random integer used by the console tests to // not clash network ports with other tests running cocurrently. func trulyRandInt(lo, hi int) int { diff --git a/cmd/XDC/run_test.go b/cmd/XDC/run_test.go index 10f3f85c7ab2..6b77e91c04a3 100644 --- a/cmd/XDC/run_test.go +++ b/cmd/XDC/run_test.go @@ -57,6 +57,16 @@ func TestMain(m *testing.M) { func runXDC(t *testing.T, args ...string) *testXDC { tt := &testXDC{} tt.TestCmd = cmdtest.NewTestCmd(t, tt) + var extraArgs []string + if !hasArg(args, "--http-port") { + extraArgs = append(extraArgs, "--http-port", "0") + } + if !hasArg(args, "--ws-port") { + extraArgs = append(extraArgs, "--ws-port", "0") + } + if len(extraArgs) > 0 { + args = append(extraArgs, args...) + } for i, arg := range args { switch arg { case "--datadir": @@ -82,3 +92,12 @@ func runXDC(t *testing.T, args ...string) *testXDC { return tt } + +func hasArg(args []string, want string) bool { + for _, arg := range args { + if arg == want { + return true + } + } + return false +} diff --git a/console/console.go b/console/console.go index 1b3f8af42308..bcae76e45181 100644 --- a/console/console.go +++ b/console/console.go @@ -51,26 +51,28 @@ const DefaultPrompt = "> " // Config is the collection of configurations to fine tune the behavior of the // JavaScript console. type Config struct { - DataDir string // Data directory to store the console history at - DocRoot string // Filesystem path from where to load JavaScript files from - Client *rpc.Client // RPC client to execute Ethereum requests through - Prompt string // Input prompt prefix string (defaults to DefaultPrompt) - Prompter UserPrompter // Input prompter to allow interactive user feedback (defaults to TerminalPrompter) - Printer io.Writer // Output writer to serialize any display strings to (defaults to os.Stdout) - Preload []string // Absolute paths to JavaScript files to preload + DataDir string // Data directory to store the console history at + DocRoot string // Filesystem path from where to load JavaScript files from + Client *rpc.Client // RPC client to execute Ethereum requests through + LocalTransport bool // Whether the console is attached over an in-process or IPC transport + Prompt string // Input prompt prefix string (defaults to DefaultPrompt) + Prompter UserPrompter // Input prompter to allow interactive user feedback (defaults to TerminalPrompter) + Printer io.Writer // Output writer to serialize any display strings to (defaults to os.Stdout) + Preload []string // Absolute paths to JavaScript files to preload } // Console is a JavaScript interpreted runtime environment. It is a fully fleged // JavaScript console attached to a running node via an external or in-process RPC // client. type Console struct { - client *rpc.Client // RPC client to execute Ethereum requests through - jsre *jsre.JSRE // JavaScript runtime environment running the interpreter - prompt string // Input prompt prefix string - prompter UserPrompter // Input prompter to allow interactive user feedback - histPath string // Absolute path to the console scrollback history - history []string // Scroll history maintained by the console - printer io.Writer // Output writer to serialize any display strings to + client *rpc.Client // RPC client to execute Ethereum requests through + jsre *jsre.JSRE // JavaScript runtime environment running the interpreter + localTransport bool // Whether the connected transport is in-process or IPC + prompt string // Input prompt prefix string + prompter UserPrompter // Input prompter to allow interactive user feedback + histPath string // Absolute path to the console scrollback history + history []string // Scroll history maintained by the console + printer io.Writer // Output writer to serialize any display strings to } // New initializes a JavaScript interpreted runtime environment and sets defaults @@ -89,12 +91,13 @@ func New(config Config) (*Console, error) { // Initialize the console and return console := &Console{ - client: config.Client, - jsre: jsre.New(config.DocRoot, config.Printer), - prompt: config.Prompt, - prompter: config.Prompter, - printer: config.Printer, - histPath: filepath.Join(config.DataDir, HistoryFile), + client: config.Client, + jsre: jsre.New(config.DocRoot, config.Printer), + localTransport: config.LocalTransport, + prompt: config.Prompt, + prompter: config.Prompter, + printer: config.Printer, + histPath: filepath.Join(config.DataDir, HistoryFile), } if err := os.MkdirAll(config.DataDir, 0700); err != nil { return nil, err @@ -207,9 +210,41 @@ func (c *Console) initExtensions() error { } } }) + if !c.localTransport { + c.hideUnavailableDebugMethods() + } return nil } +func (c *Console) hideUnavailableDebugMethods() { + c.jsre.Do(func(vm *goja.Runtime) { + if _, err := vm.RunString(` + (function() { + function hideMethod(obj, hidden) { + if (obj == null) { + return; + } + Object.defineProperty(obj, hidden, { + value: undefined, + writable: true, + configurable: true, + enumerable: false + }); + } + + if (typeof debug !== "undefined") { + hideMethod(debug, "setHead"); + } + if (typeof web3 !== "undefined" && web3 !== null) { + hideMethod(web3.debug, "setHead"); + } + })(); + `); err != nil { + panic(err) + } + }) +} + // initAdmin creates additional admin APIs implemented by the bridge. func (c *Console) initAdmin(vm *goja.Runtime, bridge *bridge) { if admin := getObject(vm, "admin"); admin != nil { @@ -260,7 +295,21 @@ func (c *Console) AutoCompleteInput(line string, pos int) (string, []string, str start++ break } - return line[:start], c.jsre.CompleteKeywords(line[start:pos]), line[pos:] + return line[:start], c.filterCompletions(c.jsre.CompleteKeywords(line[start:pos])), line[pos:] +} + +func (c *Console) filterCompletions(completions []string) []string { + if c.localTransport { + return completions + } + filtered := completions[:0] + for _, completion := range completions { + if completion == "debug.setHead" || completion == "debug.setHead(" || completion == "debug.setHead." || completion == "web3.debug.setHead" || completion == "web3.debug.setHead(" || completion == "web3.debug.setHead." { + continue + } + filtered = append(filtered, completion) + } + return filtered } // Welcome show summary of current Geth instance and some metadata about the diff --git a/console/console_test.go b/console/console_test.go index acc710d222b2..8d4953ff26ef 100644 --- a/console/console_test.go +++ b/console/console_test.go @@ -20,6 +20,7 @@ import ( "bytes" "errors" "os" + "slices" "strings" "testing" "time" @@ -28,12 +29,15 @@ import ( "github.com/XinFinOrg/XDPoSChain/XDCxlending" "github.com/XinFinOrg/XDPoSChain/common" + "github.com/XinFinOrg/XDPoSChain/common/hexutil" "github.com/XinFinOrg/XDPoSChain/consensus/ethash" "github.com/XinFinOrg/XDPoSChain/core" "github.com/XinFinOrg/XDPoSChain/eth" "github.com/XinFinOrg/XDPoSChain/eth/ethconfig" "github.com/XinFinOrg/XDPoSChain/internal/jsre" "github.com/XinFinOrg/XDPoSChain/node" + "github.com/XinFinOrg/XDPoSChain/rpc" + "github.com/dop251/goja" ) const ( @@ -120,12 +124,13 @@ func newTester(t *testing.T, confOverride func(*ethconfig.Config)) *tester { printer := new(bytes.Buffer) console, err := New(Config{ - DataDir: stack.DataDir(), - DocRoot: "testdata", - Client: client, - Prompter: prompter, - Printer: printer, - Preload: []string{"preload.js"}, + DataDir: stack.DataDir(), + DocRoot: "testdata", + Client: client, + LocalTransport: true, + Prompter: prompter, + Printer: printer, + Preload: []string{"preload.js"}, }) if err != nil { t.Fatalf("failed to create JavaScript console: %v", err) @@ -164,6 +169,164 @@ func TestEvaluate(t *testing.T) { } } +type debugPrintAndSetHeadRPC struct{} + +func (debugPrintAndSetHeadRPC) PrintBlock(uint64) (string, error) { + return "ok", nil +} + +func (debugPrintAndSetHeadRPC) SetHead(hexutil.Uint64) error { + return nil +} + +func TestConsoleHidesUnavailableDebugSetHead(t *testing.T) { + t.Run("hidden on remote transport", func(t *testing.T) { + console := newRPCConsole(t, debugPrintAndSetHeadRPC{}, false) + defer stopConsole(t, console) + assertDebugSetHeadVisible(t, console, false) + assertDebugSetHeadNotListed(t, console) + assertDebugSetHeadNotPrinted(t, console) + assertDebugSetHeadCompletion(t, console, false) + }) + + t.Run("kept on local transport", func(t *testing.T) { + console := newRPCConsole(t, debugPrintAndSetHeadRPC{}, true) + defer stopConsole(t, console) + assertDebugSetHeadVisible(t, console, true) + assertDebugSetHeadCompletion(t, console, true) + }) +} + +func newRPCConsole(t *testing.T, debugService interface{}, localTransport bool) *Console { + t.Helper() + + server := rpc.NewServer() + if err := server.RegisterName("debug", debugService); err != nil { + t.Fatalf("failed to register debug service: %v", err) + } + client := rpc.DialInProc(server) + + console, err := New(Config{ + DataDir: t.TempDir(), + DocRoot: "testdata", + Client: client, + LocalTransport: localTransport, + Printer: new(bytes.Buffer), + }) + if err != nil { + client.Close() + t.Fatalf("failed to create console: %v", err) + } + t.Cleanup(func() { + client.Close() + }) + return console +} + +func stopConsole(t *testing.T, console *Console) { + t.Helper() + if err := console.Stop(false); err != nil { + t.Fatalf("failed to stop console: %v", err) + } +} + +func assertDebugSetHeadVisible(t *testing.T, console *Console, want bool) { + t.Helper() + + console.jsre.Do(func(vm *goja.Runtime) { + debug := getObject(vm, "debug") + if debug == nil { + t.Fatal("debug object is not available") + } + got := !goja.IsUndefined(debug.Get("setHead")) + if got != want { + t.Fatalf("unexpected debug.setHead visibility: got %v want %v", got, want) + } + }) +} + +func assertDebugSetHeadCompletion(t *testing.T, console *Console, want bool) { + t.Helper() + + tests := []struct { + input string + hidden []string + }{ + {input: "debug.setH", hidden: []string{"debug.setHead", "debug.setHead(", "debug.setHead."}}, + {input: "debug.setHead", hidden: []string{"debug.setHead", "debug.setHead(", "debug.setHead."}}, + {input: "web3.debug.setH", hidden: []string{"web3.debug.setHead", "web3.debug.setHead(", "web3.debug.setHead."}}, + {input: "web3.debug.setHead", hidden: []string{"web3.debug.setHead", "web3.debug.setHead(", "web3.debug.setHead."}}, + } + for _, test := range tests { + _, completions, _ := console.AutoCompleteInput(test.input, len(test.input)) + got := false + for _, completion := range completions { + if slices.Contains(test.hidden, completion) { + got = true + break + } + } + if got != want { + t.Fatalf("unexpected debug.setHead completion visibility for %q: got %v want %v (completions=%v)", test.input, got, want, completions) + } + } +} + +func assertDebugSetHeadNotListed(t *testing.T, console *Console) { + t.Helper() + + console.jsre.Do(func(vm *goja.Runtime) { + assertNoSetHeadInObjectKeys(t, vm, "debug") + assertNoSetHeadInObjectKeys(t, vm, "web3.debug") + }) +} + +func assertNoSetHeadInObjectKeys(t *testing.T, vm *goja.Runtime, expression string) { + t.Helper() + + value, err := vm.RunString("Object.keys(" + expression + ")") + if err != nil { + t.Fatalf("failed to evaluate Object.keys(%s): %v", expression, err) + } + keys := value.Export() + switch keys := keys.(type) { + case []interface{}: + for _, key := range keys { + if key == "setHead" { + t.Fatalf("debug.setHead should not appear in Object.keys(%s): %v", expression, keys) + } + } + case []string: + for _, key := range keys { + if key == "setHead" { + t.Fatalf("debug.setHead should not appear in Object.keys(%s): %v", expression, keys) + } + } + default: + t.Fatalf("unexpected Object.keys(%s) result type %T", expression, keys) + } +} + +func assertDebugSetHeadNotPrinted(t *testing.T, console *Console) { + t.Helper() + + printer, ok := console.printer.(*bytes.Buffer) + if !ok { + t.Fatal("console printer is not a buffer") + } + printer.Reset() + console.Evaluate("debug") + if output := printer.String(); strings.Contains(output, "setHead") { + t.Fatalf("debug.setHead should not appear in pretty-printed debug object: %s", output) + } + + printer.Reset() + console.Evaluate("web3.debug") + if output := printer.String(); strings.Contains(output, "setHead") { + t.Fatalf("debug.setHead should not appear in pretty-printed web3.debug object: %s", output) + } +} + // Tests that the console can be used in interactive mode. func TestInteractive(t *testing.T) { // Create a tester and run an interactive console in the background diff --git a/internal/ethapi/api.go b/internal/ethapi/api.go index 5cb0313e0768..73be1890bcf9 100644 --- a/internal/ethapi/api.go +++ b/internal/ethapi/api.go @@ -2374,6 +2374,18 @@ func NewPrivateDebugAPI(b Backend) *PrivateDebugAPI { return &PrivateDebugAPI{b: b} } +// LocalDebugAPI is the collection of Ethereum debug APIs exposed only over +// local transports. +type LocalDebugAPI struct { + b Backend +} + +// NewLocalDebugAPI creates a new API definition for the local-only debug +// methods of the Ethereum service. +func NewLocalDebugAPI(b Backend) *LocalDebugAPI { + return &LocalDebugAPI{b: b} +} + // ChaindbProperty returns leveldb properties of the key-value database. func (api *PrivateDebugAPI) ChaindbProperty(property string) (string, error) { if property == "" { @@ -2406,7 +2418,7 @@ func (api *PrivateDebugAPI) ChaindbCompact() error { } // SetHead rewinds the head of the blockchain to a previous block. -func (api *PrivateDebugAPI) SetHead(number hexutil.Uint64) error { +func (api *LocalDebugAPI) SetHead(number hexutil.Uint64) error { header := api.b.CurrentHeader() if header == nil { return errors.New("current header is not available") diff --git a/internal/ethapi/api_local_test.go b/internal/ethapi/api_local_test.go new file mode 100644 index 000000000000..4580a74a35f3 --- /dev/null +++ b/internal/ethapi/api_local_test.go @@ -0,0 +1,93 @@ +// Copyright 2026 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package ethapi + +import ( + "context" + "testing" + + "github.com/XinFinOrg/XDPoSChain/common/hexutil" + "github.com/XinFinOrg/XDPoSChain/core/rawdb" + "github.com/XinFinOrg/XDPoSChain/ethdb" + "github.com/XinFinOrg/XDPoSChain/rpc" + "github.com/stretchr/testify/require" +) + +type debugTransportBackend struct { + *backendMock + db ethdb.Database +} + +func newDebugTransportBackend(t *testing.T) *debugTransportBackend { + t.Helper() + + db := rawdb.NewMemoryDatabase() + require.NoError(t, db.Put([]byte("debug-key"), []byte("debug-value"))) + + return &debugTransportBackend{ + backendMock: newBackendMock(), + db: db, + } +} + +func (b *debugTransportBackend) ChainDb() ethdb.Database { + return b.db +} + +func TestDebugSetHeadTransportExposure(t *testing.T) { + backend := newDebugTransportBackend(t) + apis := GetAPIs(backend, nil) + + openServer := rpc.NewServer() + localServer := rpc.NewServer() + for _, api := range apis { + if !api.Local { + require.NoError(t, openServer.RegisterName(api.Namespace, api.Service)) + } + require.NoError(t, localServer.RegisterName(api.Namespace, api.Service)) + } + + openClient := rpc.DialInProc(openServer) + defer openClient.Close() + localClient := rpc.DialInProc(localServer) + defer localClient.Close() + + ctx := context.Background() + var block string + err := openClient.CallContext(ctx, &block, "debug_printBlock", uint64(0)) + if isMethodNotFound(err) { + t.Fatalf("expected debug_printBlock to remain exposed on open RPC, got %v", err) + } + + var dbValue hexutil.Bytes + err = openClient.CallContext(ctx, &dbValue, "debug_dbGet", "debug-key") + require.NoError(t, err) + require.Equal(t, hexutil.Bytes([]byte("debug-value")), dbValue) + + err = openClient.CallContext(ctx, nil, "debug_setHead", hexutil.Uint64(0)) + if !isMethodNotFound(err) { + t.Fatalf("expected debug_setHead to be hidden from open RPC, got %v", err) + } + + err = localClient.CallContext(ctx, nil, "debug_setHead", hexutil.Uint64(0)) + require.NoError(t, err) +} + +func isMethodNotFound(err error) bool { + rpcErr, ok := err.(rpc.Error) + return ok && rpcErr.ErrorCode() == -32601 +} diff --git a/internal/ethapi/backend.go b/internal/ethapi/backend.go index afd05686c2ca..f1601fb5bdc3 100644 --- a/internal/ethapi/backend.go +++ b/internal/ethapi/backend.go @@ -150,6 +150,11 @@ func GetAPIs(apiBackend Backend, chainReader consensus.ChainReader) []rpc.API { Namespace: "debug", Version: "1.0", Service: NewPrivateDebugAPI(apiBackend), + }, { + Namespace: "debug", + Version: "1.0", + Service: NewLocalDebugAPI(apiBackend), + Local: true, }, { Namespace: "eth", Version: "1.0", diff --git a/internal/jsre/pretty.go b/internal/jsre/pretty.go index 4171e0090617..260074881f15 100644 --- a/internal/jsre/pretty.go +++ b/internal/jsre/pretty.go @@ -221,12 +221,29 @@ func (ctx ppctx) fields(obj *goja.Object) []string { } } - iterOwnAndConstructorKeys(ctx.vm, obj, add) + iterEnumerableAndConstructorKeys(ctx.vm, obj, add) sort.Strings(vals) sort.Strings(methods) return append(vals, methods...) } +func iterEnumerableAndConstructorKeys(vm *goja.Runtime, obj *goja.Object, f func(string)) { + seen := make(map[string]bool) + iterOwnKeys(vm, obj, func(prop string) { + seen[prop] = true + }) + iterEnumerableKeys(vm, obj, func(prop string) { + f(prop) + }) + if cp := constructorPrototype(vm, obj); cp != nil { + iterEnumerableKeys(vm, cp, func(prop string) { + if !seen[prop] { + f(prop) + } + }) + } +} + func iterOwnAndConstructorKeys(vm *goja.Runtime, obj *goja.Object, f func(string)) { seen := make(map[string]bool) iterOwnKeys(vm, obj, func(prop string) { @@ -267,6 +284,31 @@ func iterOwnKeys(vm *goja.Runtime, obj *goja.Object, f func(string)) { } } +func iterEnumerableKeys(vm *goja.Runtime, obj *goja.Object, f func(string)) { + Object := vm.Get("Object").ToObject(vm) + keys, isFunc := goja.AssertFunction(Object.Get("keys")) + if !isFunc { + panic(vm.ToValue("Object.keys isn't a function")) + } + rv, err := keys(goja.Null(), obj) + if err != nil { + panic(vm.ToValue(fmt.Sprintf("Error getting enumerable object properties: %v", err))) + } + gv := rv.Export() + switch gv := gv.(type) { + case []interface{}: + for _, v := range gv { + f(v.(string)) + } + case []string: + for _, v := range gv { + f(v) + } + default: + panic(fmt.Errorf("Object.keys returned unexpected type %T", gv)) + } +} + func (ctx ppctx) isBigNumber(v *goja.Object) bool { // Handle numbers with custom constructor. if obj := v.Get("constructor").ToObject(ctx.vm); obj != nil { diff --git a/node/api.go b/node/api.go index cfe904f06c90..6708df98a544 100644 --- a/node/api.go +++ b/node/api.go @@ -208,7 +208,8 @@ func (api *privateAdminAPI) StartRPC(host *string, port *int, cors *string, apis if err := api.node.http.setListenAddr(*host, *port); err != nil { return false, err } - if err := api.node.http.enableRPC(api.node.rpcAPIs, config); err != nil { + openAPIs, _ := api.node.getAPIs() + if err := api.node.http.enableRPC(openAPIs, config); err != nil { return false, err } if err := api.node.http.start(); err != nil { @@ -264,7 +265,8 @@ func (api *privateAdminAPI) StartWS(host *string, port *int, allowedOrigins *str if err := server.setListenAddr(*host, *port); err != nil { return false, err } - if err := server.enableWS(api.node.rpcAPIs, config); err != nil { + openAPIs, _ := api.node.getAPIs() + if err := server.enableWS(openAPIs, config); err != nil { return false, err } if err := server.start(); err != nil { diff --git a/node/api_test.go b/node/api_test.go index 388caff8b18c..69206837b6f0 100644 --- a/node/api_test.go +++ b/node/api_test.go @@ -18,6 +18,7 @@ package node import ( "bytes" + "context" "io" "net" "net/http" @@ -29,6 +30,12 @@ import ( "github.com/stretchr/testify/assert" ) +type helloRPC string + +func (hr helloRPC) HelloWorld() (string, error) { + return string(hr), nil +} + // This test uses the admin_startRPC and admin_startWS APIs, // checking whether the HTTP server is started correctly. func TestStartRPC(t *testing.T) { @@ -294,6 +301,59 @@ func TestStartRPC(t *testing.T) { } } +func TestStartRPCLocalAPIsRemainHidden(t *testing.T) { + config := Config{} + config.NoUSB = true + config.P2P.NoDiscovery = true + + stack, err := New(&config) + if err != nil { + t.Fatal("can't create node:", err) + } + defer stack.Close() + + stack.RegisterAPIs([]rpc.API{{ + Namespace: "debug", + Version: "1.0", + Service: helloRPC("hello debug"), + Public: true, + Local: true, + }}) + + if err := stack.Start(); err != nil { + t.Fatal("can't start node:", err) + } + + _, err = (&privateAdminAPI{stack}).StartRPC(sp("127.0.0.1"), ip(0), nil, sp("debug"), nil) + assert.NoError(t, err) + + localClient, err := stack.Attach() + if err != nil { + t.Fatalf("failed to attach to node: %v", err) + } + defer localClient.Close() + + var out string + err = localClient.CallContext(context.Background(), &out, "debug_helloWorld") + assert.NoError(t, err) + assert.Equal(t, "hello debug", out) + + httpClient, err := rpc.DialHTTP(stack.HTTPEndpoint()) + if err != nil { + t.Fatalf("failed to dial HTTP endpoint: %v", err) + } + defer httpClient.Close() + + err = httpClient.CallContext(context.Background(), &out, "debug_helloWorld") + if err == nil { + t.Fatal("expected local-only API to stay hidden from HTTP RPC started via admin API") + } + rpcErr, ok := err.(rpc.Error) + if !ok || rpcErr.ErrorCode() != -32601 { + t.Fatalf("expected method-not-found for hidden local-only API, got %v", err) + } +} + // checkReachable checks if the TCP endpoint in rawurl is open. func checkReachable(rawurl string) bool { u, err := url.Parse(rawurl) diff --git a/node/node.go b/node/node.go index 4fbff9690c53..799da0d2a1e0 100644 --- a/node/node.go +++ b/node/node.go @@ -340,13 +340,15 @@ func (n *Node) closeDataDir() { // startup. It's not meant to be called at any time afterwards as it makes certain // assumptions about the state of the node. func (n *Node) startRPC() error { - if err := n.startInProc(); err != nil { + openAPIs, localAPIs := n.getAPIs() + + if err := n.startInProc(localAPIs); err != nil { return err } // Configure IPC. if n.ipc.endpoint != "" { - if err := n.ipc.start(n.rpcAPIs); err != nil { + if err := n.ipc.start(localAPIs); err != nil { return err } } @@ -361,7 +363,7 @@ func (n *Node) startRPC() error { if err := n.http.setListenAddr(n.config.HTTPHost, n.config.HTTPPort); err != nil { return err } - if err := n.http.enableRPC(n.rpcAPIs, config); err != nil { + if err := n.http.enableRPC(openAPIs, config); err != nil { return err } } @@ -376,7 +378,7 @@ func (n *Node) startRPC() error { if err := server.setListenAddr(n.config.WSHost, n.config.WSPort); err != nil { return err } - if err := server.enableWS(n.rpcAPIs, config); err != nil { + if err := server.enableWS(openAPIs, config); err != nil { return err } } @@ -387,6 +389,17 @@ func (n *Node) startRPC() error { return n.ws.start() } +func (n *Node) getAPIs() (open, local []rpc.API) { + for _, api := range n.rpcAPIs { + local = append(local, api) + if api.Local { + continue + } + open = append(open, api) + } + return open, local +} + func (n *Node) wsServerForPort(port int) *httpServer { if n.config.HTTPHost == "" || n.http.port == port { return n.http @@ -402,8 +415,8 @@ func (n *Node) stopRPC() { } // startInProc registers all RPC APIs on the inproc server. -func (n *Node) startInProc() error { - for _, api := range n.rpcAPIs { +func (n *Node) startInProc(apis []rpc.API) error { + for _, api := range apis { if err := n.inprocHandler.RegisterName(api.Namespace, api.Service); err != nil { return err } diff --git a/rpc/types.go b/rpc/types.go index fddda07c8b6f..9123cd52fca7 100644 --- a/rpc/types.go +++ b/rpc/types.go @@ -34,6 +34,7 @@ type API struct { Version string // api version for DApp's Service interface{} // receiver instance which holds the methods Public bool // indication if the methods must be considered safe for public use + Local bool // whether the API should only be available over local transports (IPC and in-process) } // Error wraps RPC errors, which contain an error code in addition to the message.