Skip to content

fix: require destination consent on event notification clicks - #9590

Open
mikhail-dcl wants to merge 2 commits into
devfrom
fix/sec-076-notification-realm-consent
Open

fix: require destination consent on event notification clicks#9590
mikhail-dcl wants to merge 2 commits into
devfrom
fix/sec-076-notification-realm-consent

Conversation

@mikhail-dcl

@mikhail-dcl mikhail-dcl commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Pull Request Description

What does this PR change?

Clicking an "event starting" notification changed the user's realm and teleported them straight away, using realm/position query params parsed out of notification.Metadata.Link. That link is event content authored by whoever created the event through the open Events API, so any wallet could create an event and choose where a subscribed victim lands — on one click, with no confirmation. Fixes SEC-076.

The realm host stays bound to the official world server, so this is a lure/phishing primitive rather than an arbitrary-catalyst takeover — the bounded cousin of SEC-003 (scene changeRealm) and SEC-004 (deep-link realm), both already fixed.

Changes

Area Change
Consent The click now issues the existing destination prompt and performs no navigation of its ownChangeRealmPromptController for a world, TeleportPromptController for a bare parcel. Approving the prompt is what navigates, which is the same arrangement scene changeRealm(), deep links and chat world links already use, so declining goes nowhere structurally rather than conditionally
Realm validation realm must name an ENS world (IsEns()), normalized to lower case before validating — the regex is case-sensitive on .eth, so a legitimate MyWorld.DCL.ETH would otherwise be rejected and, downstream, get a second .dcl.eth appended
Total parsing Uri.TryCreate / int.TryParse / a comma-count check replace new Uri(...), int.Parse(split[0]) and split[1]. A rejected link logs a warning and returns instead of throwing
Dependencies IRealmNavigator and IDecentralandUrlsSource are no longer needed by the handler; it takes IMVCManager. No plugin signature changed — both are still used elsewhere in CommunitiesPlugin

Why the parsing matters beyond tidiness: NotificationsBusController.ClickNotification invokes its subscribers as a multicast delegate with no try/catch, so a throw here escaped into the click dispatch and dropped every subscriber registered after this one for that notification type. A crafted event was a cheap way to break notification clicking.

Two intentional behaviour changes, both reviewed and accepted:

  • A world link without a parcel now gets allowsSpawnPointerOverride: true, so the world's own spawn point wins instead of forcing parcel (0,0). This matches deep-link semantics.
  • The approved parcel-only path routes through /goto, which posts a visible nearby-chat line. That is inherited from the existing prompt callback, not added here.

Otherwise the approved path is argument-for-argument equivalent to the old direct call, and it additionally inherits the prompt callback's ValidEnvironment and IsAlreadyOnRealm checks.

Not in scope: guarding ClickNotification itself. Because subscribers share one multicast delegate per NotificationType, one throwing subscriber permanently disables every later one for that type, and SubscribeToAllNotificationTypesClick puts such a subscriber in all ~30 chains. Fixing it properly means iterating GetInvocationList(), and the other ~20 subscribers are unaudited — its own change. The events and notifications-workers halves of the finding (validating event-supplied destinations at the source) are separate repos.

Test Instructions

Steps (standard run):

metaforge explorer run 9590

Steps (fresh account):

metaforge account create --clear
metaforge explorer run 9590

Prerequisites

  • An event you can attend/subscribe to, so an EVENTS_STARTED notification is delivered — the notification only arrives for events the account is interested in
  • That event's location set to a world (an ENS *.dcl.eth name) for rows 1–3, and to a Genesis parcel for rows 4–5

Test Steps

# Action Expected
1 Click an "event starting" notification whose event is in a world A destination-confirmation prompt appears before anything moves — the client does not change realm on the click alone
2 Confirm the prompt The realm changes and you land in that world, at the event's parcel if the link carried one
3 Click the notification again and cancel the prompt Nothing happens: no realm change, no teleport, still in the same world
4 Click a notification whose event is a Genesis parcel A teleport confirmation appears; confirming teleports to that parcel
5 Cancel that one No teleport
6 Click several notifications of other types (friend request, community, gift) ⚠️ All still respond normally — the click dispatch is shared, so a regression here would silence other notification types

