Skip to content

Commit ede0ab9

Browse files
ilonatommyCopilot
andauthored
Make Virtualize component CSP-compliant (#66680)
* MutationObserver fix. * Apply placeholders style in the same atomic task as spacers styles. * Use CSS custom properties + static stylesheet for Virtualize styles * Add strict-style-csp E2E test for Virtualize * Limit observed nodes to spacers only. * Using custom properties is not justified. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 423e407 commit ede0ab9

10 files changed

Lines changed: 359 additions & 39 deletions

File tree

src/Components/Web.JS/src/Virtualize.ts

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,46 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
7272
spacerAfter.style.display = 'table-row';
7373
}
7474

75+
// Applies one-time base style (flex-shrink) on first sight of an element.
76+
const baseStylesAppliedProp = Symbol();
77+
function ensureBaseStyles(el: HTMLElement): void {
78+
if ((el as any)[baseStylesAppliedProp]) {
79+
return;
80+
}
81+
(el as any)[baseStylesAppliedProp] = true;
82+
el.style.flexShrink = '0';
83+
}
84+
85+
const layoutAttrs = [
86+
['data-blazor-virtualize-reserved-height', 'height', (n: number) => `${n}px`],
87+
['data-blazor-virtualize-loop-breaker-transform', 'transform', (n: number) => `translateY(${n}px)`],
88+
] as const;
89+
const layoutAttrNames = layoutAttrs.map(([a]) => a);
90+
function applyLayoutAttrs(el: HTMLElement): void {
91+
ensureBaseStyles(el);
92+
for (const [attr, styleProp, format] of layoutAttrs) {
93+
const raw = el.getAttribute(attr);
94+
const n = raw ? Number(raw) : NaN;
95+
if (Number.isFinite(n)) {
96+
el.style.setProperty(styleProp, format(n));
97+
} else {
98+
el.style.removeProperty(styleProp);
99+
}
100+
}
101+
}
102+
103+
// Apply layout attributes before the MutationObserver starts catching changes.
104+
function applyLayoutAttrsBetweenSpacers(): void {
105+
for (let el: Element | null = spacerBefore;
106+
el && el !== spacerAfter.nextElementSibling;
107+
el = el.nextElementSibling) {
108+
if (layoutAttrNames.some(a => el!.hasAttribute(a))) {
109+
applyLayoutAttrs(el as HTMLElement);
110+
}
111+
}
112+
}
113+
applyLayoutAttrsBetweenSpacers();
114+
75115
if (useNativeAnchoring) {
76116
// Prevent spacers from being used as scroll anchors — only rendered items should anchor.
77117
spacerBefore.style.overflowAnchor = 'none';
@@ -81,6 +121,22 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
81121
scrollElement.style.overflowAnchor = 'none';
82122
}
83123

