Skip to content

fix(nitronode): remediate MF3-I* audit findings#814

Merged
philanton merged 10 commits into
fix/audit-findings-finalx3from
fix/mf3-i
Jun 5, 2026
Merged

fix(nitronode): remediate MF3-I* audit findings#814
philanton merged 10 commits into
fix/audit-findings-finalx3from
fix/mf3-i

Conversation

@philanton

@philanton philanton commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

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=0 silently disables the per-user cap
The cap is skipped whenever maxSessionKeysPerUser <= 0, but that wasn't documented and 0 could be read as "invalid" or "zero keys allowed". Documented <= 0 as 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 existing WsBytesPerSec "<0 to disable" / MaxSignedUpdates 0 conventions and avoids breaking anyone already running 0.

MF3-I02 — duplicate asset symbols accepted case-insensitively
verifyAssetsConfig() accepted both usdt and USDT, 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 err in missing-user-signature error
The missing-user_sig guard formatted an err that is always nil at that point, polluting the message with <nil>. Dropped the format arg.

MF3-I04 — nil-deref in NewTransactionFromTransition release branch
The release case dereferenced receiverState.UserWallet before 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
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 negative and GORM treats it as "no offset", silently returning the first page. Clamped offset to MaxInt32 in GetOffsetAndLimit, 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 explicit limit: 0 still falls back to the endpoint default so page-count math never divides by zero.

MF3-I07 — session-key revocation mislogged
Both SubmitSessionKeyState handlers logged a revocation whenever the submitted expires_at was 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
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. Added 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 in GetAppSessions and GetEscrowChannel. HomeChannelID is left as-is — it has no bare-id read endpoint and is already verified by exact derivation match or existence lookup.

MF3-I09 — UpdateChannel rewrites immutable config
UpdateChannel rewrote immutable config (blockchain_id, token, nonce) on every lifecycle update, widening the blast radius if a future caller passes a partial/stale core.Channel. Restricted it to mutable lifecycle fields (status, state_version, challenge_expires_at, updated_at); immutable config is set once in CreateChannel.

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

    • Session-key revocation logging now only fires when a key actually transitions from active to inactive
    • Error message for missing state signature corrected
    • Prevented a potential crash when handling a specific transaction case
  • New Features

    • Validation added for session and channel identifiers to reject malformed IDs
  • Improvements

    • Centralized, more consistent pagination and safer offset handling across endpoints
    • Asset symbol validation now treats duplicates as case-insensitive
    • Channel updates no longer modify immutable configuration fields
  • Documentation

    • Clarified per-user active session-key cap semantics and disabling behavior

philanton and others added 8 commits June 3, 2026 14:10
…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>
@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ecce22ee-0a39-4f20-bfac-6ce6649d6db0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds 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.

Changes

Input validation, pagination, and state transitions