Additional Testing Notes

  • Row 6 is the regression-sensitive one. The handler that changed is one subscriber on a shared multicast delegate, so the check that matters is that other notification types still respond.
  • Rows 3 and 5 are the actual security property: the destination is attacker-chosen, so declining must move nothing.
  • The malformed-link cases (link that is not an absolute URI, position=abc, position=5 with no comma, a realm that is not an ENS name) are covered by 20 EditMode tests in NotificationHandlerShould — they are impractical to produce by hand without crafting event payloads through the Events API, so they are verified there rather than manually.
  • Worth a word with the finding's assignee before merging: the Notion status reads "In progress", though nothing was in the code on dev.

Quality Checklist

  • Changes have been tested locally
  • Documentation has been updated (if required)
  • Performance impact has been considered
  • For SDK features: Test scene is included

🤖 Generated with Claude Code

Clicking an "event starting" notification changed the realm and teleported
the player straight from the `realm`/`position` query params of
`notification.Metadata.Link`. That link is event content authored by whoever
created the event through the open Events API, so any wallet could pick where
a subscriber lands. Every sibling path (scene `changeRealm()`, deep link, chat
world link) already asks for consent first; this one did not.

`NotificationHandler` now issues the existing consent prompt instead of
navigating: `ChangeRealmPromptController` for a world destination (carrying the
target parcel, as the deep link path does) and `TeleportPromptController` for a
parcel-only destination. Approving the prompt is what performs the navigation,
so declining moves the player nowhere.

The realm name is also validated with `IsEns()` — normalized first, since the
check is case sensitive on the ".eth" suffix — which keeps the destination on
the official world server instead of an arbitrary catalyst.

Parsing of the link is now total. `new Uri(...)`, `int.Parse` and `split[1]`
could each throw on a crafted link, and `NotificationsBusController.ClickNotification`
invokes its subscribers as a multicast delegate with no try/catch, so a throw
escaped into the click dispatch and dropped the later subscribers for that
notification type. Malformed input is now reported via `ReportHub.LogWarning`
and the click is ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mikhail-dcl
mikhail-dcl requested review from a team as code owners August 4, 2026 14:51
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

badge

New build in progress, come back later!

@github-actions
github-actions Bot requested a review from DafGreco August 4, 2026 14:52
@decentraland-bot
decentraland-bot self-requested a review August 4, 2026 14:52

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

STEP 1 — Context & Scope

Files reviewed: NotificationHandler.cs, CommunitiesPlugin.cs, NotificationHandlerShould.cs, .meta

Surrounding context loaded: NotificationsBusController.cs (multicast delegate dispatch, subscribe/unsubscribe API), CancellationTokenExtensions.cs (SafeRestart, SafeCancelAndDispose), EnsExtensions.cs (IsEns regex), ChangeRealmPromptController.Params.cs, TeleportPromptController.Prams.cs, DeepLinkHandleImplementation.cs, RestrictedActionsAPIImplementation.cs, MVCManagerMenusAccessFacade.cs, IMVCManager.cs, EventStartedNotification.cs.

STEP 2 — Root-cause check: PASS ✅

Problem: clicking an "event starting" notification navigated the user to an attacker-chosen destination with no consent prompt. The link is authored by whoever created the event through the open Events API.

Fix: replaces direct IRealmNavigator calls with the existing consent-prompt controllers (ChangeRealmPromptController / TeleportPromptController), making approval the precondition for navigation — the same arrangement scene changeRealm(), deep links, and chat world links already use. Additionally hardens all parsing to be total (no exceptions can escape the multicast delegate dispatch). This fixes the root cause, not a symptom.

STEP 3 — Design & integration: PASS ✅

NotificationHandler is not a new class — it already existed and managed the EVENTS_STARTED notification click subscription. The diff changes its internals only: from direct navigation via IRealmNavigator to consent-prompted navigation via IMVCManager.ShowAsync. No new lifecycle, no new long-lived unit.

Owner search: NotificationHandler is created and disposed by CommunitiesPlugin (the lifecycle owner). Subscription in constructor, disposal in CommunitiesPlugin.Dispose(). No parallel mechanism introduced.

