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.