Skip to content

Commit 416da79

Browse files
SteveSandersonMSwtgodbe
authored andcommitted
Merged PR 33543: [8.0] Prevent delivery of events to disposed components
# Prevent delivery of events to disposed components Prevents delivery of events to disposed components. ## Description In some cases, it was possible to bypass validation rules on an EditForm by triggering a disposal and a form-submit synchronously together. Fixes MSRC 81646 ## Customer Impact In some cases, may allow bypass of server-side validation rules. ## Regression? - [x] Yes - [ ] No 5.0 ## Risk - [ ] High - [x] Medium - [ ] Low This change should not break any reasonable existing apps, since we don't consider it expected that events would ever be delivered to disposed components. It would never have been possible in WebAssembly - the possibility only existed in Server due to network latency, and even then only if the client is behaving very unusually. However it is theoretically possible that some app makes use of this bug intentionally, and if so they would encounter a change of behavior when upgrading to .NET 8 RC2 or GA. Normally we wouldn't consider a bugfix like this to be "medium" risk - I'm only selecting that level because of it being introduced so close to GA. Note that separate fixes are being delivered in patches for 6.0 and 7.0. ## Verification - [x] Manual (required) - [x] Automated ## Packaging changes reviewed? - [ ] Yes - [ ] No - [x] N/A
1 parent 909476d commit 416da79

File tree

6 files changed

+151
-31
lines changed

6 files changed

+151
-31
lines changed

src/Components/Components/src/RenderTree/RenderTreeDiffBuilder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -978,7 +978,7 @@ private static void InitializeNewAttributeFrame(ref DiffContext diffContext, ref
978978
newFrame.AttributeNameField.Length >= 3 &&
979979
newFrame.AttributeNameField.StartsWith("on", StringComparison.Ordinal))
980980
{
981-
diffContext.Renderer.AssignEventHandlerId(ref newFrame);
981+
diffContext.Renderer.AssignEventHandlerId(diffContext.ComponentId, ref newFrame);
982982
}
983983
}
984984

src/Components/Components/src/RenderTree/Renderer.Log.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,5 +63,16 @@ public static void HandlingEvent(ILogger logger, ulong eventHandlerId, EventArgs
6363
HandlingEvent(logger, eventHandlerId, eventArgs?.GetType().Name ?? "null");
6464
}
6565
}
66+
67+
[LoggerMessage(6, LogLevel.Debug, "Skipping attempt to raise event {EventId} of type '{EventType}' because the component ID {ComponentId} was already disposed", EventName = "SkippingEventOnDisposedComponent", SkipEnabledCheck = true)]
68+
public static partial void SkippingEventOnDisposedComponent(ILogger logger, int componentId, ulong eventId, string eventType);
69+
70+
public static void SkippingEventOnDisposedComponent(ILogger logger, int componentId, ulong eventHandlerId, EventArgs? eventArgs)
71+
{
72+
if (logger.IsEnabled(LogLevel.Debug)) // This is almost always false, so skip the evaluations
73+
{
74+
SkippingEventOnDisposedComponent(logger, componentId, eventHandlerId, eventArgs?.GetType().Name ?? "null");
75+
}
76+
}
6677
}
6778
}

src/Components/Components/src/RenderTree/Renderer.cs

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public abstract partial class Renderer : IDisposable, IAsyncDisposable
2828
private readonly Dictionary<int, ComponentState> _componentStateById = new Dictionary<int, ComponentState>();
2929
private readonly Dictionary<IComponent, ComponentState> _componentStateByComponent = new Dictionary<IComponent, ComponentState>();
3030
private readonly RenderBatchBuilder _batchBuilder = new RenderBatchBuilder();
31-
private readonly Dictionary<ulong, EventCallback> _eventBindings = new Dictionary<ulong, EventCallback>();
31+
private readonly Dictionary<ulong, (int RenderedByComponentId, EventCallback Callback)> _eventBindings = new();
3232
private readonly Dictionary<ulong, ulong> _eventHandlerIdReplacements = new Dictionary<ulong, ulong>();
3333
private readonly ILogger _logger;
3434
private readonly ComponentFactory _componentFactory;
@@ -416,7 +416,22 @@ public virtual Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo? fie
416416
_pendingTasks ??= new();
417417
}
418418