Pattern consistency verified: The usage of ChangeRealmPromptController.IssueCommand and TeleportPromptController.IssueCommand matches the existing patterns in DeepLinkHandleImplementation.cs (line 127–129), RestrictedActionsAPIImplementation.cs (lines 280, 286), and MVCManagerMenusAccessFacade.cs (lines 104, 107). The deep link handler uses the identical signature: new ChangeRealmPromptController.Params(string.Empty, realm, position).

Teardown trace:

  • eventStartsCts: created in field initializer → SafeRestart() on each click → SafeCancelAndDispose() in Dispose(). ✅
  • SubscribeToNotificationTypeClick(EVENTS_STARTED, EventStartSoonClicked) in constructor → no matching UnsubscribeFromNotificationTypeClick in Dispose(). See P2 note below. (Pre-existing — identical to old code.)

STEP 4 — Member audit: N/A

No new public members added. All new methods (TryParseDestination, TryParseParcel, ConfirmDestinationAsync) are private / private static.

STEP 5 — Line-level findings

[P2] Missing unsubscribe in Dispose() (pre-existing)

The constructor subscribes to NotificationsBusController (line 32) but Dispose() (line 35–36) only cancels/disposes the CTS — it never calls UnsubscribeFromNotificationTypeClick. After disposal, if the singleton bus fires a click event, EventStartSoonClicked will execute against disposed state. SafeRestart() catches ObjectDisposedException and creates a new CTS (which then leaks). This is pre-existing behavior (the old code had the same gap), and the practical impact is negligible since CommunitiesPlugin.Dispose() and singleton teardown happen together at shutdown. Non-blocking, but worth closing in a follow-up:

public void Dispose()
{
    NotificationsBusController.Instance.UnsubscribeFromNotificationTypeClick(
        NotificationType.EVENTS_STARTED, EventStartSoonClicked);
    eventStartsCts.SafeCancelAndDispose();
}

No other line-level issues found. All parsing uses Try-pattern methods, async handling follows CLAUDE.md §9 (ct.IsCancellationRequested before work, catch (OperationCanceledException) + catch (Exception)ReportHub.LogException), and ReportHub is used for logging.

STEP 6 — Complexity: COMPLEX

Changes dependency injection (IRealmNavigator → IMVCManager), introduces new async flow (UniTaskVoid), and modifies a security-sensitive notification click handler.

STEP 7 — QA: YES

Affects user-facing runtime navigation behavior when clicking event notifications.

STEP 8 — Non-blocking warnings: None

Main scene not modified.

Security review

  • Consent mechanism: Complete. Every navigation path goes through mvcManager.ShowAsync() — no silent fallback. ✅
  • Input validation: Uri.TryCreate (absolute URI only), int.TryParse with InvariantCulture, IsEns() regex (^[a-zA-Z0-9.]+\.eth$) after ToLowerInvariant(). Thorough. ✅
  • ENS regex: Character set (alphanumeric + dot) is restrictive enough — prevents URLs, hosts, aliases, and parameter pollution (commas from ParseQueryString duplicate-key joining are not in the charset). ✅
  • Exception totality: TryParseDestination is fully total. EventStartedNotificationMetadata is a struct (value type, can't be null), and Link being null is handled by Uri.TryCreate(null, ...) → false. ✅
  • Async exception handling: ConfirmDestinationAsync catches OperationCanceledException and logs general exceptions via ReportHub.LogException. Follows CLAUDE.md §9. ✅
  • SSRF / redirect: Not applicable — realm/position values are passed to UI prompt controllers, not used for HTTP requests. ✅
  • No secrets, no sensitive data exposure.

Test coverage

20 EditMode tests covering:

  • Consent prompts for world, parcel, and world-only destinations
  • Case normalization of realm names
  • Prompt-pending and prompt-declined scenarios (consent property verification)
  • 12 crafted-link rejection cases (null, empty, malformed URI, relative URI, invalid position formats, non-ENS realm, URL realm, alias realm, host realm, no destination)
  • Multicast delegate survival (later subscribers still fire after a crafted link)
  • Foreign payload type guards

Coverage is thorough and directly validates the security properties.


REVIEW_RESULT: PASS ✅
COMPLEXITY: COMPLEX
COMPLEXITY_REASON: Modifies security-sensitive notification handler, changes DI from IRealmNavigator to IMVCManager, introduces new async UniTaskVoid consent flow
QA_REQUIRED: YES


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

this.decentralandUrlsSource = decentralandUrlsSource;
this.mvcManager = mvcManager;

NotificationsBusController.Instance.SubscribeToNotificationTypeClick(NotificationType.EVENTS_STARTED, EventStartSoonClicked);

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 — pre-existing] This subscription has no matching UnsubscribeFromNotificationTypeClick in Dispose(). After disposal, if the singleton bus fires a click, EventStartSoonClicked runs against disposed state — SafeRestart() silently creates a new CTS that then leaks. Practically harmless since handler and bus are torn down together at shutdown, but worth closing for correctness in a follow-up:

Suggested change
NotificationsBusController.Instance.SubscribeToNotificationTypeClick(NotificationType.EVENTS_STARTED, EventStartSoonClicked);
NotificationsBusController.Instance.SubscribeToNotificationTypeClick(NotificationType.EVENTS_STARTED, EventStartSoonClicked);

(The fix belongs in Dispose() — add NotificationsBusController.Instance.UnsubscribeFromNotificationTypeClick(NotificationType.EVENTS_STARTED, EventStartSoonClicked); before SafeCancelAndDispose(). Can't target that line with a suggestion since it's outside the diff.)

@github-actions

github-actions Bot commented Aug 4, 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 4, 2026

Copy link
Copy Markdown
Contributor

badge

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

Warnings/errors in files changed by this PR (10)
Assets/DCL/PluginSystem/Global/CommunitiesPlugin.cs:215  CSharpWarnings::CS8618  Non-nullable property 'CommunityCardPrefab' is uninitialized. Consider adding the 'required' modifier or declaring the property as nullable.
Assets/DCL/PluginSystem/Global/CommunitiesPlugin.cs:218  CSharpWarnings::CS8618  Non-nullable property 'CommunityCreationEditionPrefab' is uninitialized. Consider adding the 'required' modifier or declaring the property as nullable.
Assets/DCL/PluginSystem/Global/CommunitiesPlugin.cs:215  InconsistentNaming  Name 'CommunityCardPrefab' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'communityCardPrefab'.
Assets/DCL/PluginSystem/Global/CommunitiesPlugin.cs:218  InconsistentNaming  Name 'CommunityCreationEditionPrefab' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'communityCreationEditionPrefab'.
Assets/DCL/PluginSystem/Global/CommunitiesPlugin.cs:53  InconsistentNaming  Name 'placesAPIService' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'placesApiService'.
Assets/DCL/PluginSystem/Global/CommunitiesPlugin.cs:85  InconsistentNaming  Name 'placesAPIService' does not match rule 'parameters_should_be_camel_case'. Suggested name is 'placesApiService'.
Assets/DCL/PluginSystem/Global/CommunitiesPlugin.cs:71  NotAccessedField.Local  Field 'socialServiceEventBus' is assigned but its value is never used
Assets/DCL/PluginSystem/Global/CommunitiesPlugin.cs:27  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/PluginSystem/Global/CommunitiesPlugin.cs:154  UnusedVariable  Local variable 'communityCardView' is never used
Assets/DCL/PluginSystem/Global/CommunitiesPlugin.cs:188  UnusedVariable  Local variable 'communityCreationEditionView' is never used

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

badge

All Unity tests passed ✅

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

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

🚦 CI Status

Build

Windows and Mac build successful in Unity Cloud! You can find a link to the downloadable artifact below.

Name Link
Commit 6172a26
Logs https://github.com/decentraland/unity-explorer/actions/runs/32029553576
Download Windows https://github.com/decentraland/unity-explorer/suites/86829979070/artifacts/9289449218
Download Windows S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/fix/sec-076-notification-realm-consent/pr-25197-6172a26/Decentraland_windows64.zip
Download Mac https://github.com/decentraland/unity-explorer/suites/86829979070/artifacts/9289482681
Download Mac S3 https://explorer-artifacts.decentraland.org/@dcl/unity-explorer/branch/fix/sec-076-notification-realm-consent/pr-25197-6172a26/Decentraland_macos.zip
Built on 2026-08-17T13:01:35Z

Lint

Warnings not reduced: 13156 => 13165 — remove at least 10 warnings to merge.

