rpcclient: compute httpURL once at construction time
What changed, and why it matters
This change is a performance and reliability fix, not a typical security patch. The Bitcoin RPC client was rebuilding the server URL on every request, and that rebuild triggered an unnecessary DNS lookup whose result was discarded. The patch computes the URL once when the client is created and reuses it. That removes a source of DNS failures, request delays, and potential information leakage to DNS resolvers, but it does not by itself fix an exploitable vulnerability.
Treat as a hardening/maintenance improvement rather than an urgent security fix. Users running RPC clients behind restrictive DNS or concerned about DNS leakage benefit from upgrading, but no immediate exploit is indicated. Reviewers should verify that the new prefix-based Unix-socket detection matches all previously supported address shapes and that newHTTPClient's validation still runs before httpURL is assigned.
Security signals we found
Eliminates per-request DNS resolution of RPC host, reducing DNS-based side channels and dependency on resolver availability
Removes error-returning address parsing from the request path, reducing opportunities for unexpected failures during RPC calls
Adds regression tests for URL construction and construction-time wiring
No input validation was removed: newHTTPClient still validates config.Host via ParseAddressString before httpURL is computed
Evidence from the diff
In rpcclient, ConnConfig.httpURL() previously called ParseAddressString(config.Host), which invoked net.ResolveTCPAddr and performed a DNS resolution on every JSON-RPC POST. The resolved address was only used to decide whether the host was a Unix socket; for TCP hosts the original config.Host string was used in the URL. The patch changes httpURL() to a simple strings.HasPrefix check for unix:// and unixpacket://, computes the URL once in New(), stores it in Client.httpURL, and passes that cached string to sendPostRequestWithRetry. This eliminates repeated DNS lookups and removes an error path from the per-request hot path. The change is accompanied by unit tests pinning the produced URL strings and guarding the construction-time wiring.
Changed components
btcd/rpcclient/infrastructure.gobtcd/rpcclient/infrastructure_test.goClient.httpURL field and sendPostRequestWithRetry function signatureInspect captured patch +119 / −27
diff --git a/rpcclient/infrastructure.go b/rpcclient/infrastructure.go
index 8eb3b1c..d57367b 100644
--- a/rpcclient/infrastructure.go
+++ b/rpcclient/infrastructure.go
@@ -144,6 +144,11 @@ type Client struct {
// POST mode.
httpClient *http.Client
+ // httpURL is the request URL used for every HTTP POST. It depends only
+ // on the configured Host and DisableTLS, both of which are immutable
+ // after New, so it is computed once at construction time and reused.
+ httpURL string
+
// backendVersion is the version of the backend the client is currently
// connected to. This should be retrieved through GetVersion.
backendVersionMu sync.Mutex
@@ -779,7 +784,7 @@ func (c *Client) handleSendPostMessage(ctx context.Context, jReq *jsonRequest) {
// shutdown-driven cancellation.
func sendPostRequestWithRetry(ctx context.Context, jReq *jsonRequest,
tries int, httpClient *http.Client, config *ConnConfig,
- batch bool) ([]byte, error) {
+ httpURL string, batch bool) ([]byte, error) {
var (
lastErr error
@@ -788,11 +793,6 @@ func sendPostRequestWithRetry(ctx context.Context, jReq *jsonRequest,
err error
)
- httpURL, err := config.httpURL()
- if err != nil {
- return nil, fmt.Errorf("failed to parse address %v", err)
- }
-
retryloop:
for i := 0; i < tries; i++ {
var httpReq *http.Request
@@ -899,7 +899,7 @@ func (c *Client) sendPostRequestAndRespond(ctx context.Context,
jReq *jsonRequest, tries int) {
res, err := sendPostRequestWithRetry(
- ctx, jReq, tries, c.httpClient, c.config, c.batch,
+ ctx, jReq, tries, c.httpClient, c.config, c.httpURL, c.batch,
)
// Preserve the client contract that shutdown-related cancellations surface
@@ -1418,30 +1418,23 @@ func newHTTPClient(config *ConnConfig) (*http.Client, error) {
return &client, nil
}
-// httpURL returns the URL to use for HTTP POST requests.
-func (config *ConnConfig) httpURL() (string, error) {
+// httpURL returns the URL for HTTP POST requests. The Unix-socket case
+// returns a placeholder, since the path is dialed by the Transport's
+// DialContext. config.Host is not validated here, because newHTTPClient
+// already validates it via ParseAddressString.
+func (config *ConnConfig) httpURL() string {
protocol := "http"
if !config.DisableTLS {
protocol = "https"
}
- parsedAddr, err := ParseAddressString(config.Host)
- if err != nil {
- return "", fmt.Errorf("error parsing host '%v': %v",
- config.Host, err)
- }
+ if strings.HasPrefix(config.Host, "unix://") ||
+ strings.HasPrefix(config.Host, "unixpacket://") {
- var httpURL string
- switch parsedAddr.Network() {
- case "unix", "unixpacket":
- // Using a placeholder URL because a non-empty URL is required.
- // The Unix domain socket is specified in the DialContext.
- httpURL = protocol + "://unix"
- default:
- httpURL = protocol + "://" + config.Host
+ return protocol + "://unix"
}
- return httpURL, nil
+ return protocol + "://" + config.Host
}
// dial opens a websocket connection using the passed connection configuration
@@ -1528,6 +1521,7 @@ func New(config *ConnConfig, ntfnHandlers *NotificationHandlers) (*Client, error
// when running in HTTP POST mode.
var wsConn *websocket.Conn
var httpClient *http.Client
+ var httpURL string
connEstablished := make(chan struct{})
var start bool
if config.HTTPPostMode {
@@ -1539,6 +1533,7 @@ func New(config *ConnConfig, ntfnHandlers *NotificationHandlers) (*Client, error
if err != nil {
return nil, err
}
+ httpURL = config.httpURL()
} else {
if !config.DisableConnectOnNew {
var err error
@@ -1554,6 +1549,7 @@ func New(config *ConnConfig, ntfnHandlers *NotificationHandlers) (*Client, error
config: config,
wsConn: wsConn,
httpClient: httpClient,
+ httpURL: httpURL,
requestMap: make(map[uint64]*list.Element),
requestList: list.New(),
batch: false,
diff --git a/rpcclient/infrastructure_test.go b/rpcclient/infrastructure_test.go
index 98ca117..8127b30 100644
--- a/rpcclient/infrastructure_test.go
+++ b/rpcclient/infrastructure_test.go
@@ -322,8 +322,8 @@ func TestSendPostRequestWithRetrySuccess(t *testing.T) {
jReq := newPostTestRequest()
result, err := sendPostRequestWithRetry(
- context.Background(), jReq, 1, client.httpClient, client.config,
- false,
+ context.Background(), jReq, 1, client.httpClient,
+ client.config, client.httpURL, false,
)
require.NoError(t, err)
require.Equal(t, []byte("1"), result)
@@ -340,8 +340,8 @@ func TestSendPostRequestWithRetryShutdown(t *testing.T) {
jReq := newPostTestRequest()
result, err := sendPostRequestWithRetry(
- ctx, jReq, tc.tries, client.httpClient, client.config,
- false,
+ ctx, jReq, tc.tries, client.httpClient,
+ client.config, client.httpURL, false,
)
require.Nil(t, result)
require.ErrorIs(t, err, context.Canceled)
@@ -479,6 +479,80 @@ func TestSendPostRequestAndRespondShutdown(t *testing.T) {
}
}
+// TestHTTPURL pins down the URL strings produced by httpURL for each
+// supported host shape. httpURL runs on every RPC and now uses a
+// hand-rolled prefix check rather than delegating to ParseAddressString,
+// so its output is exercised directly here.
+func TestHTTPURL(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ host string
+ disableTLS bool
+ expURL string
+ }{
+ {
+ name: "unix socket",
+ host: "unix:///var/run/bitcoin/bitcoin.sock",
+ disableTLS: true,
+ expURL: "http://unix",
+ },
+ {
+ name: "unixpacket socket",
+ host: "unixpacket:///var/run/bitcoin/bitcoin.sock",
+ disableTLS: true,
+ expURL: "http://unix",
+ },
+ {
+ name: "ipv4 literal",
+ host: "127.0.0.1:8332",
+ disableTLS: true,
+ expURL: "http://127.0.0.1:8332",
+ },
+ {
+ name: "ipv6 literal",
+ host: "[::1]:8332",
+ disableTLS: true,
+ expURL: "http://[::1]:8332",
+ },
+ {
+ name: "hostname",
+ host: "localhost:8332",
+ disableTLS: true,
+ expURL: "http://localhost:8332",
+ },
+ {
+ name: "empty host",
+ host: "",
+ disableTLS: true,
+ expURL: "http://",
+ },
+ {
+ name: "tls hostname",
+ host: "bitcoind.example.com:8332",
+ disableTLS: false,
+ expURL: "https://bitcoind.example.com:8332",
+ },
+ {
+ name: "tls unix socket",
+ host: "unix:///var/run/bitcoin/bitcoin.sock",
+ disableTLS: false,
+ expURL: "https://unix",
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ cfg := &ConnConfig{
+ Host: tc.host,
+ DisableTLS: tc.disableTLS,
+ }
+ require.Equal(t, tc.expURL, cfg.httpURL())
+ })
+ }
+}
+
// TestSendPostRequestShutdownPrioritizesFailure ensures shutdown always wins
// when it is already closed before sendPostRequest is called.
func TestSendPostRequestShutdownPrioritizesFailure(t *testing.T) {
@@ -672,3 +746,25 @@ func TestNewBatchSerializesPostSends(t *testing.T) {
require.EqualValues(t, 1, observedMax, "POST sends must be serialized")
}
+
+// TestHTTPURLWiring guards the construction-time wiring that copies
+// (*ConnConfig).httpURL onto Client.httpURL when HTTPPostMode is set.
+// TestHTTPURL covers the method itself; this test catches the case
+// where a refactor of New silently drops the assignment, leaving
+// Client.httpURL as the zero value.
+func TestHTTPURLWiring(t *testing.T) {
+ t.Parallel()
+
+ cfg := &ConnConfig{
+ Host: "localhost:8332",
+ HTTPPostMode: true,
+ DisableTLS: true,
+ User: "user",
+ Pass: "pass",
+ }
+ c, err := New(cfg, nil)
+ require.NoError(t, err)
+ defer c.Shutdown()
+
+ require.Equal(t, "http://localhost:8332", c.httpURL)
+}
Why this scored 29/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.