mirror of
https://github.com/btcsuite/btcd.git
synced 2026-08-13 12:32:51 +02:00
rpcclient: harden DisableAuth transport tests
In this commit, we make the DisableAuth tests observe successful requests instead of inferring them from an empty header. This closes a false-positive path where credential lookup could fail before the request reached the server. We also cover the WebSocket handshake, cookie bypass, and caller-provided headers across enabled and disabled auth. The public comment now makes clear that DisableAuth only suppresses rpcclient-generated Basic auth.
This commit is contained in:
parent
fe84a0e16b
commit
52d2fade69
2 changed files with 178 additions and 119 deletions
|
|
@ -1,140 +1,202 @@
|
|||
package rpcclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestDisableAuth verifies that the DisableAuth field correctly controls
|
||||
// whether the Authorization header is sent on RPC requests.
|
||||
func TestDisableAuth(t *testing.T) {
|
||||
t.Parallel()
|
||||
const (
|
||||
testRPCUser = "testuser"
|
||||
testRPCPass = "testpass"
|
||||
testCallerAuth = "Bearer test-api-key"
|
||||
testExtraHeader = "X-Test-API-Key"
|
||||
testExtraValue = "test-api-key"
|
||||
)
|
||||
|
||||
t.Run("DisableAuth true omits Authorization header", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// disableAuthTestCase describes one authentication header configuration that
|
||||
// must behave the same for HTTP POST and WebSocket transports.
|
||||
type disableAuthTestCase struct {
|
||||
name string
|
||||
configure func(*ConnConfig)
|
||||
wantAuthorization string
|
||||
}
|
||||
|
||||
var gotAuth string
|
||||
handler := http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
// disableAuthTestCases returns the shared transport authentication cases.
|
||||
func disableAuthTestCases(missingCookie string) []disableAuthTestCase {
|
||||
basicAuth := "Basic " + base64.StdEncoding.EncodeToString(
|
||||
[]byte(testRPCUser+":"+testRPCPass),
|
||||
)
|
||||
|
||||
// Return a valid JSON-RPC response so the client
|
||||
// doesn't retry.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(
|
||||
`{"result":null,"error":null,"id":1}`,
|
||||
))
|
||||
return []disableAuthTestCase{
|
||||
{
|
||||
name: "disabled omits generated authorization",
|
||||
configure: func(config *ConnConfig) {
|
||||
config.User = ""
|
||||
config.Pass = ""
|
||||
config.CookiePath = missingCookie
|
||||
config.DisableAuth = true
|
||||
},
|
||||
)
|
||||
srv := httptest.NewServer(handler)
|
||||
defer srv.Close()
|
||||
|
||||
addr := strings.TrimPrefix(srv.URL, "http://")
|
||||
client, err := New(&ConnConfig{
|
||||
Host: addr,
|
||||
HTTPPostMode: true,
|
||||
DisableAuth: true,
|
||||
DisableTLS: true,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
defer client.Shutdown()
|
||||
|
||||
// The client is now connected; issue a simple request to trigger
|
||||
// handleSendPostMessage.
|
||||
_, err = client.RawRequest("getblockchaininfo", nil)
|
||||
// We don't care if the RPC itself errors. We only care about
|
||||
// the Authorization header.
|
||||
_ = err
|
||||
|
||||
require.Empty(
|
||||
t, gotAuth,
|
||||
"Authorization header should be empty when DisableAuth is true",
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("DisableAuth false includes Authorization header", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var gotAuth string
|
||||
handler := http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(
|
||||
`{"result":null,"error":null,"id":1}`,
|
||||
))
|
||||
},
|
||||
{
|
||||
name: "disabled preserves caller authorization",
|
||||
configure: func(config *ConnConfig) {
|
||||
config.User = ""
|
||||
config.Pass = ""
|
||||
config.CookiePath = missingCookie
|
||||
config.DisableAuth = true
|
||||
config.ExtraHeaders["Authorization"] =
|
||||
testCallerAuth
|
||||
},
|
||||
)
|
||||
srv := httptest.NewServer(handler)
|
||||
defer srv.Close()
|
||||
wantAuthorization: testCallerAuth,
|
||||
},
|
||||
{
|
||||
name: "explicit false includes basic authorization",
|
||||
configure: func(config *ConnConfig) {
|
||||
config.DisableAuth = false
|
||||
},
|
||||
wantAuthorization: basicAuth,
|
||||
},
|
||||
{
|
||||
name: "zero value includes basic authorization",
|
||||
configure: func(*ConnConfig) {
|
||||
// Leave DisableAuth at its zero value.
|
||||
},
|
||||
wantAuthorization: basicAuth,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
addr := strings.TrimPrefix(srv.URL, "http://")
|
||||
client, err := New(&ConnConfig{
|
||||
Host: addr,
|
||||
HTTPPostMode: true,
|
||||
DisableAuth: false,
|
||||
DisableTLS: true,
|
||||
User: "testuser",
|
||||
Pass: "testpass",
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
defer client.Shutdown()
|
||||
// newDisableAuthConfig creates the common configuration for the transport
|
||||
// authentication cases.
|
||||
func newDisableAuthConfig() *ConnConfig {
|
||||
return &ConnConfig{
|
||||
User: testRPCUser,
|
||||
Pass: testRPCPass,
|
||||
ExtraHeaders: map[string]string{
|
||||
testExtraHeader: testExtraValue,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
_, err = client.RawRequest("getblockchaininfo", nil)
|
||||
_ = err
|
||||
// assertAuthHeaders verifies both generated or caller-supplied authorization
|
||||
// and the independent extra header.
|
||||
func assertAuthHeaders(t *testing.T, header http.Header,
|
||||
wantAuthorization string) {
|
||||
|
||||
login := []byte("testuser:testpass")
|
||||
expected := "Basic " + base64.StdEncoding.EncodeToString(login)
|
||||
require.Equal(
|
||||
t, expected, gotAuth,
|
||||
"Authorization header should be set when DisableAuth is false",
|
||||
)
|
||||
})
|
||||
t.Helper()
|
||||
|
||||
t.Run(
|
||||
"DisableAuth default (zero value) includes Authorization header",
|
||||
func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, wantAuthorization, header.Get("Authorization"))
|
||||
require.Equal(t, testExtraValue, header.Get(testExtraHeader))
|
||||
}
|
||||
|
||||
var gotAuth string
|
||||
handler := http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(
|
||||
`{"result":null,"error":null,"id":1}`,
|
||||
))
|
||||
// TestDisableAuthHTTPPost verifies that DisableAuth controls generated Basic
|
||||
// Auth headers on HTTP POST requests without suppressing caller headers.
|
||||
func TestDisableAuthHTTPPost(t *testing.T) {
|
||||
missingCookie := filepath.Join(t.TempDir(), "missing-cookie")
|
||||
|
||||
for _, tc := range disableAuthTestCases(missingCookie) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
requestHeader := make(chan http.Header, 1)
|
||||
client := newPostModeTestClient(postRoundTripFunc(
|
||||
func(req *http.Request) (*http.Response, error) {
|
||||
requestHeader <- req.Header.Clone()
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"result":1,"error":null,"id":1}`,
|
||||
)),
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
srv := httptest.NewServer(handler)
|
||||
defer srv.Close()
|
||||
))
|
||||
client.config = newDisableAuthConfig()
|
||||
client.config.Host = "127.0.0.1:8332"
|
||||
client.config.DisableTLS = true
|
||||
client.config.HTTPPostMode = true
|
||||
tc.configure(client.config)
|
||||
|
||||
addr := strings.TrimPrefix(srv.URL, "http://")
|
||||
client, err := New(&ConnConfig{
|
||||
Host: addr,
|
||||
HTTPPostMode: true,
|
||||
DisableTLS: true,
|
||||
User: "myuser",
|
||||
Pass: "mypass",
|
||||
}, nil)
|
||||
result, err := sendPostRequestWithRetry(
|
||||
context.Background(), newPostTestRequest(), 1,
|
||||
client.httpClient, client.config, client.httpURL,
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer client.Shutdown()
|
||||
require.Equal(t, []byte("1"), result)
|
||||
|
||||
_, err = client.RawRequest("getblockchaininfo", nil)
|
||||
_ = err
|
||||
select {
|
||||
case header := <-requestHeader:
|
||||
assertAuthHeaders(t, header, tc.wantAuthorization)
|
||||
|
||||
login := []byte("myuser:mypass")
|
||||
expected := "Basic " +
|
||||
base64.StdEncoding.EncodeToString(login)
|
||||
require.Equal(
|
||||
t, expected, gotAuth,
|
||||
"Authorization header should be set by default",
|
||||
)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for HTTP POST request")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newWebsocketAuthServer creates a server that records the WebSocket handshake
|
||||
// headers before upgrading the connection.
|
||||
func newWebsocketAuthServer(t *testing.T) (string, <-chan http.Header) {
|
||||
t.Helper()
|
||||
|
||||
requestHeader := make(chan http.Header, 1)
|
||||
upgrader := websocket.Upgrader{}
|
||||
handler := http.HandlerFunc(
|
||||
func(w http.ResponseWriter, req *http.Request) {
|
||||
requestHeader <- req.Header.Clone()
|
||||
|
||||
conn, err := upgrader.Upgrade(w, req, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
},
|
||||
)
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
return strings.TrimPrefix(server.URL, "http://"), requestHeader
|
||||
}
|
||||
|
||||
// TestDisableAuthWebsocket verifies that DisableAuth controls generated Basic
|
||||
// Auth headers on WebSocket handshakes without suppressing caller headers.
|
||||
func TestDisableAuthWebsocket(t *testing.T) {
|
||||
missingCookie := filepath.Join(t.TempDir(), "missing-cookie")
|
||||
|
||||
for _, tc := range disableAuthTestCases(missingCookie) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
host, requestHeader := newWebsocketAuthServer(t)
|
||||
config := newDisableAuthConfig()
|
||||
config.Host = host
|
||||
config.DisableTLS = true
|
||||
tc.configure(config)
|
||||
|
||||
conn, err := dial(config)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, conn.Close())
|
||||
})
|
||||
|
||||
select {
|
||||
case header := <-requestHeader:
|
||||
assertAuthHeaders(t, header, tc.wantAuthorization)
|
||||
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for WebSocket handshake")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -810,7 +810,7 @@ retryloop:
|
|||
httpReq.Header.Set(key, value)
|
||||
}
|
||||
|
||||
// Configure basic access authorization.
|
||||
// Configure generated basic access authorization.
|
||||
if !config.DisableAuth {
|
||||
user, pass, authErr := config.getAuth()
|
||||
if authErr != nil {
|
||||
|
|
@ -1333,10 +1333,9 @@ type ConnConfig struct {
|
|||
// when connecting to blockchain.info RPC server
|
||||
EnableBCInfoHacks bool
|
||||
|
||||
// DisableAuth instructs the client to skip setting the Authorization
|
||||
// header on RPC requests. This is useful when connecting to third-party
|
||||
// RPC providers that authenticate via API key in the URL path and
|
||||
// reject requests containing an Authorization header with 401 errors.
|
||||
// DisableAuth instructs the client to skip generating a Basic
|
||||
// Authorization header for RPC requests. Caller-provided Authorization
|
||||
// values in ExtraHeaders are still sent.
|
||||
DisableAuth bool
|
||||
}
|
||||
|
||||
|
|
@ -1477,10 +1476,8 @@ func dial(config *ConnConfig) (*websocket.Conn, error) {
|
|||
dialer.NetDial = proxy.Dial
|
||||
}
|
||||
|
||||
// Configure basic access authorization. When DisableAuth is set, skip
|
||||
// setting the Authorization header entirely. This is useful for
|
||||
// third-party RPC providers that authenticate via API key in the URL
|
||||
// path and reject requests containing an Authorization header.
|
||||
// Configure generated basic access authorization. Caller-provided
|
||||
// headers are added independently below.
|
||||
requestHeader := make(http.Header)
|
||||
if !config.DisableAuth {
|
||||
user, pass, err := config.getAuth()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue