Skip to content
Open
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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,28 @@
- Better error handling for access denied errors (#1249)
- Add --no-wait/--finalize/--non-interactive flags for agent-driven releases (#1360)

### Behavior Changes

- **Light proxy WebSocket origin policy is now zero-trust by default.** The
`light` RPC proxy previously accepted WebSocket connections from any web
origin. It now accepts only same-host origins and Origin-less (non-browser)
clients, matching the main RPC server behavior. To permit specific browser
origins, set `rpc.cors-allowed-origins` (same semantics as the main RPC
`cors-allowed-origins`); a `*` entry restores the old allow-all behavior but
is discouraged. (#1368)
- **Light client no longer fails on a single dissenting witness.** A single
witness reporting a header that conflicts with the (corroborated) primary is
now treated as a faulty witness and removed, instead of failing verification
— preventing one bad witness from denying service to the light client.
Verification still fails (fork evidence) when two or more witnesses conflict,
or when a conflicting witness has no corroborating peer. (#1369)
- **`voting_power_threshold` override below the 2/3+1 BFT floor now logs an
explicit `OVERRIDE ENABLED - NOT SAFE FOR PRODUCTION` error at startup.** The
on-chain `voting_power_threshold` validator param can lower the commit gate
below the strict BFT floor (a dev/single-node escape hatch, #1052). Operators
must never set this on production quorums: doing so reduces fault tolerance
below BFT assumptions and can enable safety violations or halting. (#1370)

### Miscellaneous Tasks

- Add AI agent instructions and streamline style guide (#1250)
Expand Down
3 changes: 3 additions & 0 deletions cmd/tenderdash/commands/light.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,9 @@ for applications built w/ Cosmos SDK).
if err != nil {
return err
}
// Zero-trust WebSocket origin policy by default; operators opt in to
// specific browser origins via rpc.cors-allowed-origins.
p.AllowedOrigins = conf.RPC.CORSAllowedOrigins

ctx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
defer cancel()
Expand Down
6 changes: 6 additions & 0 deletions docs/nodes/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ laddr = "tcp://127.0.0.1:26657"
# Use '["*"]' to allow any origin
cors-allowed-origins = []

# This list also governs the WebSocket origin policy of the `light` RPC proxy
# (tenderdash light --proxy). By default (empty) the proxy accepts only
# same-host origins and Origin-less (non-browser) clients — a zero-trust policy.
# Add specific origins here to permit browser clients; a "*" entry allows any
# origin and is discouraged.

# A list of methods the client is allowed to use with cross-domain requests
cors-allowed-methods = ["HEAD", "GET", "POST", ]

Expand Down
2 changes: 1 addition & 1 deletion internal/consensus/state_data.go
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ func (s *StateData) updateToState(state sm.State, commit *types.Commit, blockSto
s.logger.Info("Updating validators", "from", s.Validators.BasicInfoString(),
"to", validators.BasicInfoString())
if validators.BelowStrictThreshold() {
s.logger.Warn("validator set voting threshold is below the 2/3+1 BFT safety bound; fault tolerance is reduced",
s.logger.Error("OVERRIDE ENABLED - NOT SAFE FOR PRODUCTION: validator set voting threshold is below the 2/3+1 BFT safety bound; fault tolerance is reduced and the chain is exposed to safety violations",
"quorum_type", validators.QuorumType.Name(),
"threshold", validators.QuorumVotingThresholdPower(),
"total_power", validators.TotalVotingPower(),
Comment thread
lklimek marked this conversation as resolved.
Expand Down
34 changes: 29 additions & 5 deletions light/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -833,22 +833,26 @@ func (c *Client) compareFirstHeaderWithWitnesses(ctx context.Context, h *types.S
go c.compareNewHeaderWithWitness(compareCtx, errc, h, witness, i)
}

// Separate counters for the two outcomes we care about:
// - conflictingWitnesses: witnesses whose header disagrees with the primary's.
// - corroborating: witnesses whose header matches the primary's (a healthy
// primary is expected to be corroborated by at least one other party).
witnessesToRemove := make([]int, 0, len(c.witnesses))
conflictingWitnesses := make([]int, 0, len(c.witnesses))
corroborating := 0

// handle errors from the header comparisons as they come in
for i := 0; i < cap(errc); i++ {
err := <-errc

switch e := err.(type) {
case nil:
corroborating++
continue
case errConflictingHeaders:
c.logger.Error(fmt.Sprintf("witness #%d has a different header. Please check primary is correct and"+
" remove witness. Otherwise, use the different primary", e.WitnessIndex), "witness",
c.witnesses[e.WitnessIndex])
// The primary and a witness disagree; keep the dissenting witness, as
// the primary may be the faulty party, and fail the comparison below.
conflictingWitnesses = append(conflictingWitnesses, e.WitnessIndex)
case errBadWitness:
// If witness sent us an invalid header, then remove it
Expand Down Expand Up @@ -881,12 +885,32 @@ func (c *Client) compareFirstHeaderWithWitnesses(ctx context.Context, h *types.S
return err
}

// A witness presenting a header that conflicts with the primary's means the
// primary's header cannot be trusted: fail the comparison.
if len(conflictingWitnesses) > 0 {
// A single conflicting witness must not be allowed to break verification: a
// lone dissenter is far more likely to be a faulty/compromised witness than a
// sign that the primary is malicious, and failing here would let one bad
// witness denial-of-service an otherwise-healthy light client. We therefore
// treat a single conflicting witness as faulty and remove it, exactly like
// errBadWitness. We still FAIL (preserving the safety-over-availability
// guarantee) only when the conflict is corroborated:
// - two or more witnesses disagree with the primary (genuine fork / primary
// likely faulty), OR
// - at least one witness conflicts and NO witness corroborates the primary
// (we cannot establish trust in either party, so we refuse to proceed).
if len(conflictingWitnesses) > 1 || (len(conflictingWitnesses) == 1 && corroborating == 0) {
return fmt.Errorf("%w: witnesses %v", ErrConflictingWitnessHeader, conflictingWitnesses)
}

// Exactly one conflicting witness with at least one corroborating witness:
// drop the dissenter and continue.
if len(conflictingWitnesses) == 1 {
c.logger.Warn("single witness conflicts with the primary but others corroborate it; "+
"removing the dissenting witness rather than failing verification",
"witness", c.witnesses[conflictingWitnesses[0]])
if err := c.removeWitnesses(conflictingWitnesses); err != nil {
Comment thread
lklimek marked this conversation as resolved.
Comment on lines 885 to +909

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Blocking: Sequential removals use stale witness indices and can panic

conflictingWitnesses stores indices from the original witness slice, but removeWitnesses(witnessesToRemove) first reorders and truncates that slice using swap-with-last. With witnesses [matching, bad, conflicting], removing bad index 1 moves the conflict to index 1 and leaves a two-element slice; the subsequent log evaluates c.witnesses[2] and panics. Other orderings can remove the wrong provider instead. Do not mutate c.witnesses until every provider selected for removal has been captured; if dissent removal remains after resolving the security issue above, combine all indices into one removal operation and add a regression test containing matching, bad, and conflicting witnesses together.

source: ['codex']

return err
}
}
Comment on lines +888 to +912

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Blocking: A colluding primary and witness can evict the sole honest witness

This changes the light client from the documented any-honest-witness model to an unenforced provider-majority model. If a malicious primary serves a validly signed non-canonical fork, one colluding witness returns the same header, and the sole honest witness returns the canonical header, the counters are one corroborating and one conflicting witness. The code removes the honest witness and then persists the primary's fork. Witness comparison exists specifically to detect an otherwise valid conflicting fork after validator trust assumptions have failed, and docs/tendermint-core/light-client.md currently says the client is unsafe only when all witnesses are malicious. Fail closed when both sides provide conflicting valid headers, or explicitly adopt, document, and enforce a suitable honest-majority provider model rather than automatically selecting the primary's side.

source: ['codex']


return nil
}

Expand Down
11 changes: 5 additions & 6 deletions light/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -515,13 +515,12 @@ func TestClient(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, 2, len(c.Witnesses()))

// A witness reporting a header that conflicts with the primary's fails the
// verification; the dissenting witness is kept, since the primary may be
// the faulty party.
// A single witness reporting a header that conflicts with the primary's is
// removed (treated as faulty), NOT fatal: the good witness corroborates the
// primary, so one dissenter must not break verification.
_, err = c.VerifyLightBlockAtHeight(ctx, 2, bTime.Add(2*time.Hour).Add(1*time.Second))
require.Error(t, err)
assert.ErrorIs(t, err, light.ErrConflictingWitnessHeader)
assert.Equal(t, 2, len(c.Witnesses()))
require.NoError(t, err)
assert.Equal(t, 1, len(c.Witnesses()))
mockFullNode.AssertExpectations(t)
})
t.Run("PrunesHeadersAndValidatorSets", func(t *testing.T) {
Expand Down
32 changes: 28 additions & 4 deletions light/detector_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,9 @@ func TestCompareNewHeaderWithWitness_Match(t *testing.T) {
}

// TestCompareFirstHeaderWithWitnesses covers the witness cross-check flow: a
// conflicting witness fails verification while being kept, a bad witness is
// removed, and full agreement succeeds.
// single conflicting witness is removed when corroborated, multiple conflicting
// witnesses fail as fork evidence, a bad witness is removed, and full agreement
// succeeds.
func TestCompareFirstHeaderWithWitnesses(t *testing.T) {
primary := testLightBlock(5, []byte("primary-validators-hash"))
matching := func() *types.LightBlock { return testLightBlock(5, []byte("primary-validators-hash")) }
Expand All @@ -117,16 +118,39 @@ func TestCompareFirstHeaderWithWitnesses(t *testing.T) {
wantWitnessCount: 2,
},
{
name: "one conflicting witness fails verification but is kept",
name: "one conflicting witness with a corroborating witness is removed, not fatal",
witnesses: func() []provider.Provider {
return []provider.Provider{
mockWitness(matching(), "w0"),
mockWitness(conflicting(), "w1"),
}
},
wantErr: false,
wantWitnessCount: 1,
},
{
name: "two conflicting witnesses fail verification (fork evidence)",
witnesses: func() []provider.Provider {
return []provider.Provider{
mockWitness(matching(), "w0"),
mockWitness(conflicting(), "w1"),
mockWitness(conflicting(), "w2"),
}
},
wantErr: true,
wantConflict: true,
wantWitnessCount: 2,
wantWitnessCount: 3,
},
{
name: "single conflicting witness with no corroborating witness fails",
witnesses: func() []provider.Provider {
return []provider.Provider{
mockWitness(conflicting(), "w1"),
}
},
wantErr: true,
wantConflict: true,
wantWitnessCount: 1,
},
{
name: "an erroring witness is removed when others agree",
Expand Down
31 changes: 20 additions & 11 deletions light/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ type Proxy struct {
Client *lrpc.Client
Logger log.Logger
Listener net.Listener

// AllowedOrigins restricts which web origins may open a WebSocket to the
// proxy. It mirrors rpc.Config.CORSAllowedOrigins semantics. An empty/nil
// slice enforces the zero-trust default: only same-host origins and
// Origin-less (non-browser) clients are accepted. Operators that must permit
// specific browser origins set this explicitly; a "*" entry allows any
// origin (discouraged).
AllowedOrigins []string
}

// NewProxy creates the struct used to run an HTTP server for serving light
Expand All @@ -39,10 +47,11 @@ func NewProxy(
}

return &Proxy{
Addr: listenAddr,
Config: config,
Client: lrpc.NewClient(logger, rpcClient, lightClient, opts...),
Logger: logger,
Addr: listenAddr,
Config: config,
Client: lrpc.NewClient(logger, rpcClient, lightClient, opts...),
Logger: logger,
AllowedOrigins: nil,
}, nil
}

Expand Down Expand Up @@ -105,13 +114,13 @@ func (p *Proxy) listen(ctx context.Context) (net.Listener, *http.ServeMux, error
}),
rpcserver.ReadLimit(p.Config.MaxBodyBytes),
)
// Preserve the light proxy's historic permissive websocket origin policy:
// allow every origin. The proxy is configured via rpcserver.Config, which
// exposes no CORS allow-list knob, so the WebsocketManager default
// (same-host/Origin-less only) would silently reject browser clients that
// worked before. Operators that need origin restrictions are expected to
// enforce AuthN/AuthZ in front of the proxy.
wm.CheckOrigin = func(*http.Request) bool { return true }
// WebSocket origin policy is zero-trust by default: OriginChecker with no
// allow-list accepts only same-host origins and Origin-less (non-browser)
// clients, rejecting cross-origin browser requests. Operators that need to
// permit specific browser origins set Proxy.AllowedOrigins (mirrors
// rpc.Config.CORSAllowedOrigins); a "*" entry allows any origin but is
// discouraged. This replaces the previous blanket allow-all policy.
wm.CheckOrigin = rpcserver.OriginChecker(p.Logger, p.AllowedOrigins)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Blocking: Same-host fallback permits DNS-rebinding WebSocket access

OriginChecker trusts the request's unvalidated Host header whenever it matches the Origin host, even when the configured allow-list is empty. An attacker can serve a page from http://evil.example:8888, rebind that hostname to the victim's loopback or private address, and connect to ws://evil.example:8888/websocket. The browser then sends both Origin: http://evil.example:8888 and Host: evil.example:8888, so the check succeeds against the default localhost light proxy. This exposes WebSocket RPC methods, including transaction broadcasting and subscriptions, despite the claimed zero-trust default. Reject Origin-bearing requests unless the origin is explicitly configured, or independently validate Host against trusted listener or reverse-proxy hostnames.

source: ['codex']


mux.HandleFunc("/websocket", wm.WebsocketHandler)

Expand Down
Loading