Warnings/errors in files changed by this PR (10)
Assets/DCL/PluginSystem/Global/CommunitiesPlugin.cs:215  CSharpWarnings::CS8618  Non-nullable property 'CommunityCardPrefab' is uninitialized. Consider adding the 'required' modifier or declaring the property as nullable.
Assets/DCL/PluginSystem/Global/CommunitiesPlugin.cs:218  CSharpWarnings::CS8618  Non-nullable property 'CommunityCreationEditionPrefab' is uninitialized. Consider adding the 'required' modifier or declaring the property as nullable.
Assets/DCL/PluginSystem/Global/CommunitiesPlugin.cs:215  InconsistentNaming  Name 'CommunityCardPrefab' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'communityCardPrefab'.
Assets/DCL/PluginSystem/Global/CommunitiesPlugin.cs:218  InconsistentNaming  Name 'CommunityCreationEditionPrefab' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'communityCreationEditionPrefab'.
Assets/DCL/PluginSystem/Global/CommunitiesPlugin.cs:53  InconsistentNaming  Name 'placesAPIService' does not match rule 'non_public_members_should_be_camel_case'. Suggested name is 'placesApiService'.
Assets/DCL/PluginSystem/Global/CommunitiesPlugin.cs:85  InconsistentNaming  Name 'placesAPIService' does not match rule 'parameters_should_be_camel_case'. Suggested name is 'placesApiService'.
Assets/DCL/PluginSystem/Global/CommunitiesPlugin.cs:71  NotAccessedField.Local  Field 'socialServiceEventBus' is assigned but its value is never used
Assets/DCL/PluginSystem/Global/CommunitiesPlugin.cs:27  RedundantUsingDirective  Using directive is not required by the code and can be safely removed
Assets/DCL/PluginSystem/Global/CommunitiesPlugin.cs:154  UnusedVariable  Local variable 'communityCardView' is never used
Assets/DCL/PluginSystem/Global/CommunitiesPlugin.cs:188  UnusedVariable  Local variable 'communityCreationEditionView' is never used

Tests

⚠️ EditMode produced no results — the run likely crashed or timed out before finishing. Check the Unity Test / Test (editmode) job.

TESTS SUITE Result Passed Failed Skipped
EditMode ⚠️ No results
PlayMode ✅ Passed 236 0 37

@decentraland-bot

Copy link
Copy Markdown
Contributor

PR #9590, run #32032889896

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) 2335 (×3)
CPU average 33.2 ms (33.2–34.6) 38.3 ms (37.1–38.5) 5.2 ms 🔴 16% slower
CPU 1% worst 34.3 ms (33.5–184.5) 332.2 ms (320.8–376.7) 297.9 ms 🔴 869% slower
CPU 0.1% worst 41.4 ms (33.7–332.0) 351.7 ms (331.6–408.6) 310.2 ms 🔴 749% slower
GPU average 9.3 ms (9.2–9.4) 9.4 ms (9.2–9.5) 0.1 ms ⚪ within noise
GPU 1% worst 20.7 ms (19.8–26.9) 35.8 ms (34.2–37.0) 15.2 ms 🔴 73% slower
GPU 0.1% worst 36.3 ms (31.6–37.7) 43.1 ms (39.9–48.3) 6.7 ms ⚪ within noise
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) 3922 (×3)
CPU average 20.5 ms (20.3–21.6) 22.7 ms (22.7–23.1) 2.2 ms 🔴 11% slower
CPU 1% worst 34.7 ms (33.9–34.7) 231.5 ms (204.8–231.9) 196.9 ms 🔴 568% slower
CPU 0.1% worst 34.9 ms (34.9–35.3) 243.1 ms (241.3–244.9) 208.2 ms 🔴 596% slower
GPU average 1.0 ms (0.1–1.6) 2.8 ms (2.1–5.1) 1.8 ms ⚪ within noise
GPU 1% worst 34.2 ms (7.7–34.8) 36.6 ms (36.0–36.7) 2.4 ms ⚪ within noise
GPU 0.1% worst 35.9 ms (35.1–37.2) 37.7 ms (37.7–38.7) 1.8 ms ⚪ within noise
Exceptions per run 0 0 0 ⚪ none new

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.

2 participants