Make Virtualize component CSP-compliant#66680
Conversation
There was a problem hiding this comment.
Pull request overview
Makes the Virtualize component compatible with strict Content Security Policy (style-src 'self') by moving its dynamically-generated spacer/placeholder style strings off the inline style attribute and onto a new data-blazor-spacer-style attribute, with a JS MutationObserver mirroring the values to the element via CSSOM (element.style.cssText = ...).
Changes:
- C# render output now emits
data-blazor-spacer-styleinstead ofstyleon both spacers and the default placeholder. - New per-instance
MutationObserverinVirtualize.tswatches the spacer parent subtree, applies styles via CSSOM, and is disconnected on dispose. - New CSP sample page/route plus a middleware that sets
style-src 'self'for/virtualize-csp, and two new tests asserting the data attributes are present and inlinestyleis absent.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Components/Web/src/Virtualization/Virtualize.cs | Replaces style with data-blazor-spacer-style on spacers and default placeholder. |
| src/Components/Web.JS/src/Virtualize.ts | Adds applySpacerStylesFromAttributes, initial sweep, MutationObserver, disconnect on dispose; removes outdated comment. |
| src/Components/Web/test/Virtualization/VirtualizeTest.cs | Adds two tests verifying spacers use the data attribute and no inline style. |
| src/Components/Samples/BlazorUnitedApp/Program.cs | Adds middleware setting strict CSP on /virtualize-csp. |
| src/Components/Samples/BlazorUnitedApp/Pages/VirtualizeCsp.razor(.css) | New sample page exercising Virtualize under strict CSP. |
Comments suppressed due to low confidence (1)
src/Components/Web.JS/src/Virtualize.ts:232
- The removed comment ("C# style updates overwrite the entire style attribute. Re-apply what we need.") is still accurate in spirit: the JS
applySpacerStylesFromAttributesnow callsel.style.cssText = stylewhenever the data attribute changes, which still wipes thedisplay: table-rowandoverflow-anchor: noneset elsewhere — that is exactly why this re-application block still has to exist. Removing the comment makes the purpose of this block non-obvious to future readers. Please keep an updated comment explaining that the MutationObserver'scssTextwrite erases these properties so they must be reapplied.
function refreshObservedElements(): void {
if (isTable) {
spacerBefore.style.display = 'table-row';
spacerAfter.style.display = 'table-row';
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (3)
src/Components/Web.JS/src/Virtualize.ts:111
MutationObserveris only attached tospacerBeforeandspacerAfter, so updates todata-blazor-styleon placeholder elements (rendered byDefaultPlaceholderat Virtualize.cs:700) will not trigger any CSSOM application. Placeholder styles only get applied via the loop inrefreshObservedElements(line 230). If a placeholder appears in the DOM in a render whererefreshObservedElementsis not called afterward (e.g., the very first render before the JS-interop hand-off completes, or any code path that adds new placeholder nodes without triggeringrefreshObservers), those placeholders will render with zero height. Consider applying CSSOM styles to newly inserted placeholders proactively (e.g., observing the parent for childList mutations, or invokingapplyStyleViaCssomon placeholder elements found in initialinit()).
const mutationObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
const el = mutation.target as HTMLElement;
applyStyleViaCssom(el, el.getAttribute('data-blazor-style') || '');
}
});
mutationObserver.observe(spacerBefore, { attributes: true, attributeFilter: ['data-blazor-style'] });
mutationObserver.observe(spacerAfter, { attributes: true, attributeFilter: ['data-blazor-style'] });
src/Components/Web.JS/src/Virtualize.ts:227
- Now that the C# side no longer emits a
styleattribute (so Blazor's renderer will not overwrite the inlinestyleset via CSSOM), the deleted comment was the entire reason these re-application lines existed. The remainingdisplay = 'table-row'andoverflowAnchor = 'none'reassignments inrefreshObservedElementsare no longer required — the values set once ininit()will persist across re-renders since the renderer won't touch them. Consider removing this defensive code or adding a new comment explaining why it is still needed; as written it now looks like leftover dead code.
function refreshObservedElements(): void {
if (isTable) {
spacerBefore.style.display = 'table-row';
spacerAfter.style.display = 'table-row';
}
if (useNativeAnchoring) {
spacerBefore.style.overflowAnchor = 'none';
spacerAfter.style.overflowAnchor = 'none';
}
src/Components/Web/test/Virtualization/VirtualizeTest.cs:1008
- This test relies on the order of entries in
referenceFramesto map index 0 → spacerBefore and index 1 → spacerAfter, and on the absolute count being exactly 2. If any other element in Virtualize's render tree (or inVirtualizeTestHostcomponent) ever gains adata-blazor-styleattribute — e.g., the default placeholders, which now also emitdata-blazor-style(Virtualize.cs:700) — these assertions will fail or assert against the wrong frame. The current test happens to pass only becauseItemsis used (so the placeholder render path isn't exercised). Consider asserting against attribute-name + a value-shape check rather than relying on index/count, or filtering frames by their owning element to make the test more robust to render-tree changes.
var dataStyleAttributes = referenceFrames
.Where(f => f.FrameType == RenderTreeFrameType.Attribute
&& f.AttributeName == "data-blazor-style")
.ToList();
// Both spacerBefore and spacerAfter should have the data attribute
Assert.Equal(2, dataStyleAttributes.Count);
// spacerBefore style: _itemsBefore is 0 initially, so height = 0px
var beforeStyle = (string)dataStyleAttributes[0].AttributeValue;
Assert.Contains("height: 0px", beforeStyle);
Assert.Contains("flex-shrink: 0", beforeStyle);
// spacerAfter style: should contain a height value
var afterStyle = (string)dataStyleAttributes[1].AttributeValue;
Assert.Contains("height:", afterStyle);
Assert.Contains("flex-shrink: 0", afterStyle);
Per @javiercn's review feedback, replace direct CSS property emission in data-blazor-style with --blazor-virtualize-* custom property assignments, consumed by a constructable stylesheet installed once via JS. spacerAfter now always emits --blazor-virtualize-transform (either 'none' or 'translateY(...)') so that a previously applied translateY cannot leak into a later render where _unusedItemCapacity transitions back to 0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ot-interfere-with-strict-csp # Conflicts: # src/Components/test/testassets/BasicTestApp/Index.razor
Addresses review feedback asking for an actual CSP-enforced test. Adds a ?strict-style-csp query parameter to ServerStartup which emits 'Content-Security-Policy: style-src ''self''' so that any inline style or <style> block (including the legacy inline-style code path) would trigger a console violation. VirtualizationCsp.razor now uses CSS classes from style.css instead of inline style attributes so the host page itself doesn't violate the policy, leaving only Virtualize's own styling under test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… `CSSStyleSheet`, replace it by per-item style update. Move constant style `flex-shrink: 0` to JS.
|
Server is serializing the communication: the first message contains the attribute name with integer value, the later updates contain only the ref index of that attribute name and the new integer value. Analysis of edge cases:
cc @pavelsavara |
|
@javiercn's feedback: avoid observing the subtree. Both spacers and placeholders have the inline styles problem. Initially we used
Choosing 1) for simplicity, the perf difference is negligible. |
Problem
The
<Virtualize>component renders dynamic inlinestyleattributes on its spacer and placeholder elements (e.g.,style="height: 478896px; flex-shrink: 0;"). These are blocked by Content Security Policy whenstyle-src 'self'is set, breaking virtualization entirely for applications with strict CSP policies.The component must use dynamic style values because spacer heights are calculated at runtime based on scroll position, item count, and average item size - they change on every scroll interaction.
Solution
Render style values in a
data-blazor-styleattribute instead ofstyle, and use aMutationObserverin JS to read it and apply each declaration via CSSOM (element.style.setProperty(name, value)), which is not governed by CSP.Fixes #66840.
Alternatives Considered
1.
'unsafe-inline'in CSPAllows all inline styles globally. Defeats the purpose of CSP - customers use strict CSP precisely to prevent style injection attacks (e.g., CSS-based data exfiltration via
background: url()). Not the best idea.2.
'unsafe-hashes'withsha256-...hashesPer CSP3 spec §unsafe-hashes, hashes can match
styleattributes when'unsafe-hashes'is present. However, Virtualize produces infinite unique values (height: 478896.3px,height: 12049.7px, etc.) — it's impossible to pre-compute hashes for dynamic float-valued styles. Browser support is also incomplete (caniuse: Chrome 69+, Firefox 130+, Safari ❌ as of v18).3. Nonces (
'nonce-<base64>')Nonces only apply to
<style>blocks and<link>elements, not tostyleattributes on elements. Per CSP3 spec §style-src: "A nonce [...] does not match a style attribute on an element." Not applicable.4. CSS
attr()function with a data attributeRender as
data-height="478896px"and use CSSattr(data-height type(<length>))to read it. However,attr()with type coercion is only supported in Chrome 133+ (Chrome Status), not in Firefox or Safari. Not viable for production use.5. CSS Custom Properties via
styleattributeSetting
style="--spacer-h: 478896px"and usingheight: var(--spacer-h)in CSS still requires an inlinestyleattribute - blocked by the same CSP rule. No benefit.6. Customized Built-in Elements (
is="")Create a
<div is="blazor-spacer">with a custom element that reads attributes and applies styles internally. Safari refuses to implement customized built-ins (WebKit position) and they're explicitly excluded from the HTML spec for Safari. Not viable cross-browser.7. JS Interop call from C# to set styles
Call
JSRuntime.InvokeAsyncfrom C# after rendering to push style values to JS. Creates an async timing gap - between render and the JS call completing, spacers have zero height, causing IntersectionObserver to fire with incorrect geometry. Would require complex synchronization and defeats Blazor's declarative rendering model.8. Data attribute + MutationObserver (chosen)
Render values in
data-blazor-style(a semicolon-separated list of--blazor-virtualize-*custom property assignments), observe withMutationObserver({ attributes: true, attributeFilter: ['data-blazor-style'] }), and apply each declaration viaelement.style.setProperty(name, value)- required for setting CSS custom properties. A small static stylesheet (installed once viadocument.adoptedStyleSheets) maps those custom properties ontoheight,flex-shrink, andtransform.Why CSSOM is CSP-compliant: Per CSP3 spec §style-src, the
style-srcdirective governsstyleHTML attributes,<style>elements, and stylesheet fetches. Direct CSSOM manipulation (element.style.cssText,element.style.height) is explicitly not restricted by CSP - it's a programmatic API, not content parsing.