TrustedTypes
Policies that turn a string into a value the browser will accept at a dangerous sink - and a CSP that makes every other string throw. The 'no unreviewed strings reach innerHTML' rule, enforced by the browser rather than by review.
@inject Bit.Butil.TrustedTypes trustedTypesMDN reference
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types my-policy -
at which point assigning a plain string to innerHTML, script.src or
eval throws, and only a value produced by a listed policy goes through. Start with
the -Report-Only header and OnViolation; every violation is a call site
to fix before you switch it on.
TrustedTypePolicyOptions declares what a
hand-written policy usually does instead: sanitize the HTML (through the browser's own
Sanitizer, optionally one you configured), and allow script URLs only
from prefixes you named.
Two different questions. IsSupported says window.trustedTypes exists; IsEnforced says a CSP is actually making plain strings fail. Nothing in the platform reports the second, so it is asked by assigning a plain string to a sink on a detached element and seeing whether it throws - the element is never in the document, so the probe has no effect. It comes back null rather than false when a 'default' policy is registered, because that policy would rescue the probe under enforcement too.
@inject Bit.Butil.TrustedTypes trustedTypes
var available = await trustedTypes.IsSupported();
bool? enforced = await trustedTypes.IsEnforced(); // null = a 'default' policy hides the answerA policy name has to be listed in the CSP's trusted-types directive, and can only be created once per document - so a false here is a configuration fact rather than an error. The name 'default' is special: the browser falls back to it for any string assigned to a sink without a policy, which is how existing code keeps working under enforcement.
Bit.Butil.TrustedTypes trustedTypes
{
// False when the name is not listed in the CSP's trusted-types directive, or when this document
// has already created it - a policy can only be created once. Both are configuration facts
// rather than errors, so this is worth doing once and remembering.
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender is false) return;
var created = await trustedTypes.CreatePolicy("app-html", new TrustedTypePolicyOptions
{
SanitizeHtml = true,
AllowedScriptUrlPrefixes = ["https://cdn.example.com/", "/_content/"],
});
}
}// The name the page creates has to be listed here, or CreatePolicy fails - the directive is the
// allowlist, and the browser is the thing enforcing it. Nothing in the page can turn this on.
app.Use(async (context, next) =>
{
context.Response.Headers["Content-Security-Policy"] =
"require-trusted-types-for 'script'; trusted-types app-html";
await next();
});The trusted value itself never crosses to .NET - it would arrive as its string and lose exactly the type that makes it trusted - so what comes back is only the resulting text, for display or comparison. Note the script URL: one that matches an allowed prefix comes back, anything else is refused.
var html = await trustedTypes.CreateHtml("app-html", userMarkup);
var src = await trustedTypes.CreateScriptUrl("app-html", "https://cdn.example.com/lib.js");
var refused = await trustedTypes.CreateScriptUrl("app-html", "https://evil.example/x.js"); // nullThe point of having a policy: under enforcement this succeeds where assigning a string to innerHTML throws. The markup is created and assigned in one call, because the trusted value cannot be handed back to C# in between.
await trustedTypes.SetHtml(preview, "app-html", userMarkup);
await trustedTypes.SetScriptSrc(scriptElement, "app-html", "https://cdn.example.com/lib.js");Where a rollout starts. Serve the report-only header, subscribe here, and every violation names a sink still being written to as a plain string, with the file and line that did it. Nothing will be reported on this page unless you are serving that header.
IAsyncDisposable
Bit.Butil.TrustedTypes trustedTypes
ILogger<TrustedTypes> logger
{
private ButilSubscription? _subscription;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender is false) return;
_subscription = await trustedTypes.OnViolation(violation =>
{
logger.LogWarning("{Directive} at {File}:{Line} - {Sample}",
violation.Directive, violation.SourceFile, violation.LineNumber, violation.Sample);
});
}
public async ValueTask DisposeAsync()
{
if (_subscription is not null) await _subscription.DisposeAsync();
}
}// Report-Only is where a rollout starts: nothing breaks, and every violation names a sink still
// being written to as a plain string. Without this header the subscription above reports nothing,
// because the browser has not been asked to look.
app.Use(async (context, next) =>
{
context.Response.Headers["Content-Security-Policy-Report-Only"] =
"require-trusted-types-for 'script'; trusted-types app-html";
await next();
});
// Swap the header name for Content-Security-Policy once the list of violations is empty.API reference
ValueTask<bool> IsSupported()ValueTask<bool?> IsEnforced()ValueTask<bool> CreatePolicy(string name, TrustedTypePolicyOptions? options = null, SanitizerHandle? sanitizer = null)ValueTask<bool> HasPolicy(string name)ValueTask<string[]> GetPolicyNames()ValueTask<string?> CreateHtml(string policyName, string html)ValueTask<string?> CreateScriptUrl(string policyName, string url)ValueTask<bool> SetHtml(ElementReference element, string policyName, string html)ValueTask<bool> SetScriptSrc(ElementReference scriptElement, string policyName, string url)ValueTask<ButilSubscription> OnViolation(Action<TrustedTypeViolation> handler)ValueTask DisposeAsync()SanitizeHtml | AllowedScriptUrlPrefixes | AllowScript