Layer / File(s) Summary
Core utils & pagination clamping
pkg/core/utils.go, pkg/core/utils_test.go, pkg/core/types.go, pkg/core/types_test.go
Adds HashRegex and IsValidHash(), clamps pagination offsets to math.MaxInt32, adjusts TransitionTypeRelease nil-check ordering, and adds tests for SafeOffset, IsValidHash, and pagination boundary cases.
Handler ID validation guards
nitronode/api/app_session_v1/get_app_sessions.go, nitronode/api/app_session_v1/get_app_sessions_test.go, nitronode/api/channel_v1/get_escrow_channel.go, nitronode/api/channel_v1/get_escrow_channel_test.go
Validate provided hash IDs early with IsValidHash; return rpc.Errorf("invalid ...") for malformed IDs; update/add tests to assert invalid inputs are rejected and store methods are not invoked.
API pagination centralization
nitronode/api/app_session_v1/get_last_key_states.go, nitronode/api/channel_v1/get_channels.go, nitronode/api/channel_v1/get_last_key_states.go
Use core.PaginationParams and GetOffsetAndLimit to derive/clamp offset/limit consistently across handlers while preserving response metadata.
Store SafeOffset usage
nitronode/store/database/*.go (apps, app_session, app_session_key_state, channel_session_key_state, transaction, etc.)
Replace int(offset) casts with core.SafeOffset(offset) for GORM Offset(...) calls to avoid negative/overflow offsets.
Session-key revocation transition logic
nitronode/api/app_session_v1/submit_session_key_state.go, nitronode/api/channel_v1/submit_session_key_state.go
Introduce revoked flag computed from prior stored active state and submitted activeness; emit "revoked" logs only for active→inactive transitions.
Channel update immutability enforcement
nitronode/store/database/channel.go, nitronode/store/database/channel_test.go
UpdateChannel now persists only lifecycle fields (status, state_version, challenge_expires_at, updated_at); tests assert immutable config fields (blockchain_id, token, nonce) remain unchanged.
Case-insensitive asset symbol validation
nitronode/store/memory/asset_config.go, nitronode/store/memory/asset_config_test.go
Enforce case-insensitive uniqueness for enabled asset Symbol values via lowercased map lookup; add tests for conflicts and disabled-asset exceptions.
Bug fixes and docs
nitronode/api/channel_v1/submit_state.go, nitronode/runtime.go, nitronode/api/app_session_v1/README.md, tests
Fix undefined err formatting in SubmitState, add doc comment for ValidationLimits.MaxSessionKeysPerUser, update README clarifying cap semantics, and small test formatting cleanup.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • dimast-x
  • ihsraham
  • nksazonov

Poem

🐰 I hopped through hashes, tidy and neat,
Clamped the offsets so pages won't cheat,
Revoked when it truly fell night to day,
Immutable fields stood firm in their way,
Symbols sang lowercased — orderly and sweet.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and concisely identifies the main objective: remediating a set of audit findings (MF3-I*) in the nitronode component. While it doesn't enumerate all nine individual findings, it appropriately summarizes the primary change at a high level.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/mf3-i

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (1)
nitronode/store/memory/asset_config_test.go (1)

171-181: ⚡ Quick win

Add 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

📥 Commits

Reviewing files that changed from the base of the PR and between 0c46c9e and dd855d2.

📒 Files selected for processing (20)
  • nitronode/api/app_session_v1/README.md
  • nitronode/api/app_session_v1/get_app_sessions.go
  • nitronode/api/app_session_v1/get_app_sessions_test.go
  • nitronode/api/app_session_v1/get_last_key_states.go
  • nitronode/api/app_session_v1/submit_session_key_state.go
  • nitronode/api/channel_v1/get_channels.go
  • nitronode/api/channel_v1/get_escrow_channel.go
  • nitronode/api/channel_v1/get_escrow_channel_test.go
  • nitronode/api/channel_v1/get_last_key_states.go
  • nitronode/api/channel_v1/submit_session_key_state.go
  • nitronode/api/channel_v1/submit_state.go
  • nitronode/runtime.go
  • nitronode/store/database/channel.go
  • nitronode/store/database/channel_test.go
  • nitronode/store/memory/asset_config.go
  • nitronode/store/memory/asset_config_test.go
  • pkg/core/types.go
  • pkg/core/types_test.go
  • pkg/core/utils.go
  • pkg/core/utils_test.go

@ihsraham ihsraham left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 nksazonov left a comment

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.

Good work — all eight addressed findings are correctly implemented and backed by targeted regression tests.

Comment thread pkg/core/types.go
// 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)

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.

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>
@philanton philanton changed the base branch from main to fix/audit-findings-finalx3 June 5, 2026 10:23
… fix/mf3-i

# Conflicts:
#	pkg/core/types_test.go
@philanton philanton merged commit a145aae into fix/audit-findings-finalx3 Jun 5, 2026
6 of 7 checks passed
@philanton philanton deleted the fix/mf3-i branch June 5, 2026 10:27
nksazonov added a commit that referenced this pull request Jun 17, 2026
- 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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants