Skip to content

fix: bound retained chat-reaction message IDs and validate relay identity - #9571

Open
mikhail-dcl wants to merge 6 commits into
devfrom
fix/sec-085-reaction-messageid-retention
Open

fix: bound retained chat-reaction message IDs and validate relay identity#9571
mikhail-dcl wants to merge 6 commits into
devfrom
fix/sec-085-reaction-messageid-retention

Conversation

@mikhail-dcl

Copy link
Copy Markdown
Collaborator

Closes SEC-085.

Problem

Inbound ChatReaction built its dedup key from the peer-controlled MessageId and retained it before anything checked the message existed, so a peer could flood unique packet-sized IDs and grow the dedup set unbounded for the full 5-minute window. Payload.Address was trusted on the relay path with no format validation, handing an attacker the wallet half of that key too.

#9489's rate limiter already capped the downstream work, so the residual was retained memory, not a CPU/GC storm.

Changes

  • Reject empty / over-length MessageId at intake, before any key is built. Dropped silently: ReportHub.LogWarning is not compiled out, so naming the ID would allocate it once per packet in retail builds.
  • MessageDeduplication<T> gains an opt-in capacity (default unbounded, so other callers are unchanged). Both reaction caches and the nearby-chat one are bounded; at capacity Register drops the window instead of growing it.
  • Trust Payload.Address only when it arrives from the message-router and passes Web3Address.IsValidWalletAddress (the validator fix: authenticate chat sender identity before trusting ForwardedFrom #9501 just added).
  • Return a NullReactionMessageBus while alfa-chat-reactions is off, so no pipe is subscribed — the flag previously gated the UI only.

Deliberately unchanged: the rate limiter stays after dedup. Nearby reactions legitimately arrive on both the island and scene pipes, so moving it earlier would halve every honest client's budget.

Heads-up on the second commit

dev does not compile on its own — #9501's new LiveKitChatMessagesBusShould calls CommunitiesFeatureAccess(identityCache, appArgs) while #9472 added a required third warmUpCt parameter. CS7036 kills the whole EditMode assembly, so no test runs on dev at all. Fixed in its own commit here so it can be dropped or moved to a hotfix.

QA

Reactions are behind alfa-chat-reactions.

  1. Flag ON — add and remove reactions in nearby, DM and community channels; they must appear on other clients and survive add→remove→add toggling.
  2. Community reactions relayed through the router must still show the original sender, not message-router-{env}-0. Check both zone and orgroutingUser is environment-derived. This is the main regression risk.
  3. Flag OFF — no reaction UI and no reaction processing.
  4. Nearby chat unaffected — the dedup bound applies there too.

Under sustained flood a dedup cache at capacity drops its window, so a duplicate nearby message can render once more. Reactions converge, being set operations.

Out of scope

Fully closing attribution needs a server-stamped sender on ChatReaction, which has no forwarded_from field — that is comms-message-sfu work.

Verified: 96/96 EditMode tests, 0 compile errors, no new lint findings.

🤖 Generated with Claude Code

mikhail-dcl and others added 3 commits August 3, 2026 18:08
…tity

Inbound ChatReaction built its dedup key from the peer-controlled MessageId and
retained it before anything verified that the message existed, so a peer could
flood unique packet-sized IDs and grow the dedup set unbounded for the full
5-minute window. The relayed Payload.Address was trusted with no format
validation at all, which also handed an attacker the wallet half of that key.

- Reject empty or over-length MessageId at intake, before any key is built.
  Locally produced IDs are either ChatUtils.GetId or a GUID, so the cap clears
  both with headroom. Dropped silently: ReportHub.LogWarning is not compiled
  out, so naming the ID would allocate it once per packet in retail builds.
- Give MessageDeduplication<T> an opt-in capacity (default unbounded, leaving
  other callers unchanged) and bound both reaction caches plus the nearby-chat
  one. At capacity Register drops the window instead of growing it.
- Trust Payload.Address only when it arrives from the message-router and passes
  Web3Address.IsValidWalletAddress, matching the Chat ForwardedFrom fix (#9501).
- Return a NullReactionMessageBus while alfa-chat-reactions is off so no pipe is
  subscribed; the flag previously gated the UI only.

SEC-085. The rate limiter deliberately stays after dedup: nearby reactions
legitimately arrive on both the island and scene pipes, so moving it earlier
would halve every honest client's budget.

Closing the attribution leg needs a server-stamped sender on the ChatReaction
wire type, which has no forwarded_from field - that is comms-message-sfu work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…us tests

dev does not compile on its own: #9501 added this fixture calling
CommunitiesFeatureAccess(identityCache, appArgs) while #9472 added a required
third warmUpCt parameter. Both merged without rebasing against each other, so
the EditMode assembly fails with CS7036 and no test can run.

Unrelated to SEC-085 — kept as its own commit so it can be dropped or moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mikhail-dcl
mikhail-dcl requested review from a team as code owners August 3, 2026 15:17
@decentraland-bot
decentraland-bot self-requested a review August 3, 2026 15:18
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

badge

New build in progress, come back later!

@decentraland-bot decentraland-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.

PR Review — fix: bound retained chat-reaction message IDs and validate relay identity

STEP 2 — Root-cause check ✅ PASS

The problem is unbounded memory growth in the dedup cache from peer-controlled MessageId values, and untrusted Payload.Address on the relay path. This PR fixes the cause, not a symptom:

  • Message IDs are validated (empty/over-length) at intake before any dedup key is constructed.
  • MessageDeduplication<T> gains an opt-in capacity that drops the window when the bound is reached.
  • Payload.Address is only trusted from the known routingUser identity and only when it passes Web3Address.IsValidWalletAddress.
  • NullReactionMessageBus eliminates the entire attack surface when alfa-chat-reactions is disabled.

STEP 3 — Design & integration ✅ PASS

NullReactionMessageBus — Correct null-object pattern. The empty add { } remove { } event accessors prevent subscriber leaks; no-op method bodies drop every send. The factory is the right gate point: CreateReactionBus is the composition root for the bus, so the decision between real and null implementations belongs there. All downstream code (ChatMessageReactionService, ReactionRouter, SituationalReactionFacade) operates against IReactionMessageBus without any conditional branches. Dispose() is a correct no-op. No lifecycle reconciliation or new long-lived unit is introduced — this is just a factory decision.

ResolveSenderWalletId extraction — Private method within the owning class, not a new unit. The security-critical wallet resolution logic (router check + IsValidWalletAddress validation) is cleanly isolated. Single use is justified for readability in a security-sensitive handler.

MessageDeduplication capacity — Opt-in parameterization of an existing class. The UNBOUNDED_CAPACITY default preserves backward compatibility — existing callers are unchanged. No new lifecycle owner, no persistent state outside ECS (this is a networking utility, not an ECS system).

Owner search: MessageDeduplication<T> is created as a field initializer in MultiplayerReactionMessageBus and LiveKitChatMessagesBus; both are created by their respective factories and disposed via EventSubscriptionScope / container disposal. No new lifecycle ownership is introduced.

STEP 4 — Member audit

Member Consumers Verdict
MessageDeduplication.UNBOUNDED_CAPACITY (public const) Default parameter on constructor; MessageDeduplicationShould.TreatTheUnboundedConstantAsNoLimit test Reasonable API — gives a name to the sentinel so callers don't pass raw 0. Consider narrowing to internal since the parameterless constructor already provides unbounded behavior.
MessageDeduplication(int capacity) (public ctor) MultiplayerReactionMessageBus (×2), LiveKitChatMessagesBus (×1) Clean — a convenience overload that delegates to the full constructor.
NullReactionMessageBus (all members) All are IReactionMessageBus interface implementations; created by ChatReactionsFactory.CreateReactionBus. Correct null-object pattern.
ResolveSenderWalletId (private) Called once from OnChatReactionReceived. Justified single-use extraction — isolates security logic for auditability.

STEP 5 — Line-level review

Security review: No issues found. The messageId length check is placed before dedup key construction. The wallet validation uses a robust format check (exactly 42 chars, 0x prefix, hex-only). The capacity bound prevents HashSet growth. The NullReactionMessageBus eliminates the attack surface when the feature is off. Pre-existing limitation (relay trusts client-supplied Payload.Address) is partially mitigated and explicitly documented as requiring server-stamped ForwardedFrom — that is out of scope (comms-message-sfu work).

Teardown/consumption trace: MultiplayerReactionMessageBus.Dispose()cts.SafeCancelAndDispose() — unchanged and correct. NullReactionMessageBus.Dispose() — correct no-op (nothing to clean up). No new subscriptions, event hookups, or connections are introduced that lack a matching teardown.

One P2 finding noted as inline comment below.

Observations (not blocking):

  • MAX_DEDUP_ENTRIES = 2048 is declared independently in both LiveKitChatMessagesBus and MultiplayerReactionMessageBus. Since these serve different subsystems (chat messages vs. reactions) and could diverge intentionally, this is acceptable. A brief comment noting the independence would prevent future consolidation mistakes.
  • DateTime.Now vs DateTime.UtcNow in MessageDeduplication is pre-existing. UtcNow avoids timezone conversion overhead and DST edge cases — worth a follow-up but not introduced by this PR.

STEP 6 — Complexity: COMPLEX

Modifies multiplayer deduplication infrastructure (MessageDeduplication<T>), chat-reactions networking (MultiplayerReactionMessageBus), and adds security-critical input validation. Touches 8 meaningful C# files across 2 subsystems.

STEP 7 — QA: YES

Runtime code changes affecting chat reactions (behind alfa-chat-reactions feature flag). Changes affect what users see (reaction attribution, reaction processing). QA plan is well-specified in the PR description.

STEP 8 — Non-blocking warnings

None. Main scene not modified.

STEP 9 — Verdict

REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Modifies multiplayer deduplication infrastructure and chat-reactions networking with security-critical input validation and relay identity checks
QA_REQUIRED: YES

Reviewed by Jarvis 🤖 · Requested by decentraland-bot via GitHub

Comment on lines +26 to 31
public MessageDeduplication(TimeSpan cleanPerPeriod, int capacity = UNBOUNDED_CAPACITY)
{
this.cleanPerPeriod = cleanPerPeriod;
this.capacity = capacity;
previousClean = DateTime.Now;
}

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.

[P2] Missing argument validation for negative capacity. Passing a negative value (e.g. capacity = -1) makes registeredStamps.Count >= capacity always true, so every Register call clears the set — dedup becomes completely ineffective. While all current callers pass known positive constants, a future caller could trigger this silently.

Suggested change
public MessageDeduplication(TimeSpan cleanPerPeriod, int capacity = UNBOUNDED_CAPACITY)
{
this.cleanPerPeriod = cleanPerPeriod;
this.capacity = capacity;
previousClean = DateTime.Now;
}
public MessageDeduplication(TimeSpan cleanPerPeriod, int capacity = UNBOUNDED_CAPACITY)
{
if (capacity < 0)
throw new ArgumentOutOfRangeException(nameof(capacity), capacity, "Capacity must be non-negative; use UNBOUNDED_CAPACITY (0) for no limit.");
this.cleanPerPeriod = cleanPerPeriod;
this.capacity = capacity;
previousClean = DateTime.Now;
}

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

🔍 Jarvis reviewed this PR and found no blocking issues, but assessed it as complex — human DEV review is still required before merging.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

badge

All Unity tests passed ✅

TESTS SUITE Result Passed Failed Skipped
EditMode ✅ Passed 24476 0 13
PlayMode ✅ Passed 236 0 5

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

badge

Warnings not reduced: 13729 => 13729 — remove at least 1 warning to merge.

Warnings/errors in files changed by this PR (9)
Assets/DCL/Multiplayer/Deduplication/MessageDeduplication.cs:61  InconsistentNaming  Name 'timestamp' does not match rule 'members_should_be_pascal_case'. Suggested name is 'Timestamp'.
Assets/DCL/Multiplayer/Deduplication/MessageDeduplication.cs:60  InconsistentNaming  Name 'walletId' does not match rule 'members_should_be_pascal_case'. Suggested name is 'WalletId'.
Assets/DCL/Chat/_Refactor/ChatReactions/Networking/MultiplayerReactionMessageBus.cs:111  RedundantNameQualifier  Qualifier is redundant
Assets/DCL/Chat/_Refactor/ChatReactions/Networking/MultiplayerReactionMessageBus.cs:115  RedundantNameQualifier  Qualifier is redundant
Assets/DCL/Chat/_Refactor/ChatReactions/Networking/MultiplayerReactionMessageBus.cs:119  RedundantNameQualifier  Qualifier is redundant
Assets/DCL/Chat/_Refactor/ChatReactions/Core/ChatReactionsFactory.cs:14  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/Chat/_Refactor/ChatReactions/Networking/MultiplayerReactionMessageBus.cs:3  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/Chat/_Refactor/ChatReactions/Networking/MultiplayerReactionMessageBus.cs:14  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/Chat/_Refactor/ChatReactions/Networking/MultiplayerReactionMessageBus.cs:18  RedundantUsingDirective  Using directive is not required by the code and can be safely removed

@Ludmilafantaniella Ludmilafantaniella 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.

QA Findings

Tested with alfa-chat-reactions ON/OFF, on .org.

✅ Working as expected

  • Reactions add/remove correctly in nearby, DM, and community channels
  • Reactions appear on other clients
  • Add → remove → add toggling works without issues
  • Community reactions correctly show the original sender's name, not message-router-{env}-0 - verified on both .zone and .org
  • Flag OFF: no reaction UI, no reaction processing
  • Nearby chat unaffected with the dedup bound in place
image (2) community-pass

🐛 Issues found (flag OFF - UI doesn't seem to reflow correctly without reactions)

1. Scroll-to-bottom button misplaced (visual only)
The chevron button to scroll to the bottom of the chat sidebar renders near the top instead of at the bottom. Purely cosmetic - button still functions correctly.

2. Emoji picker overlaps chat in community voice streams
When in a community voice stream, opening the emoji picker overlaps with the chat panel, making it impossible to see the emoji being added. This does not happen in 1:1 calls, where the same picker displays correctly.

Image Image

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

New build in progress, come back later!

Lint

Lint in progress, come back later!

Tests

All Unity tests passed ✅

TESTS SUITE Result Passed Failed Skipped
EditMode ✅ Passed 25011 0 13
PlayMode ✅ Passed 236 0 37

@decentraland-bot

Copy link
Copy Markdown
Contributor

PR #9571, run #32021426676

Builds: Windows change, Windows baseline, macOS change, macOS baseline

How to read this table
  • Each build is measured 3 times. The values are the median, and (min–max) is the lowest and highest of those runs — a wide range means the metric is noisy and small differences are not trustworthy.
  • Δ is Change minus Baseline (a negative Δ means Change is faster).
  • 🟢 faster / 🔴 slower — a real difference: larger than both 3% and the run-to-run range.
  • ⚪ within noise — the difference is smaller than how much the build varies between its own runs, so it cannot be told apart from random variation. Treat it as no change.
  • Exceptions per run — the average number of exceptions in a run's log; more than the baseline is flagged 🔴 even when frame times look fine. The Exception breakdown under each table groups them by the explorer's report category and exception type (as totals across the runs).
  • A run that logged unusually many exceptions (at least 10 and 5× the median of its build's runs — e.g. a service was down during it) is excluded from all numbers and called out under the table.

Intel Core i5

Metric Baseline Change Δ Result
Samples 2701 (×3) 2363 (×3)
CPU average 33.2 ms (33.2–34.6) 37.8 ms (37.2–37.9) 4.7 ms 🔴 14% slower
CPU 1% worst 34.3 ms (33.5–184.5) 298.6 ms (290.4–311.0) 264.3 ms 🔴 771% slower
CPU 0.1% worst 41.4 ms (33.7–332.0) 315.1 ms (313.6–337.7) 273.7 ms ⚪ within noise
GPU average 9.3 ms (9.2–9.4) 8.1 ms (8.1–8.3) -1.2 ms 🟢 12% faster
GPU 1% worst 20.7 ms (19.8–26.9) 18.9 ms (18.9–19.0) -1.7 ms ⚪ within noise
GPU 0.1% worst 36.3 ms (31.6–37.7) 19.4 ms (19.3–20.4) -17.0 ms 🟢 47% faster
Exceptions per run 66 66 0 ⚪ none new
Exception breakdown
Exception Baseline (3 runs) Change (3 runs)
[UI] DllNotFoundException 192 192
[ENGINE] NullReferenceException 3 3
[ENGINE] ObjectDisposedException 3 3

Apple M1

Metric Baseline Change Δ Result
Samples 4368 (×3) 4020 (×3)
CPU average 20.5 ms (20.3–21.6) 22.3 ms (22.1–22.5) 1.8 ms 🔴 9% slower
CPU 1% worst 34.7 ms (33.9–34.7) 232.9 ms (228.3–233.9) 198.2 ms 🔴 571% slower
CPU 0.1% worst 34.9 ms (34.9–35.3) 236.8 ms (234.5–240.0) 201.9 ms 🔴 578% slower
GPU average 1.0 ms (0.1–1.6) 3.1 ms (2.5–6.1) 2.1 ms ⚪ within noise
GPU 1% worst 34.2 ms (7.7–34.8) 34.9 ms (34.6–35.3) 0.8 ms ⚪ within noise
GPU 0.1% worst 35.9 ms (35.1–37.2) 36.9 ms (36.3–37.8) 1.0 ms ⚪ within noise
Exceptions per run 0 0 0 ⚪ none new

@Ludmilafantaniella
Ludmilafantaniella self-requested a review August 17, 2026 12:55

@Ludmilafantaniella Ludmilafantaniella 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.

✅ Approve

Tested with alfa-chat-reactions ON/OFF, on Windows and Mac (org and zone).

Working as expected

  • Reactions add/remove correctly in nearby, DM, and community channels
  • Reactions appear on other clients
  • Add → remove → add toggling works without issues
  • Community reactions correctly show the original sender's name, not message-router-{env}-0 - verified on both .zone and .org
  • Flag OFF: no reaction UI, no reaction processing
  • Nearby chat unaffected with the dedup bound in place

Previously found issues - resolved

  • Scroll-to-bottom button "misplacement" - re-checked, layout is actually correct as-is, not an issue.
  • Emoji picker overlapping chat in community voice streams - fixed in 43429e4. Confirmed working correctly on latest commit (initial retest was on the wrong commit, verified again after).
  • Minor known inconsistency (non-blocking, per dev): if you start a community call while chat is focused and press emotes for the first time, the panel appears in the alternate position; pressing it again returns it to the regular spot. Cosmetic edge case tied to the layout complexity, not going to file separately per dev's note.

No blockers.

Image
9571-evi-flaOn.mp4

(more evidence on previous comment)

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