fix(nitronode): remediate MF3-I* audit findings#814
Conversation
…les cap Setting NITRONODE_MAX_SESSION_KEYS_PER_USER to 0 or a negative value disables the per-user session key cap entirely, since both registration paths gate the count on maxSessionKeysPerUser > 0. Document this behavior on the config field and in the app-session submit README so operators do not misread 0 as invalid or zero-keys-allowed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
verifyAssetsConfig() validated asset fields but accepted both "usdt" and "USDT" in the same config. Downstream logic matches asset symbols case-sensitively in the in-memory supported-asset registry and case-insensitively in the persistence layer, so duplicate canonical symbols make asset handling ambiguous. Track enabled asset symbols by their lowercased form and fail startup on a case-insensitive (or exact) duplicate. Disabled assets are skipped, and request-time exact-symbol semantics are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The missing-user-signature guard formatted a stale err that is always nil at that point (the preceding PackState error was already returned), polluting the message with "<nil>". Drop the format arg. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…case NewTransactionFromTransition dereferenced receiverState.UserWallet in the 'release' branch before the nil guard, so a release transition with a nil receiver state panicked instead of returning a controlled error. No RPC path passes nil today, but the helper is now safe regardless of caller. Move the nil check ahead of any field access and add a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GetOffsetAndLimit returned a caller-controlled uint32 offset that every store query converts via int(offset) before GORM's Offset(). On a 32-bit target a large offset wraps to a negative int, which GORM treats as "no offset" and silently returns the first page instead of the requested one. Clamp offset to MaxInt32 in GetOffsetAndLimit so the conversion is always a non-negative int, then route the handlers that still computed offset/limit by hand through the same helper so the clamp is enforced everywhere: - channels.v1.get_channels - channels.v1.get_last_key_states - app_sessions.v1.get_last_key_states The app session, transaction, and app list paths already used GetOffsetAndLimit and need no change. An explicit limit of 0 still falls back to the endpoint default so page-count math never divides by zero. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nactive Both SubmitSessionKeyState handlers logged "session key revoked" whenever the submitted expires_at was not after now, ignoring whether the previous latest state was active. Inactive-to-inactive updates, and new versions submitted already-expired, were mislogged as revocations. Track the real transition with revoked := prevActive && !submittedActive (hoisted out of the tx closure) and emit the revocation log only then. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hRegex GetAppSessions accepted a non-nil but empty app_session_id, which the store treated as "no filter", silently returning an unfiltered paginated list instead of honoring the documented either-id-or-participant contract. Add core.HashRegex + core.IsValidHash (a 0x-prefixed 32-byte hash, the canonical form of channel and app-session IDs) mirroring app.IsValidApplicationID, and reject malformed IDs at the RPC boundary: - GetAppSessions: reject a provided app_session_id that is not a valid hash - GetEscrowChannel: reject an escrow_channel_id that is not a valid hash HomeChannelID is left as-is: it has no bare-id read endpoint and is already verified by exact derivation match (request_creation) or existence lookup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…fields UpdateChannel rewrote immutable channel config (blockchain_id, token, nonce) on every lifecycle update. Callers normally write them back unchanged, but a future caller passing a partial or stale core.Channel could silently corrupt a channel's identity/config, widening the blast radius of a local update. Persist only the mutable lifecycle fields (status, state_version, challenge_expires_at, updated_at). Immutable config is set once in CreateChannel. Update the test to assert immutable fields are left intact. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughAdds canonical hash validation and tests, centralizes pagination via PaginationParams and SafeOffset to avoid offset wraparound, refines session-key revocation logging to actual active→inactive transitions, restricts UpdateChannel to mutable fields, enforces case-insensitive enabled-asset symbol uniqueness, and includes small bugfixes and doc updates. ChangesInput validation, pagination, and state transitions
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
nitronode/store/memory/asset_config_test.go (1)
171-181: ⚡ Quick winAdd reverse-order disabled duplicate test case.
Please add one more subtest where the disabled duplicate appears first and the enabled asset appears second, to lock in the “disabled assets are skipped regardless of ordering” contract.
Proposed test addition
+ t.Run("duplicate symbol with first asset disabled", func(t *testing.T) { + cfg := AssetsConfig{ + Assets: []AssetConfig{ + {Symbol: "USDT", Disabled: true}, + {Symbol: "usdt", SuggestedBlockchainID: 1}, + }, + } + err := verifyAssetsConfig(&cfg) + require.NoError(t, err) + })🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@nitronode/store/memory/asset_config_test.go` around lines 171 - 181, Add a new subtest that mirrors the existing "duplicate symbol with disabled asset" case but with the disabled AssetConfig listed first to ensure ordering doesn't matter: create an AssetsConfig with AssetConfig{Symbol: "USDT", Disabled: true} followed by AssetConfig{Symbol: "usdt", SuggestedBlockchainID: 1}, call verifyAssetsConfig(&cfg) and assert require.NoError(t, err). Use a distinct t.Run name like "duplicate symbol with disabled asset reversed" and reference the same types (AssetsConfig, AssetConfig) and function verifyAssetsConfig to locate where to add the test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@nitronode/store/memory/asset_config_test.go`:
- Around line 171-181: Add a new subtest that mirrors the existing "duplicate
symbol with disabled asset" case but with the disabled AssetConfig listed first
to ensure ordering doesn't matter: create an AssetsConfig with
AssetConfig{Symbol: "USDT", Disabled: true} followed by AssetConfig{Symbol:
"usdt", SuggestedBlockchainID: 1}, call verifyAssetsConfig(&cfg) and assert
require.NoError(t, err). Use a distinct t.Run name like "duplicate symbol with
disabled asset reversed" and reference the same types (AssetsConfig,
AssetConfig) and function verifyAssetsConfig to locate where to add the test.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: a7239c22-065f-486d-806a-edb310727feb
📒 Files selected for processing (20)
nitronode/api/app_session_v1/README.mdnitronode/api/app_session_v1/get_app_sessions.gonitronode/api/app_session_v1/get_app_sessions_test.gonitronode/api/app_session_v1/get_last_key_states.gonitronode/api/app_session_v1/submit_session_key_state.gonitronode/api/channel_v1/get_channels.gonitronode/api/channel_v1/get_escrow_channel.gonitronode/api/channel_v1/get_escrow_channel_test.gonitronode/api/channel_v1/get_last_key_states.gonitronode/api/channel_v1/submit_session_key_state.gonitronode/api/channel_v1/submit_state.gonitronode/runtime.gonitronode/store/database/channel.gonitronode/store/database/channel_test.gonitronode/store/memory/asset_config.gonitronode/store/memory/asset_config_test.gopkg/core/types.gopkg/core/types_test.gopkg/core/utils.gopkg/core/utils_test.go
ihsraham
left a comment
There was a problem hiding this comment.
approving for the MF3-I batch. the fixes cover the audit asks: the session-key cap behavior is documented, asset symbols are checked case-insensitively at config load, the stale signature error is cleaned up, the release transaction nil check is before the dereference, ID filters are rejected before store reads, revocation logging now keys off an active-to-inactive transition, and UpdateChannel no longer rewrites immutable config fields.
one non-blocking follow-up: the I-05 offset clamp is enforced at the current RPC callers before they reach the store, which closes the runtime path I reviewed. the lower-level store methods still accept raw uint32 offset values and cast them with int(offset), so if we want the store API itself to carry the invariant, it would be worth adding the same clamp there too.
also worth running gofmt on nitronode/runtime.go, nitronode/store/database/channel_test.go, pkg/core/types.go, and pkg/core/utils_test.go before merge. my local checks otherwise passed, and the scanner output I saw was existing/toolchain noise rather than PR-introduced risk.
nksazonov
left a comment
There was a problem hiding this comment.
Good work — all eight addressed findings are correctly implemented and backed by targeted regression tests.
| // 32-bit target int(offset) wraps to a negative value for large uint32s, | ||
| // which GORM treats as "no offset" and silently returns the first page. | ||
| // Clamp to MaxInt32 so the conversion is always a non-negative int. | ||
| offset = min(offset, math.MaxInt32) |
There was a problem hiding this comment.
The clamp enforces the invariant at the RPC handler layer. However, six store methods still do int(offset) directly without a guard: app.go:79, app_session.go:119, channel.go:192, transaction.go:109, app_session_key_state.go:142, channel_session_key_state.go:123. Any caller that reaches the store without routing through GetOffsetAndLimit — admin tooling, future internal endpoints, tests passing raw values — can still trigger the silent first-page bug. Consider either adding the clamp in the store query helpers, or adding a doc comment on each store method requiring callers to pre-clamp.
Carry the I-05 pagination offset invariant into the store API: add core.SafeOffset, which clamps a uint32 offset to MaxInt32 so int(offset) never wraps negative (GORM treats a negative offset as "no offset" and silently returns the first page). Apply it at the six store query sites that previously cast offset raw, so callers reaching the store without routing through PaginationParams.GetOffsetAndLimit stay safe. Also gofmt the files flagged in review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… fix/mf3-i # Conflicts: # pkg/core/types_test.go
- MF3-C01: fix(nitronode): require enforcing deposit states before crediting off-chain (#808) - MF3-M04: fix(nitronode): fix uint8 overflow in quorum weight accumulators (#802) - MF-L02: fix(nitronode): lock user balance row in HandleHomeChannelCreated + backfill unsigned receiver states (#803) - MF3-H01: fix(nitronode): fix pagination.limit=0 DoS (divide-by-zero) (#801) - fix(go-sdk): preserve channel challenge expiry (#816) - MF3-L01: fix(core): align transaction ID with referencing transition TxID (#807) - MF3-L10: reject missing last user state in submit_deposit_state (#812) - MF3-L11: fix unbounded responseSinks growth on marshal failure (#813) - fix(nitronode): remediate MF3-I* audit findings (#814) - MF3-L04: fix(nitronode): enforce canonical allocations in deposit state (#811) - MF3-M01, M02, M03, L09: feat(app-sessions): remove rebalance feature (#806) - MF3-L05, L06, L07, L08, I11 : feat(nitronode): remove app registry, action allowances, and user staking (#810) - MF3-M05: allow wallet-only revocation of session keys (#809) - MF3-L14: enforce request-rate limit at the frame layer (#819) - MF3-H02: lock home channel before reading status in event handlers (#820) - MF3-L13: document asset-symbol equivalence operator invariant (#818) - MF3-L16: fix(rpc): escape error message in NewErrorPayload (#829) - MF3-I12, I13, I14: nitronode audit remediations (#822) - MF3-L12: validate session-key scope ID formats at request boundary (#826) - MF3-H03: fix(nitronode): skip unsupported NodeBalanceUpdated tokens instead of fatal (#827) - MF3-L17, I15, I16: app-session participant docs, Void-checkpoint promotion, signature canonicalization note (#828) - MF3-L18: fix(nitronode): lock transfer balances in deterministic order (#830) - MF3-L03: docs: document app session close atomicity blocking on in-flight escrow (#831) - MF3-L15: fix(nitronode): fix reorg double-spend via confirmation gate (#832) - MF3-L19: fix: prevent SC reentrancy + event-handler monotonicity (#837)
Remediates the MF3-I* batch of audit findings (informational severity). Each finding is its own commit so they can be reviewed independently.
Findings
MF3-I01 —
MAX_SESSION_KEYS_PER_USER=0silently disables the per-user capThe cap is skipped whenever
maxSessionKeysPerUser <= 0, but that wasn't documented and0could be read as "invalid" or "zero keys allowed". Documented<= 0as the unlimited mode on the config field and in the app-session submit README. Kept it as a documented behavior rather than a startup-reject, since that matches the existingWsBytesPerSec"<0 to disable" /MaxSignedUpdates0conventions and avoids breaking anyone already running0.MF3-I02 — duplicate asset symbols accepted case-insensitively
verifyAssetsConfig()accepted bothusdtandUSDT, which is ambiguous because the in-memory registry matches symbols case-sensitively while the persistence layer lowercases them. Now tracks enabled symbols by their lowercased form and fails startup on a case-insensitive (or exact) duplicate. Disabled assets are skipped; request-time exact-symbol semantics are unchanged.MF3-I03 — stale
errin missing-user-signature errorThe missing-
user_sigguard formatted anerrthat is alwaysnilat that point, polluting the message with<nil>. Dropped the format arg.MF3-I04 — nil-deref in
NewTransactionFromTransitionrelease branchThe
releasecase dereferencedreceiverState.UserWalletbefore the nil check, so a nil receiver state panicked instead of returning an error. Moved the nil check ahead of any field access. No RPC path passes nil today, but the helper is now safe regardless of caller. Added a regression test.MF3-I05 — pagination offset can wrap negative on 32-bit
GetOffsetAndLimitreturned a caller-controlleduint32offset that every store query converts viaint(offset)before GORM'sOffset(); on a 32-bit target a large offset wraps negative and GORM treats it as "no offset", silently returning the first page. Clamped offset toMaxInt32inGetOffsetAndLimit, then routed the handlers that still computed offset/limit by hand through the same helper so the clamp is enforced everywhere (channels.v1.get_channels,channels.v1.get_last_key_states,app_sessions.v1.get_last_key_states). An explicitlimit: 0still falls back to the endpoint default so page-count math never divides by zero.MF3-I07 — session-key revocation mislogged
Both
SubmitSessionKeyStatehandlers logged a revocation whenever the submittedexpires_atwas not in the future, ignoring whether the previous state was active — so inactive-to-inactive updates (and already-expired new versions) were logged as revocations. Now gated on the real transition:revoked := prevActive && !submittedActive.MF3-I08 — unvalidated channel / app-session IDs
GetAppSessionsaccepted a non-nil but emptyapp_session_id, which the store treated as "no filter", silently returning an unfiltered paginated list instead of honoring the documented either-id-or-participant contract. Addedcore.HashRegex+core.IsValidHash(a0x-prefixed 32-byte hash — the canonical form of channel and app-session IDs), mirroringapp.IsValidApplicationID, and reject malformed IDs at the RPC boundary inGetAppSessionsandGetEscrowChannel.HomeChannelIDis left as-is — it has no bare-id read endpoint and is already verified by exact derivation match or existence lookup.MF3-I09 —
UpdateChannelrewrites immutable configUpdateChannelrewrote immutable config (blockchain_id,token,nonce) on every lifecycle update, widening the blast radius if a future caller passes a partial/stalecore.Channel. Restricted it to mutable lifecycle fields (status,state_version,challenge_expires_at,updated_at); immutable config is set once inCreateChannel.Test plan
go build ./...go test ./pkg/core/... ./nitronode/store/database/... ./nitronode/api/channel_v1/... ./nitronode/api/app_session_v1/... ./nitronode/event_handlers/...go vet ./...on touched packages🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
New Features
Improvements
Documentation