Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ Some behavior of Blockbook can be modified by environment variables. The variabl

- `<coin shortcut>_WS_ALLOWED_ORIGINS` - Comma-separated list of allowed WebSocket origins (e.g. `https://example.com`, `http://localhost:3000`). If omitted, all origins are allowed and it is the operator's responsibility to enforce origin access (for example via proxy).

- `<network>_WS_TRUSTED_PROXIES` - Comma-separated list of trusted proxy CIDRs whose `X-Real-Ip` header should be used as the WebSocket client IP. This IP is used by per-IP WebSocket connection and connection-attempt limits.
Blockbook always trusts `X-Real-Ip` from loopback, RFC1918/private, and link-local peers, so this variable is only needed for additional non-local proxies.

If this variable is unset, Blockbook keeps the default Cloudflare behavior and uses `CF-Connecting-IPv6` first, then `CF-Connecting-IP`, when either header contains a valid IP address. This is intended for deployments where the origin only accepts traffic from Cloudflare IP ranges, for example enforced by nginx or a firewall. Blockbook does not validate the TCP peer against Cloudflare ranges itself.

If this variable is set, Blockbook switches to generic trusted-proxy mode: `CF-Connecting-IP` and `CF-Connecting-IPv6` are ignored, and `X-Real-Ip` is used only when the TCP peer is a built-in trusted proxy or matches one of the configured CIDRs. In this mode the proxy must overwrite or strip any client-supplied `X-Real-Ip` header before forwarding requests to Blockbook.

Do not set this variable for a normal Cloudflare-only deployment unless the proxy in front of Blockbook sets `X-Real-Ip` to the real visitor IP. Otherwise all clients may collapse to the proxy or Cloudflare address for rate limiting.

To avoid unsafe configuration, Blockbook fails startup if a configured prefix is too broad (`/<8` for IPv4, `/<16` for IPv6), malformed, or uses IPv4-mapped IPv6 notation. Use regular IPv4 CIDR notation instead, for example `198.51.100.0/24` rather than `::ffff:198.51.100.0/120`.

- `<coin shortcut>_STAKING_POOL_CONTRACT` - The pool name and contract used for Ethereum staking. The format of the variable is `<pool name>/<pool contract>`. If missing, staking support is disabled.

- `COINGECKO_API_KEY`, `<network>_COINGECKO_API_KEY`, or `<coin shortcut>_COINGECKO_API_KEY` - API key for making requests to CoinGecko in the paid tier.
Expand Down
27 changes: 22 additions & 5 deletions server/public.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const maxWebsocketBlockPageSize = 10000
const maxPageNumber = 1000000
const maxGapValue = 10000
const maxSafePagingOffset = 1000000000
const maxAccountHistoryPagingOffset = 100000
const maxSendTxBodyBytes int64 = 8 * 1024 * 1024

const secondaryCoinCookieName = "secondary_coin"
Expand Down Expand Up @@ -250,10 +251,18 @@ func (s *PublicServer) Close() error {
return s.https.Close()
}

// Shutdown shuts down the server
// Shutdown shuts down the server. http.Server.Shutdown does not drain
// hijacked WebSocket connections, so after the HTTP listener stops we also
// drain the WebSocket server's in-flight DB-touching goroutines; otherwise a
// long getAccountInfo can race rocksdb_close in cgo and SIGSEGV the process.
func (s *PublicServer) Shutdown(ctx context.Context) error {
glog.Infof("public server: shutdown")
return s.https.Shutdown(ctx)
httpErr := s.https.Shutdown(ctx)
wsErr := s.websocket.Shutdown(ctx)
if httpErr != nil {
return httpErr
}
return wsErr
}

// OnNewBlock notifies users subscribed to bitcoind/hashblock about new block
Expand Down Expand Up @@ -908,13 +917,21 @@ func validateIntParam(value string, defaultValue int, min int, max int) int {
}

func sanitizePagingParams(page, pageSize, defaultPageSize, maxPageSize int) (int, int) {
return sanitizePagingParamsWithMaxOffset(page, pageSize, defaultPageSize, maxPageSize, maxSafePagingOffset)
}

func sanitizeAccountPagingParams(page, pageSize, defaultPageSize, maxPageSize int) (int, int) {
return sanitizePagingParamsWithMaxOffset(page, pageSize, defaultPageSize, maxPageSize, maxAccountHistoryPagingOffset)
}

func sanitizePagingParamsWithMaxOffset(page, pageSize, defaultPageSize, maxPageSize, maxPagingOffset int) (int, int) {
page = validateIntValue(page, 0, 0, maxPageNumber)
pageSize = validateIntValue(pageSize, defaultPageSize, 0, maxPageSize)
if pageSize == 0 {
pageSize = defaultPageSize
}
if page > 0 && pageSize > 0 && page > maxSafePagingOffset/pageSize {
page = maxSafePagingOffset / pageSize
if page > 0 && pageSize > 0 && page > maxPagingOffset/pageSize {
page = maxPagingOffset / pageSize
}
return page, pageSize
}
Expand All @@ -923,7 +940,7 @@ func (s *PublicServer) getAddressQueryParams(r *http.Request, accountDetails api
var voutFilter = api.AddressFilterVoutOff
page := validateIntParam(r.URL.Query().Get("page"), 0, 0, maxPageNumber)
pageSize := validateIntParam(r.URL.Query().Get("pageSize"), maxPageSize, 0, maxPageSize)
page, pageSize = sanitizePagingParams(page, pageSize, maxPageSize, maxPageSize)
page, pageSize = sanitizeAccountPagingParams(page, pageSize, maxPageSize, maxPageSize)
from := validateIntParam(r.URL.Query().Get("from"), 0, 0, 10000000000)
Comment on lines 940 to 944
Copy link
Copy Markdown
Contributor Author

@pragmaxim pragmaxim Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot change the PR description accordingly

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the PR description to explicitly call out that sanitizeAccountPagingParams is now used by the HTTP /api/.../address endpoint as well, replacing the previous maxSafePagingOffset-based clamp and applying consistent capping across both WebSocket and HTTP API.

to := validateIntParam(r.URL.Query().Get("to"), 0, 0, 10000000000)

Expand Down
53 changes: 53 additions & 0 deletions server/public_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2779,3 +2779,56 @@ func Test_sanitizePagingParams(t *testing.T) {
})
}
}

func Test_sanitizeAccountPagingParams(t *testing.T) {
tests := []struct {
name string
page int
pageSize int
defaultPageSize int
maxPageSize int
wantPage int
wantPageSize int
}{
{"ws getAccountInfo default", 0, 0, txsOnPage, txsInAPI, 0, txsOnPage},
{"ws getAccountInfo within limit", 1, 100, txsOnPage, txsInAPI, 1, 100},
{"ws getAccountInfo caps page size at txsInAPI", 1, txsInAPI + 1, txsOnPage, txsInAPI, 1, txsInAPI},
{"ws getAccountInfo negative defaults", 0, -5, txsOnPage, txsInAPI, 0, txsOnPage},
{"api address caps history offset", maxPageNumber, txsInAPI, txsInAPI, txsInAPI, maxAccountHistoryPagingOffset / txsInAPI, txsInAPI},
{"explorer address caps history offset", maxPageNumber, txsOnPage, txsOnPage, txsOnPage, maxAccountHistoryPagingOffset / txsOnPage, txsOnPage},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
page, pageSize := sanitizeAccountPagingParams(tt.page, tt.pageSize, tt.defaultPageSize, tt.maxPageSize)
if page != tt.wantPage || pageSize != tt.wantPageSize {
t.Errorf("sanitizeAccountPagingParams(%d, %d, %d, %d) = (%d, %d), want (%d, %d)",
tt.page, tt.pageSize, tt.defaultPageSize, tt.maxPageSize,
page, pageSize, tt.wantPage, tt.wantPageSize)
}
})
}
}

func Test_validateIntValue_gapClamp(t *testing.T) {
// Mirrors the WS getAccountInfo gap clamp: validateIntValue(req.Gap, 0, 0, maxGapValue).
tests := []struct {
name string
val int
want int
}{
{"unset passes through as 0", 0, 0},
{"suite default 20 passes through", 20, 20},
{"negative defaults to 0", -1, 0},
{"caps at maxGapValue", maxGapValue + 1, maxGapValue},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := validateIntValue(tt.val, 0, 0, maxGapValue)
if got != tt.want {
t.Errorf("validateIntValue(%d, 0, 0, %d) = %d, want %d",
tt.val, maxGapValue, got, tt.want)
}
})
}
}
Loading
Loading