419-
var callback = GetRequiredEventCallback(eventHandlerId);
419+
var (renderedByComponentId, callback) = GetRequiredEventBindingEntry(eventHandlerId);
420+
421+
// If this event attribute was rendered by a component that's since been disposed, don't dispatch the event at all.
422+
// This can occur because event handler disposal is deferred, so event handler IDs can outlive their components.
423+
// The reason the following check is based on "which component rendered this frame" and not on "which component
424+
// receives the callback" (i.e., callback.Receiver) is that if parent A passes a RenderFragment with events to child B,
425+
// and then child B is disposed, we don't want to dispatch the events (because the developer considers them removed
426+
// from the UI) even though the receiver A is still alive.
427+
if (!_componentStateById.ContainsKey(renderedByComponentId))
428+
{
429+
// This is not an error since it can happen legitimately (in Blazor Server, the user might click a button at the same
430+
// moment that the component is disposed remotely, and then the click event will arrive after disposal).
431+
Log.SkippingEventOnDisposedComponent(_logger, renderedByComponentId, eventHandlerId, eventArgs);
432+
return Task.CompletedTask;
433+
}
434+
420435
Log.HandlingEvent(_logger, eventHandlerId, eventArgs);
421436

422437
// Try to match it up with a receiver so that, if the event handler later throws, we can route the error to the
@@ -480,7 +495,7 @@ public virtual Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo? fie
480495
/// <returns>The parameter type expected by the event handler. Normally this is a subclass of <see cref="EventArgs"/>.</returns>
481496
public Type GetEventArgsType(ulong eventHandlerId)
482497
{
483-
var methodInfo = GetRequiredEventCallback(eventHandlerId).Delegate?.Method;
498+
var methodInfo = GetRequiredEventBindingEntry(eventHandlerId).Callback.Delegate?.Method;
484499

485500
// The DispatchEventAsync code paths allow for the case where Delegate or its method
486501
// is null, and in this case the event receiver just receives null. This won't happen
@@ -581,7 +596,7 @@ protected virtual void AddPendingTask(ComponentState? componentState, Task task)
581596
_pendingTasks?.Add(task);
582597
}
583598

584-
internal void AssignEventHandlerId(ref RenderTreeFrame frame)
599+
internal void AssignEventHandlerId(int renderedByComponentId, ref RenderTreeFrame frame)
585600
{
586601
var id = ++_lastEventHandlerId;
587602

@@ -593,15 +608,15 @@ internal void AssignEventHandlerId(ref RenderTreeFrame frame)
593608
//
594609
// When that happens we intentionally box the EventCallback because we need to hold on to
595610
// the receiver.
596-
_eventBindings.Add(id, callback);
611+
_eventBindings.Add(id, (renderedByComponentId, callback));
597612
}
598613
else if (frame.AttributeValueField is MulticastDelegate @delegate)
599614
{
600615
// This is the common case for a delegate, where the receiver of the event
601616
// is the same as delegate.Target. In this case since the receiver is implicit we can
602617
// avoid boxing the EventCallback object and just re-hydrate it on the other side of the
603618
// render tree.
604-
_eventBindings.Add(id, new EventCallback(@delegate.Target as IHandleEvent, @delegate));
619+
_eventBindings.Add(id, (renderedByComponentId, new EventCallback(@delegate.Target as IHandleEvent, @delegate)));
605620
}
606621

607622
// NOTE: we do not to handle EventCallback<T> here. EventCallback<T> is only used when passing
@@ -645,14 +660,14 @@ internal void TrackReplacedEventHandlerId(ulong oldEventHandlerId, ulong newEven
645660
_eventHandlerIdReplacements.Add(oldEventHandlerId, newEventHandlerId);
646661
}
647662