124+
// Observe only the two spacers we already hold references to. Placeholders are siblings between them,
125+
// so on each spacer mutation we walk the sibling chain to reapply styles.
126+
const mutationObserver = new MutationObserver(applyLayoutAttrsBetweenSpacers);
127+
128+
function flushPendingStyleMutations(): void {
129+
if (mutationObserver.takeRecords().length > 0) {
130+
applyLayoutAttrsBetweenSpacers();
131+
}
132+
}
133+
const spacerObserverOptions: MutationObserverInit = {
134+
attributes: true,
135+
attributeFilter: layoutAttrNames,
136+
};
137+
mutationObserver.observe(spacerBefore, spacerObserverOptions);
138+
mutationObserver.observe(spacerAfter, spacerObserverOptions);
139+
84140
const intersectionObserver = new IntersectionObserver(intersectionCallback, {
85141
root: scrollContainer,
86142
rootMargin: `${rootMargin}px`,
@@ -196,17 +252,6 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
196252
resizeObserver.observe(spacerAfter);
197253

198254
function refreshObservedElements(): void {
199-
// C# style updates overwrite the entire style attribute. Re-apply what we need.
200-
if (isTable) {
201-
spacerBefore.style.display = 'table-row';
202-
spacerAfter.style.display = 'table-row';
203-
}
204-
205-
if (useNativeAnchoring) {
206-
spacerBefore.style.overflowAnchor = 'none';
207-
spacerAfter.style.overflowAnchor = 'none';
208-
}
209-
210255
// Ensure spacers are always observed (idempotent).
211256
resizeObserver.observe(spacerBefore);
212257
resizeObserver.observe(spacerAfter);
@@ -293,6 +338,9 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
293338
// Corrects scrollTop after a render that shifted content, using the snapshot
294339
// saved by updateAnchorSnapshot() during the previous render cycle.
295340
function restoreAnchorForShift(): void {
341+
// Apply styles before we read layout
342+
flushPendingStyleMutations();
343+
296344
const snapshot = observersByDotNetObjectId[id].anchorSnapshot;
297345
if (!snapshot) {
298346
return;
@@ -504,6 +552,7 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
504552
beginProgrammaticScroll: beginProgrammaticScrollSuppression,
505553
anchorSnapshot: null as { anchorItemIndex: number; anchorOffset: number; scrollTop: number } | null,
506554
onDispose: () => {
555+
mutationObserver.disconnect();
507556
stopConvergenceObserving();
508557
anchoredItems.clear();
509558
resizeObserver.disconnect();

src/Components/Web/src/Virtualization/Virtualize.cs

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -520,7 +520,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
520520
}
521521

522522
builder.OpenElement(0, SpacerElement);
523-
builder.AddAttribute(1, "style", GetSpacerStyle(_itemsBefore));
523+
builder.AddAttribute(1, "data-blazor-virtualize-reserved-height", GetSpacerHeightPx(_itemsBefore));
524524
builder.AddAttribute(2, "aria-hidden", "true");
525525
builder.AddElementReferenceCapture(3, elementReference => _spacerBefore = elementReference);
526526
builder.CloseElement();
@@ -589,22 +589,18 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
589589

590590
builder.OpenElement(7, SpacerElement);
591591
builder.AddAttribute(8, "aria-hidden", "true");
592-
builder.AddAttribute(9, "style", GetSpacerStyle(itemsAfter, _unusedItemCapacity));
593-
builder.AddElementReferenceCapture(10, elementReference => _spacerAfter = elementReference);
592+
builder.AddAttribute(9, "data-blazor-virtualize-reserved-height", GetSpacerHeightPx(itemsAfter));
593+
if (_unusedItemCapacity != 0)
594+
{
595+
builder.AddAttribute(10, "data-blazor-virtualize-loop-breaker-transform", GetSpacerHeightPx(_unusedItemCapacity));
596+
}
597+
builder.AddElementReferenceCapture(11, elementReference => _spacerAfter = elementReference);
594598

595599
builder.CloseElement();
596600
}
597601

598-
private string GetSpacerStyle(int itemsInSpacer, int numItemsGapAbove)
599-
{
600-
var avgHeight = GetItemHeight();
601-
return numItemsGapAbove == 0
602-
? GetSpacerStyle(itemsInSpacer)
603-
: $"height: {(itemsInSpacer * avgHeight).ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0; transform: translateY({(numItemsGapAbove * avgHeight).ToString(CultureInfo.InvariantCulture)}px);";
604-
}
605-
606-
private string GetSpacerStyle(int itemsInSpacer)
607-
=> $"height: {(itemsInSpacer * GetItemHeight()).ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0;";
602+
private string GetSpacerHeightPx(int itemCount)
603+
=> (itemCount * GetItemHeight()).ToString(CultureInfo.InvariantCulture);
608604

609605
private float GetItemHeight()
610606
=> _measuredItemCount > 0 ? _totalMeasuredHeight / _measuredItemCount : _itemSize;
@@ -938,7 +934,7 @@ private ValueTask<ItemsProviderResult<TItem>> DefaultItemsProvider(ItemsProvider
938934
private RenderFragment DefaultPlaceholder(PlaceholderContext context) => (builder) =>
939935
{
940936
builder.OpenElement(0, "div");
941-
builder.AddAttribute(1, "style", $"height: {_itemSize.ToString(CultureInfo.InvariantCulture)}px; flex-shrink: 0;");
937+
builder.AddAttribute(1, "data-blazor-virtualize-reserved-height", GetSpacerHeightPx(1));
942938
builder.CloseElement();
943939
};
944940

src/Components/Web/test/Virtualization/VirtualizeTest.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Globalization;
45
using Microsoft.AspNetCore.Components.Rendering;
56
using Microsoft.AspNetCore.Components.RenderTree;
67
using Microsoft.AspNetCore.Components.Test.Helpers;
@@ -1215,4 +1216,48 @@ public async Task InitialIndex_BeyondCountClampsToEnd()
12151216
// capacity = OverscanCount*2 + 1 = 31 with default OverscanCount=15, so _itemsBefore = max(0, 100 - 31) = 69.
12161217
Assert.Equal(69, renderedVirtualize._itemsBefore);
12171218
}
1219+
1220+
[Fact]
1221+
public async Task Virtualize_SpacersUseDataAttributeNotInlineStyle()
1222+
{
1223+
Virtualize<int> renderedVirtualize = null;
1224+
1225+
var rootComponent = new VirtualizeTestHostcomponent
1226+
{
1227+
InnerContent = BuildVirtualizeWithContent(50f, Enumerable.Range(1, 100).ToList(),
1228+
captureRenderedVirtualize: v => renderedVirtualize = v)
1229+
};
1230+
1231+
var serviceProvider = new ServiceCollection()
1232+
.AddTransient((sp) => Mock.Of<IJSRuntime>())
1233+
.BuildServiceProvider();
1234+
1235+
var testRenderer = new TestRenderer(serviceProvider);
1236+
var componentId = testRenderer.AssignRootComponentId(rootComponent);
1237+
1238+
await testRenderer.RenderRootComponentAsync(componentId);
1239+
Assert.NotNull(renderedVirtualize);
1240+
1241+
// Spacer elements use data-blazor-virtualize-* attributes instead of inline style.
1242+
// A MutationObserver on the JS side mirrors them to element styles via CSSOM.
1243+
var referenceFrames = testRenderer.Batches.SelectMany(b => b.ReferenceFrames).ToList();
1244+
1245+
var heightAttributes = referenceFrames
1246+
.Where(f => f.FrameType == RenderTreeFrameType.Attribute
1247+
&& f.AttributeName == "data-blazor-virtualize-reserved-height")
1248+
.ToList();
1249+
1250+
Assert.Equal(2, heightAttributes.Count);
1251+
Assert.Equal("0", (string)heightAttributes[0].AttributeValue);
1252+
Assert.True(double.TryParse((string)heightAttributes[1].AttributeValue, NumberStyles.Float, CultureInfo.InvariantCulture, out _));
1253+
1254+
var inlineStyleAttributes = referenceFrames
1255+
.Where(f => f.FrameType == RenderTreeFrameType.Attribute
1256+
&& f.AttributeName == "style")
1257+
.ToList();
1258+
1259+
// The only inline style is the test host's scroll container; Virtualize itself emits none.
1260+
var hostStyle = Assert.Single(inlineStyleAttributes);
1261+
Assert.Equal("overflow: auto; height: 800px;", (string)hostStyle.AttributeValue);
1262+
}
12181263
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using BasicTestApp;
5+
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
6+
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
7+
using Microsoft.AspNetCore.E2ETesting;
8+
using OpenQA.Selenium;
9+
using TestServer;
10+
using Xunit.Abstractions;
11+
12+
namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests;
13+
14+
public class VirtualizationCspTest : ServerTestBase<BasicTestAppServerSiteFixture<ServerStartup>>
15+
{
16+
public VirtualizationCspTest(
17+
BrowserFixture browserFixture,
18+
BasicTestAppServerSiteFixture<ServerStartup> serverFixture,
19+
ITestOutputHelper output)
20+
: base(browserFixture, serverFixture, output)
21+
{
22+
}
23+
24+
[Fact]
25+
public void Virtualize_WithItems_DoesNotViolate_StrictStyleCspPolicy()
26+
{
27+
// strict-style-csp causes ServerStartup to add `Content-Security-Policy: style-src 'self'`.
28+
Navigate($"{ServerPathBase}?strict-style-csp=true");
29+
Browser.MountTestComponent<VirtualizationCsp>();
30+
31+
var container = Browser.Exists(By.Id("csp-container"));
32+
Browser.True(() => container.FindElements(By.CssSelector(".csp-item")).Count > 0);
33+
34+
// Scroll to force spacer style updates which set CSS custom properties via CSSOM.
35+
var js = (IJavaScriptExecutor)Browser;
36+
js.ExecuteScript("document.getElementById('csp-container').scrollTop = 5000;");
37+
38+
// Allow the MutationObserver microtask + CSSOM updates to flush.
39+
Browser.True(() =>
40+
{
41+
var topSpacer = container.FindElements(By.CssSelector(":scope > div"))[0];
42+
var height = topSpacer.GetDomAttribute("data-blazor-virtualize-reserved-height");
43+
return height != null && height != "0";
44+
});
45+
46+
AssertNoStyleCspViolations();
47+
}
48+
49+
[Fact]
50+
public void Virtualize_WithItemsProvider_DoesNotViolate_StrictStyleCspPolicy()
51+
{
52+
// strict-style-csp causes ServerStartup to add `Content-Security-Policy: style-src 'self'`.
53+
Navigate($"{ServerPathBase}?strict-style-csp=true&mode=items-provider");
54+
Browser.MountTestComponent<VirtualizationCsp>();
55+
56+
var container = Browser.Exists(By.Id("csp-container-async"));
57+
58+
// Wait for either placeholders (during in-flight load) or resolved items.
59+
Browser.True(() => container.FindElements(
60+
By.CssSelector(".csp-placeholder, .csp-item-async")).Count > 0);
61+
62+
// Scroll to trigger new placeholder renders and spacer style updates.
63+
var js = (IJavaScriptExecutor)Browser;
64+
js.ExecuteScript("document.getElementById('csp-container-async').scrollTop = 5000;");
65+
66+
// Wait for resolved items after scroll so the placeholder path has executed.
67+
Browser.True(() => container.FindElements(By.CssSelector(".csp-item-async")).Count > 0);
68+
69+
AssertNoStyleCspViolations();
70+
}
71+
72+
[Theory]
73+
[InlineData("url(https://example.invalid/x.png)")] // non-numeric, CSS-like
74+
[InlineData("120px")] // a unit suffix is no longer accepted
75+
[InlineData("abc")] // non-numeric
76+
[InlineData("1e9999")] // overflows to Infinity (not finite)
77+
[InlineData("NaN")] // non-finite
78+
[InlineData("1 2")] // multi-token
79+
[InlineData("")] // empty
80+
public void Virtualize_RejectsUnexpectedLayoutAttributeValues(string invalidValue)
81+
{
82+
// Values that don't parse as a finite number must not propagate to CSS custom properties.
83+
Navigate($"{ServerPathBase}?strict-style-csp=true");
84+
Browser.MountTestComponent<VirtualizationCsp>();
85+
86+
var container = Browser.Exists(By.Id("csp-container"));
87+
Browser.True(() => container.FindElements(By.CssSelector(".csp-item")).Count > 0);
88+
89+
var js = (IJavaScriptExecutor)Browser;
90+
const string spacerSelector = "#csp-container > div:first-of-type";
91+
92+
js.ExecuteScript(
93+
"var el = document.querySelector(arguments[0]);" +
94+
"el.setAttribute('data-blazor-virtualize-reserved-height', arguments[1]);" +
95+
"el.setAttribute('data-blazor-virtualize-loop-breaker-transform', arguments[1]);",
96+
spacerSelector, invalidValue);
97+
98+
// Invalid values must result in the inline style being removed (empty).
99+
Browser.True(() =>
100+
{
101+
var heightStyle = (string)js.ExecuteScript(
102+
"return document.querySelector(arguments[0]).style.height;",
103+
spacerSelector);
104+
var transformStyle = (string)js.ExecuteScript(
105+
"return document.querySelector(arguments[0]).style.transform;",
106+
spacerSelector);
107+
return heightStyle == "" && transformStyle == "";
108+
});
109+
110+
AssertNoStyleCspViolations();
111+
}
112+
113+
[Fact]
114+
public void QuickGrid_WithVirtualize_DoesNotViolate_StrictStyleCspPolicy()
115+
{
116+
// QuickGrid uses Virtualize internally. Validates the integration is CSP-clean end-to-end.
117+
Navigate($"{ServerPathBase}?strict-style-csp=true");
118+
Browser.MountTestComponent<BasicTestApp.QuickGridTest.QuickGridCsp>();
119+
120+
var container = Browser.Exists(By.Id("csp-quickgrid"));
121+
122+
// Wait for QuickGrid to render at least one data row.
123+
Browser.True(() => container.FindElements(By.CssSelector("tbody tr")).Count > 0);
124+
125+
// Scroll to force spacer style updates which set CSS custom properties via CSSOM.
126+
var js = (IJavaScriptExecutor)Browser;
127+
js.ExecuteScript("document.getElementById('csp-quickgrid').scrollTop = 20000;");
128+
129+
// Wait for the top spacer's reserved-height attribute to grow past 0 after scrolling.
130+
Browser.True(() =>
131+
{
132+
var spacers = container.FindElements(By.CssSelector("[data-blazor-virtualize-reserved-height]"));
133+
return spacers.Count > 0
134+
&& spacers[0].GetDomAttribute("data-blazor-virtualize-reserved-height") != "0";
135+
});
136+
137+
AssertNoStyleCspViolations();
138+
}
139+
140+
private void AssertNoStyleCspViolations()
141+
{
142+
const string cspErrorMessage = "violates the following Content Security Policy directive: \"style-src";
143+
var logs = Browser.Manage().Logs.GetLog(LogType.Browser);
144+
var styleErrors = logs.Where(log => log.Message.Contains(cspErrorMessage)).ToList();
145+
Assert.Empty(styleErrors);
146+
}
147+
}

0 commit comments

Comments
 (0)