Skip to content

Make Virtualize component CSP-compliant#66680

Merged
ilonatommy merged 27 commits into
dotnet:mainfrom
ilonatommy:virtuazliation-does-not-interfere-with-strict-csp
Jun 16, 2026
Merged

Make Virtualize component CSP-compliant#66680
ilonatommy merged 27 commits into
dotnet:mainfrom
ilonatommy:virtuazliation-does-not-interfere-with-strict-csp

Conversation

@ilonatommy

@ilonatommy ilonatommy commented May 14, 2026

Copy link
Copy Markdown
Member

Problem

The <Virtualize> component renders dynamic inline style attributes on its spacer and placeholder elements (e.g., style="height: 478896px; flex-shrink: 0;"). These are blocked by Content Security Policy when style-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-style attribute instead of style, and use a MutationObserver in 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 CSP

Allows 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' with sha256-... hashes

Per CSP3 spec §unsafe-hashes, hashes can match style attributes 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 to style attributes 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 attribute

Render as data-height="478896px" and use CSS attr(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 style attribute

Setting style="--spacer-h: 478896px" and using height: var(--spacer-h) in CSS still requires an inline style attribute - 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.InvokeAsync from 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 with MutationObserver({ attributes: true, attributeFilter: ['data-blazor-style'] }), and apply each declaration via element.style.setProperty(name, value) - required for setting CSS custom properties. A small static stylesheet (installed once via document.adoptedStyleSheets) maps those custom properties onto height, flex-shrink, and transform.

Why CSSOM is CSP-compliant: Per CSP3 spec §style-src, the style-src directive governs style HTML 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.

@ilonatommy ilonatommy added this to the 11.0-preview5 milestone May 14, 2026
@ilonatommy ilonatommy self-assigned this May 14, 2026
@ilonatommy ilonatommy requested a review from a team as a code owner May 14, 2026 13:32
Copilot AI review requested due to automatic review settings May 14, 2026 13:32
@ilonatommy ilonatommy added the area-blazor Includes: Blazor, Razor Components label May 14, 2026

Copilot AI 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.

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-style instead of style on both spacers and the default placeholder.
  • New per-instance MutationObserver in Virtualize.ts watches 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 inline style is 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 applySpacerStylesFromAttributes now calls el.style.cssText = style whenever the data attribute changes, which still wipes the display: table-row and overflow-anchor: none set 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's cssText write erases these properties so they must be reapplied.
  function refreshObservedElements(): void {
    if (isTable) {
      spacerBefore.style.display = 'table-row';
      spacerAfter.style.display = 'table-row';
    }

Comment thread src/Components/Web.JS/src/Virtualize.ts Outdated
Comment thread src/Components/Web/src/Virtualization/Virtualize.cs Outdated
Comment thread src/Components/Web/src/Virtualization/Virtualize.cs Outdated
Comment thread src/Components/Web.JS/src/Virtualize.ts Outdated
Comment thread src/Components/Web.JS/src/Virtualize.ts Outdated
Comment thread src/Components/Web.JS/src/Virtualize.ts
Comment thread src/Components/Web/test/Virtualization/VirtualizeTest.cs Outdated
Comment thread src/Components/Samples/BlazorUnitedApp/Program.cs Outdated
Comment thread src/Components/Web.JS/src/Virtualize.ts Outdated

Copilot AI 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.

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

  • MutationObserver is only attached to spacerBefore and spacerAfter, so updates to data-blazor-style on placeholder elements (rendered by DefaultPlaceholder at Virtualize.cs:700) will not trigger any CSSOM application. Placeholder styles only get applied via the loop in refreshObservedElements (line 230). If a placeholder appears in the DOM in a render where refreshObservedElements is 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 triggering refreshObservers), 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 invoking applyStyleViaCssom on placeholder elements found in initial init()).
  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 style attribute (so Blazor's renderer will not overwrite the inline style set via CSSOM), the deleted comment was the entire reason these re-application lines existed. The remaining display = 'table-row' and overflowAnchor = 'none' reassignments in refreshObservedElements are no longer required — the values set once in init() 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 referenceFrames to 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 in VirtualizeTestHostcomponent) ever gains a data-blazor-style attribute — e.g., the default placeholders, which now also emit data-blazor-style (Virtualize.cs:700) — these assertions will fail or assert against the wrong frame. The current test happens to pass only because Items is 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);

Comment thread src/Components/Samples/BlazorUnitedApp/Pages/VirtualizeCsp.razor.css Outdated
Comment thread src/Components/Web/test/Virtualization/VirtualizeTest.cs Outdated
Comment thread src/Components/Web/src/Virtualization/Virtualize.cs Outdated
Comment thread src/Components/Web.JS/src/Virtualize.ts Outdated
Comment thread src/Components/Web/src/Virtualization/Virtualize.cs Outdated
Comment thread src/Components/Web/test/Virtualization/VirtualizeTest.cs
ilonatommy and others added 4 commits May 27, 2026 17:26
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>
@ilonatommy ilonatommy requested review from javiercn and oroztocil June 3, 2026 11:07
@ilonatommy ilonatommy modified the milestones: Backlog, 11.0-preview6 Jun 3, 2026
Comment thread src/Components/Web.JS/src/Virtualize.ts Outdated
Comment thread src/Components/Web.JS/src/Virtualize.ts Outdated
Comment thread src/Components/Web.JS/src/Virtualize.ts Outdated
… `CSSStyleSheet`, replace it by per-item style update.

Move constant style `flex-shrink: 0` to JS.
Comment thread src/Components/Web.JS/src/Virtualize.ts Outdated
@ilonatommy

Copy link
Copy Markdown
Member Author

Server is serializing the communication:

builder.AddAttribute(id, "data-blazor-virtualize-reserved-height", pxValue);

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:

  • App that binds its ItemSize value with user input can be broken by the user. ItemSize has direct impact on styles calculation. The damage is limited to that one client and the same problem existed before we moved applying styles to the client code.
  • Client gets incorrect data from different actor than the server. The damage is limited to that one client and the fact that it was possible implies that whole transport layer is broken.

cc @pavelsavara

@ilonatommy ilonatommy requested a review from javiercn June 3, 2026 16:14
Comment thread src/Components/Web.JS/src/Virtualize.ts
@ilonatommy

Copy link
Copy Markdown
Member Author

@javiercn's feedback: avoid observing the subtree.

Both spacers and placeholders have the inline styles problem. Initially we used MutationObserver on a subtree but there are 2 cheaper ideas how to do it.

  1. Using the fact that spacers and placeholders are siblings in DOM, we observe only spacers and loop-walk the siblings in between them, applying the styles to these that are placeholders. O(N), N - number of placeholders + 2 spacers + real items loaded in between them.
  2. Using the fact that custom property propagates to children, we can set it on the common immediate parent and observe it with childList: true). On each fire: (a) for each newly inserted child carrying our data attr, apply a one-time style setup (e.g. el.style.height = 'var(--bv-placeholder-height)'), (b) read the attributes that C# put on spacers and write them on parent (c# has no ownership of parent element, it cannot write there directly). Children's heights update automatically via inheritance. Multiple virtualize components on the page need ID of component to apply the property correctly. O(k), k = newly inserted styled children per fire, k <= N.

Choosing 1) for simplicity, the perf difference is negligible.

Comment thread src/Components/Web/test/Virtualization/VirtualizeTest.cs Outdated
@ilonatommy ilonatommy enabled auto-merge (squash) June 15, 2026 07:55
@ilonatommy ilonatommy merged commit ede0ab9 into dotnet:main Jun 16, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-blazor Includes: Blazor, Razor Components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Blazor Virtualize requires inline styles, preventing strict CSP enforcement

6 participants