648-
private EventCallback GetRequiredEventCallback(ulong eventHandlerId)
663+
private (int RenderedByComponentId, EventCallback Callback) GetRequiredEventBindingEntry(ulong eventHandlerId)
649664
{
650-
if (!_eventBindings.TryGetValue(eventHandlerId, out var callback))
665+
if (!_eventBindings.TryGetValue(eventHandlerId, out var entry))
651666
{
652667
throw new ArgumentException($"There is no event handler associated with this event. EventId: '{eventHandlerId}'.", nameof(eventHandlerId));
653668
}
654669

655-
return callback;
670+
return entry;
656671
}
657672

658673
private ulong FindLatestEventHandlerIdInChain(ulong eventHandlerId)

src/Components/Components/test/RendererTest.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2619,6 +2619,56 @@ public void RenderBatch_DoesNotDisposeComponentMultipleTimes()
26192619
Assert.True(component.Disposed);
26202620
}
26212621

2622+
[Fact]
2623+
public async Task DoesNotDispatchEventsAfterOwnerComponentIsDisposed()
2624+
{
2625+
// Arrange
2626+
var renderer = new TestRenderer();
2627+
var eventCount = 0;
2628+
Action<EventArgs> origEventHandler = args => { eventCount++; };
2629+
var component = new ConditionalParentComponent<EventComponent>
2630+
{
2631+
IncludeChild = true,
2632+
ChildParameters = new Dictionary<string, object>
2633+
{
2634+
{ nameof(EventComponent.OnTest), origEventHandler }
2635+
}
2636+
};
2637+
var rootComponentId = renderer.AssignRootComponentId(component);
2638+
component.TriggerRender();
2639+
var batch = renderer.Batches.Single();
2640+
var rootComponentDiff = batch.DiffsByComponentId[rootComponentId].Single();
2641+
var rootComponentFrame = batch.ReferenceFrames[0];
2642+
var childComponentFrame = rootComponentDiff.Edits
2643+
.Select(e => batch.ReferenceFrames[e.ReferenceFrameIndex])
2644+
.Where(f => f.FrameType == RenderTreeFrameType.Component)
2645+
.Single();
2646+
var childComponentId = childComponentFrame.ComponentId;
2647+
var childComponentDiff = batch.DiffsByComponentId[childComponentFrame.ComponentId].Single();
2648+
var eventHandlerId = batch.ReferenceFrames
2649+
.Skip(childComponentDiff.Edits[0].ReferenceFrameIndex) // Search from where the child component frames start
2650+
.Where(f => f.FrameType == RenderTreeFrameType.Attribute)
2651+
.Single(f => f.AttributeEventHandlerId != 0)
2652+
.AttributeEventHandlerId;
2653+
2654+
// Act/Assert 1: Event handler fires when we trigger it
2655+
Assert.Equal(0, eventCount);
2656+
var renderTask = renderer.DispatchEventAsync(eventHandlerId, args: null);
2657+
Assert.True(renderTask.IsCompletedSuccessfully);
2658+
Assert.Equal(1, eventCount);
2659+
await renderTask;
2660+
2661+
// Now remove the EventComponent, but without ever acknowledging the renderbatch, so the event handler doesn't get disposed
2662+
var disposalBatchAcknowledgementTcs = new TaskCompletionSource();
2663+
component.IncludeChild = false;
2664+
renderer.NextRenderResultTask = disposalBatchAcknowledgementTcs.Task;
2665+
component.TriggerRender();
2666+
2667+
// Act/Assert 2: Can no longer fire the original event. It's not an error but the delegate was not invoked.
2668+
await renderer.DispatchEventAsync(eventHandlerId, args: null);
2669+
Assert.Equal(1, eventCount);
2670+
}
2671+
26222672
[Fact]
26232673
public async Task DisposesEventHandlersWhenAttributeValueChanged()
26242674
{

src/Components/test/E2ETest/Tests/FormsTest.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -990,6 +990,39 @@ public void CanHaveModelLevelValidationErrors()
990990
Browser.Collection(logEntries, x => Assert.Equal("OnValidSubmit", x));
991991
}
992992

993+
[Fact]
994+
public async Task CannotSubmitEditFormSynchronouslyAfterItWasRemoved()
995+
{
996+
var appElement = MountSimpleValidationComponent();
997+
998+
var submitButtonFinder = By.CssSelector("button[type=submit]");
999+
Browser.Exists(submitButtonFinder);
1000+
1001+
// Remove the form then immediately also submit it, so the server receives both
1002+
// the 'remove' and 'submit' commands (in that order) before it updates the UI
1003+
appElement.FindElement(By.Id("remove-form")).Click();
1004+
1005+
try
1006+
{
1007+
appElement.FindElement(submitButtonFinder).Click();
1008+
}
1009+
catch (NoSuchElementException)
1010+
{
1011+
// This should happen on WebAssembly because the form will be removed synchronously
1012+
// That means the test has passed
1013+
return;
1014+
}
1015+
1016+
// Wait for the removal to complete, which is intentionally delayed to ensure
1017+
// this test can submit a second instruction before the first is processed.
1018+
Browser.DoesNotExist(submitButtonFinder);
1019+
1020+
// Verify that the form submit event was not processed, even if we wait a while
1021+
// to be really sure the second instruction was processed.
1022+
await Task.Delay(1000);
1023+
Browser.DoesNotExist(By.Id("last-callback"));
1024+
}
1025+
9931026
private Func<string[]> CreateValidationMessagesAccessor(IWebElement appElement, string messageSelector = ".validation-message")
9941027
{
9951028
return () => appElement.FindElements(By.CssSelector(messageSelector))
Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,42 @@
11
@using System.ComponentModel.DataAnnotations
22
@using Microsoft.AspNetCore.Components.Forms
33

4-
<EditForm Model="@this" OnValidSubmit="@HandleValidSubmit" OnInvalidSubmit="@HandleInvalidSubmit" autocomplete="off">
5-
<DataAnnotationsValidator />
6-
7-
<p class="user-name">
8-
User name: <input @bind="UserName" class="@context.FieldCssClass(() => UserName)" />
9-
</p>
10-
<p class="accepts-terms">
11-
Accept terms: <input type="checkbox" @bind="AcceptsTerms" class="@context.FieldCssClass(() => AcceptsTerms)" />
12-
</p>
13-
14-
<button type="submit">Submit</button>
15-
16-
@* Could use <ValidationSummary /> instead, but this shows it can be done manually *@
17-
<ul class="validation-errors">
18-
@foreach (var message in context.GetValidationMessages())
19-
{
20-
<li class="validation-message">@message</li>
21-
}
22-
</ul>
23-
24-
</EditForm>
4+
@if (!removeForm)
5+
{
6+
<EditForm Model="@this" OnValidSubmit="@HandleValidSubmit" OnInvalidSubmit="@HandleInvalidSubmit" autocomplete="off">
7+
<DataAnnotationsValidator />
8+
9+
<p class="user-name">
10+
User name: <input @bind="UserName" class="@context.FieldCssClass(() => UserName)" />
11+
</p>
12+
<p class="accepts-terms">
13+
Accept terms: <input type="checkbox" @bind="AcceptsTerms" class="@context.FieldCssClass(() => AcceptsTerms)" />
14+
</p>
15+
16+
<button type="submit">Submit</button>
17+
18+
@* Could use <ValidationSummary /> instead, but this shows it can be done manually *@
19+
<ul class="validation-errors">
20+
@foreach (var message in context.GetValidationMessages())
21+
{
22+
<li class="validation-message">@message</li>
23+
}
24+
</ul>
25+
</EditForm>
26+
}
2527

2628
@if (lastCallback != null)
2729
{
2830
<span id="last-callback">@lastCallback</span>
2931
}
3032

33+
<p><button id="remove-form" @onclick="RemoveForm">Remove form</button></p>
34+
3135
@code {
3236
protected virtual bool UseExperimentalValidator => false;
3337

3438
string lastCallback;
39+
bool removeForm;
3540

3641
[Required(ErrorMessage = "Please choose a username")]
3742
public string UserName { get; set; }
@@ -49,4 +54,10 @@
4954
{
5055
lastCallback = "OnInvalidSubmit";
5156
}
57+
58+
void RemoveForm()
59+
{
60+
removeForm = true;
61+
Thread.Sleep(1000); // To ensure we can dispatch another event before this completes
62+
}
5263
}

0 commit comments

Comments
 (0)