HtmlSanitizer
Formatierter Text aus einer Eingabe darf nicht ungeprüft in eine Seite. Die Basis bringt dafür
einen Endpunkt und eine Erweiterungsmethode mit, die beide gegen dieselbe Whitelist
arbeiten wie der Richtext-Editor im Browser, erlaubt ist, was HtmlSanitizerRichtextDefauls
nennt, alles Übrige wird entfernt.
Beide sind erreichbar, sobald die Komponente referenziert ist; eine Registrierung in der Anwendung braucht es nicht.
Bereinigung ausprobieren
Der Endpunkt /api/widgets/HtmlSanitizer/check nimmt eine Zeichenkette entgegen und gibt sie bereinigt zurück – gegen dieselbe Whitelist, die auch der Richtext-Editor im Browser verwendet.
@* Bereinigt wird über sanitizeString(text) aus der Skript-Sammlung der Basis, siehe Tab
„JavaScript". Die Funktion holt die Whitelist des Servers einmalig ab und lässt DOMPurify damit
arbeiten; eine eigene Umsetzung braucht es nicht.
Es ist dieselbe Whitelist, gegen die auch der Richtext-Editor und die serverseitige
Erweiterungsmethode SanitizeHtml() bereinigen. *@
<label class="form-label" for="sanitizerEingabe">HTML zum Prüfen</label>
<textarea id="sanitizerEingabe" class="form-control font-monospace" rows="5"><p>Ein <b>fetter</b> Text mit <a href="https://siwf.ch" target="_blank">Link</a>.</p>
<script>alert('böse')</script>
<img src=x onerror="alert('auch böse')">
<table><tr><td>Tabelle ist nicht erlaubt</td></tr></table></textarea>
@Html.Fmh(Fmh.Button()
.Text("Bereinigen")
.Type(ButtonType.Button)
.Size(ButtonSize.Sm)
.Id("sanitizerPruefen")
.Attributes(new { @class = "mt-2" }))
<p class="fw-bold mt-3 mb-1">Was der Server zurückgibt</p>
<pre class="border rounded p-3 mb-2" data-sanitizer-ausgabe>Noch nichts geprüft.</pre>
<p class="fw-bold mb-1">So gerendert</p>
<div class="border rounded p-3" data-sanitizer-vorschau></div>
@Html.PostScript(@<script src="~/js/demos/htmlsanitizer-pruefen.js" asp-append-version="true"></script>)
// Beispiel «Bereinigung ausprobieren» (Basis).
//
// Selbst bereinigen muss hier niemand: Die Skript-Sammlung der Basis bringt sanitizeString(text)
// mit (fmh-client-sanitizer.js). Die Funktion holt die Whitelist des Servers einmalig ab, merkt
// sie sich in window.sanitizerSettings und lässt DOMPurify damit arbeiten.
//
// sanitizeString ist async und liefert ein Promise.
document.addEventListener('click', function (ereignis) {
if (ereignis.target.id !== 'sanitizerPruefen') {
return;
}
var eingabe = document.getElementById('sanitizerEingabe');
var ausgabe = document.querySelector('[data-sanitizer-ausgabe]');
var vorschau = document.querySelector('[data-sanitizer-vorschau]');
sanitizeString(eingabe.value).then(function (bereinigt) {
ausgabe.textContent = bereinigt;
// Bewusst innerHTML: Genau das ist der Zweck der Bereinigung, was hier ankommt, darf in
// die Seite. Ungeprüfte Eingaben gehören niemals so eingesetzt.
vorschau.innerHTML = bereinigt;
});
});
// Dieselbe Umsetzung mit jQuery. Diese Datei wird NICHT geladen, sie steht nur im Code-Tab
// «jQuery». Würden beide Fassungen laufen, liefe jeder Klick doppelt.
//
// Unterschied zur nativen Fassung gibt es hier fast keinen: sanitizeString stammt aus der
// Skript-Sammlung der Basis und liefert ein Promise, .then() bleibt also auch mit jQuery.
// jQuery steuert nur die Delegation und den Zugriff auf die Elemente bei.
$(document).on('click', '#sanitizerPruefen', function () {
var $ausgabe = $('[data-sanitizer-ausgabe]');
var $vorschau = $('[data-sanitizer-vorschau]');
sanitizeString($('#sanitizerEingabe').val()).then(function (bereinigt) {
$ausgabe.text(bereinigt);
// Bewusst html(): Genau das ist der Zweck der Bereinigung.
$vorschau.html(bereinigt);
});
});
Darstellung
Was der Server zurückgibt
Noch nichts geprüft.
So gerendert
HTML aus einer Eingabe darf nicht ungeprüft in eine Seite. Die Basis bringt dafür einen Endpunkt und eine Erweiterungsmethode mit, beide bereinigen gegen dieselbe Whitelist.
Serverseitig
using FMH.Komponente.Basis.Ui.Infrastruktur.HtmlSanitizer.Erweiterungen;
var sicher = eingabe.SanitizeHtml();
SanitizeHtml bereinigt gegen HtmlSanitizerRichtextDefauls.Options() und dekodiert das Ergebnis
anschliessend (HttpUtility.HtmlDecode), es kommt also lesbares HTML zurück, keine Entitäten.
Daneben gibt es SanitizeDbParameterTexte() für Altbestände: eine fest verdrahtete, sehr enge Liste
(p, i, b, br, span, strong), unabhängig von der Konfiguration der Anwendung.
Im Browser
Dafür gibt es fertige Funktionen in der Skript-Sammlung (fmh-client-sanitizer.js), eine eigene
Umsetzung ist nicht nötig:
sanitizeString(text).then(function (bereinigt) { … });
| Funktion | Wirkung |
|---|---|
sanitizeString(text) |
Bereinigt und liefert ein Promise. Holt die Whitelist beim ersten Aufruf selbst. |
sanitize(text) |
Dasselbe synchron, setzt voraus, dass die Whitelist bereits geladen ist. |
loadSanitizerSettings() |
Holt die Whitelist und legt sie in window.sanitizerSettings; weitere Aufrufe liefern den gemerkten Stand. |
Bereinigt wird dabei im Browser durch DOMPurify, mit der Whitelist des Servers. Die Komponente selbst nutzt das an mehreren Stellen, etwa beim Verlassen eines Frage-Antwort-Textfelds.
Die drei Endpunkte
| Adresse | Methode | Antwort |
|---|---|---|
/api/widgets/HtmlSanitizer/ping |
GET | Widgets <Version>, zum Prüfen, ob die Komponente eingebunden ist |
/api/widgets/HtmlSanitizer/settings |
GET | die Whitelist für DOMPurify, siehe zweites Beispiel |
/api/widgets/HtmlSanitizer/check |
POST | die bereinigte Zeichenkette |
/settings ist der einzige, den die Skript-Sammlung von sich aus aufruft, loadSanitizerSettings()
holt ihn einmalig. /check bereinigt serverseitig und wird von keiner Funktion der Sammlung
verwendet; er ist der Weg, wenn die Bereinigung nicht dem Browser überlassen werden soll. Sein
Rumpf ist eine Zeichenkette in JSON (JSON.stringify(text)), weil er über [FromBody] string
bindet.
Sie sind mit [AllowAnonymous] versehen und erreichbar, sobald die Komponente referenziert ist.
Warum es keine Registrierung braucht
Die Komponente ist eine Razor Class Library (Sdk="Microsoft.NET.Sdk.Razor"). Der Build stempelt
deshalb ein Attribut in die Assembly:
[assembly: ProvideApplicationPartFactory(typeof(ConsolidatedAssemblyApplicationPartFactory))]
AddControllersWithViews() durchsucht beim Start die Abhängigkeiten der Anwendung nach Assemblies,
die MVC referenzieren; findet es dort dieses Attribut, meldet die Fabrik die Assembly als
Application Part an, mit ihren kompilierten Views und ihren Controllern.
In der alten Lösung stand dafür ein Aufruf in Program.cs:
builder.Services.AddControllersWithViews()
…
.AddWidgetController(); // entfällt
Der ist nicht mehr nötig und in der Komponente auch nicht mehr vorhanden. Er tat zweierlei:
AddApplicationPart(...), heute überflüssig, und AddControllersAsServices(), was Controller
vom DI-Container erzeugen lässt. Letzteres ist eine eigene Entscheidung und hat mit dem Auffinden
nichts zu tun; die Controller dieser Komponente haben keine Konstruktor-Abhängigkeiten.
Von Hand registrieren muss man weiterhin, wenn eine Assembly nicht über eine Projekt- oder Paketreferenz eingebunden ist (Plugin, zur Laufzeit geladen) oder wenn eine Komponente mit Controllern nicht das Razor-SDK verwendet.
Was zu beachten ist
SanitizeHtmlgibt es zweimal. Einmal in dieser Komponente (…Basis.Ui.Infrastruktur.HtmlSanitizer.Erweiterungen), einmal im NuGet-Paketdatabinding.WebSecurity.HtmlSanitizer. Welche greift, entscheidet dieusing-Liste der aufrufenden Datei. Nur die Fassung dieser Komponente liestHtmlSanitizerRichtextDefaulsund berücksichtigt damit die Erweiterungen der Anwendung, wer die Whitelist gesetzt hat und sie wirken sehen will, importiert den Namespace der Komponente.- Bereinigen ersetzt kein Kodieren.
SanitizeHtmlist für Werte gedacht, die als HTML ausgegeben werden sollen. Text, der einfach nur Text ist, gehört kodiert ausgegeben, nicht bereinigt. - Die Bereinigung im Browser ist Bequemlichkeit, keine Absicherung.
sanitizeStringsorgt dafür, dass der Anwender gleich sieht, was übrig bleibt. Verlassen darf man sich darauf nicht: Ein Aufruf muss nicht aus dem Browser kommen. Serverseitig gehörtSanitizeHtml()trotzdem aufgerufen.
Whitelist der Anwendung
Was erlaubt ist, steht an einer einzigen Stelle. Die Anwendung erweitert sie in Program.cs; der Browser holt sich dieselbe Liste über /api/widgets/HtmlSanitizer/settings.
@* Die Whitelist steht an einer einzigen Stelle: HtmlSanitizerRichtextDefauls. Die Anwendung
erweitert sie in Program.cs, der Server bereinigt danach, und der Browser holt sich dieselbe
Liste über loadSanitizerSettings() aus der Skript-Sammlung der Basis ab, um DOMPurify damit zu
bestücken.
Der Tab „Program.cs" zeigt die beiden Zeilen dieser Anwendung im Original. *@
<p class="mb-2">Die Liste, die dieser Server gerade ausliefert:</p>
<pre class="border rounded p-3 mb-0" data-whitelist-ausgabe>Wird geladen …</pre>
@Html.PostScript(@<script src="~/js/demos/htmlsanitizer-whitelist.js" asp-append-version="true"></script>)
using System;
using System.Linq;
using databinding.WebSecurity.Headers;
using databinding.WebSecurity.HtmlSanitizer.Settings;
using FMH.ICT.Basis.Web.Security.Security;
using FMH.Komponente.Basis.Erweiterungen;
using FMH.Komponente.BenutzerPraesenzCheck;
using FMH.Komponente.Infrastruktur;
using FMH.Komponente.Infrastruktur.Bundle;
using FMH.Komponente.Infrastruktur.Mvc.Errors;
using FMH.Komponente.Serilog.Ingestion;
using FMH.Komponente.Tabelle.Erweiterungen;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Net.Http.Headers;
using Serilog;
using Serilog.Sinks.SystemConsole.Themes;
namespace FMH.Komponente.WebUI
{
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
var configuration = builder.Configuration;
builder.Host.UseSerilog((context, services, config) => config.Enrich.WithRequestHeader("User-Agent")
.Enrich.WithClientIp()
////.Enrich.With(services.GetService<BenutzerTraceIdNet6Enricher>()) // Einkommentieren mit isAuthEnabled = true
.ReadFrom.Configuration(configuration)
.WriteTo.Console(theme: ConsoleTheme.None));
// Stellt sicher, dass Static Web Assets referenzierter Razor Class Libraries (z.B. FMH.Komponente.Basis)
// unabhängig von der Hosting-Umgebung auflösbar sind, damit WebOptimizer deren SCSS-/JS-Dateien findet.
builder.WebHost.UseStaticWebAssets();
// Add services to the container.
// AdditionalMetadataProvider registrieren, damit [AdditionalMetadata(...)] die
// ModelMetadata.AdditionalValues füllt, Voraussetzung für die Listen-EditorTemplates
// (DropdownList/RadioList/CheckboxList/…, Schlüssel "Selection") sowie für die
// Display-Optionen Bold/ValueColorClass und die Icons in Auswahlbox/CheckInfo.
builder.Services.AddControllersWithViews(options =>
{
options.AddFmhWidgetOptions();
// Nötig für die Tabellen-Beispiele mit AjaxDataUrl: Ohne den Binder darin kommt
// GridDatatableParameter leer im Endpunkt an (DataTables sendet start/length/
// order[0][dir]/search[value], nicht Skip/Take/…). AddFmhWidgetOptions kann ihn
// nicht mitbringen, FMH.Komponente.Basis referenziert die Tabelle nicht.
options.AddFmhTabelleOptions();
});
builder.Services.AddBundledAssets();
// Links im Richtext zulassen. Die Voreinstellung des Sanitizers kennt weder das a-Tag
// noch Attribute, ein eingefügter Link würde samt Text entfernt.
//
// Muss VOR der ersten Verwendung stehen: Serverseitig liest HtmlWhitelistSanitizer die
// Werte über HtmlSanitizerRichtextDefauls.Options(), clientseitig meldet sie der
// Endpunkt /api/widgets/HtmlSanitizer/settings an DOMPurify. Beide Seiten arbeiten damit
// gegen dieselbe Liste.
HtmlSanitizerRichtextDefauls.AddAllowedTags("a");
HtmlSanitizerRichtextDefauls.AddAllowedAttributes("href", "target", "rel");
// Die Upload-Validierung (DocumentUploadRequired) liest die Anzahl bereits
// vorhandener Dokumente aus dem Request – dafür braucht sie den HttpContext.
builder.Services.AddHttpContextAccessor();
// Bindet die Section "BenutzerPraesenzPruefer" der appsettings.json an das Setting und
// macht es über IOptions injizierbar, die Demo-Seite nutzt es genau so wie ein Projekt.
builder.Services.AddBenutzerPraesenzPruefer(builder.Configuration);
// Bindet die Section "ErrorPages" an das Setting. Die Fehlerseiten des Pakets
// Infrastruktur sind über /400, /401, /403, /404 und /500 ohnehin erreichbar; erst
// diese Zeile gibt ihnen Logo und Texte dieser Anwendung.
//
// Bewusst OHNE app.UseSiwfErrorPages(...): Die Middleware würde in Betrieb die
// Umleitung auf die Fehlerseiten übernehmen und damit UseExceptionHandler ersetzen.
// Beim Entwickeln hält sie sich über ihren Entwicklermodus selbst zurück, sie bleibt
// hier trotzdem aussen vor, damit die Pipeline dieser Anwendung dem entspricht, was
// die Code-Tabs der Demo-Seite «Fehlerseiten» zeigen.
builder.Services.AddSiwfErrorPages(builder.Configuration);
var app = builder.Build();
app.UseSiwfLogIngestion();
var isDev = app.Environment.IsDevelopment();
// Configure the HTTP request pipeline.
if (!isDev)
{
app.UseExceptionHandler("/Home/Error");
// Ausnahme für die Integrationstests: Die Seiten der Area Integration zeigen auch
// ausserhalb von Development die Entwickler-Fehlerseite. Der Test der doppelten
// Render-Aufrufe liest dort die Meldung der Ausnahme, die allgemeine Fehlerseite
// verschluckt sie. Liegt innerhalb von UseExceptionHandler und fängt deshalb zuerst.
app.UseWhen(
context => context.Request.Path.StartsWithSegments("/Integration"),
integration => integration.UseDeveloperExceptionPage());
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
var headerCollection = new HeaderCollectionBuilder().AddConnectSrcLocalhost()
.AddUnsafeInlineScriptSrc()
.AddBlazor()
.Build();
// frame-src/frame-ancestors auf 'self': Die Doku-Seite AnalyseDemo/Demo/AnalyseSeite zeigt
// die Analyse-Seite derselben Anwendung in einem Rahmen. frame-src erlaubt das Einbetten,
// frame-ancestors das Eingebettetwerden, beides bleibt auf die eigene Herkunft beschränkt.
// Der HeaderCollectionBuilder kennt dafür keine Kurzform, deshalb hier am fertigen
// HeaderCollection ergänzt, statt die Richtlinie neu aufzubauen.
headerCollection.GetContentSecurityPolicyBuilder()
.AddFrameSrc(direktive => direktive.Self())
.AddFrameAncestors(direktive => direktive.Self());
app.UseHeaderSecurity(headerCollection);
app.UseHttpsRedirection();
app.UseWebOptimizer();
// Native ES-Module werden nicht gebündelt. Der Versionsstempel wirkt nur auf die jeweilige
// Einstiegsdatei, nicht auf die transitiv importierten Module. Deshalb erzwingen wir für die
// Modulverzeichnisse eine ETag-Revalidierung bei jedem Laden, damit Änderungen an jeder Datei
// im Modul-Graphen sofort sichtbar werden. Die Verzeichnisse liefert der Modulkatalog, damit
// ein neues Modul hier nicht nachgetragen werden muss.
var modulVerzeichnisse = app.Services.GetRequiredService<ModuleScriptRegistry>()
.GetModuleDirectories();
// Nur für die Demo: Dateitypen ohne bekannten MIME-Typ liefert UseStaticFiles nicht aus.
// Die Beispiele des DocumentViewers brauchen aber ein Format, das er bewusst nicht
// darstellen kann, dort ist eine Outlook-Nachricht der realistische Fall.
var inhaltstypen = new FileExtensionContentTypeProvider();
inhaltstypen.Mappings[".msg"] = "application/vnd.ms-outlook";
// Nicht nur für die Demo: pdf.js im PDFViewer lädt Zeichensatztabellen (.bcmap) und
// Farbprofile (.icc) nach, beide kennt UseStaticFiles nicht und antwortet mit 404.
inhaltstypen.Mappings[".bcmap"] = "application/octet-stream";
inhaltstypen.Mappings[".icc"] = "application/vnd.iccprofile";
app.UseStaticFiles(
new StaticFileOptions
{
ContentTypeProvider = inhaltstypen,
OnPrepareResponse = context =>
{
var pfad = context.Context.Request.Path.Value;
if (pfad != null && modulVerzeichnisse.Any(verzeichnis =>
pfad.StartsWith(verzeichnis, StringComparison.OrdinalIgnoreCase)))
{
context.Context.Response.Headers[HeaderNames.CacheControl] = "no-cache, must-revalidate";
}
},
});
app.UseRouting();
app.UseAuthorization();
app.MapControllerRoute("areas", "{area:exists}/{controller=Home}/{action=Index}/{id?}");
// Sprechende Adressen für die Anleitungen: /Anleitungen/logging/ueberblick. Beide Segmente
// sind Pflicht, deshalb fällt /Anleitungen weiter auf die Default-Route und landet auf Index.
app.MapControllerRoute("anleitungen", "Anleitungen/{gruppe}/{seite}", new { controller = "Anleitungen", action = "Seite" });
app.MapControllerRoute("default", "{controller=Home}/{action=Index}/{id?}");
app.Run();
}
}
}
// Beispiel «Whitelist der Anwendung» (Basis).
//
// loadSanitizerSettings() aus fmh-client-sanitizer.js holt /api/widgets/HtmlSanitizer/settings ab
// und legt das Ergebnis in window.sanitizerSettings. Ein zweiter Aufruf liefert den gemerkten
// Stand zurück, ohne erneut beim Server nachzufragen.
var whitelistAusgabe = document.querySelector('[data-whitelist-ausgabe]');
if (whitelistAusgabe !== null) {
loadSanitizerSettings().then(function (einstellungen) {
whitelistAusgabe.textContent =
'ALLOWED_TAGS (' + einstellungen.ALLOWED_TAGS.length + ')\n ' +
einstellungen.ALLOWED_TAGS.join(', ') + '\n\n' +
'ALLOWED_ATTR (' + einstellungen.ALLOWED_ATTR.length + ')\n ' +
einstellungen.ALLOWED_ATTR.join(', ');
});
}
// Dieselbe Umsetzung mit jQuery. Diese Datei wird NICHT geladen, sie steht nur im Code-Tab
// «jQuery».
//
// loadSanitizerSettings stammt aus der Skript-Sammlung der Basis und liefert ein Promise; jQuery
// ändert daran nichts und steuert hier nur den Zugriff auf das Ausgabefeld bei.
var $whitelistAusgabe = $('[data-whitelist-ausgabe]');
if ($whitelistAusgabe.length > 0) {
loadSanitizerSettings().then(function (einstellungen) {
$whitelistAusgabe.text(
'ALLOWED_TAGS (' + einstellungen.ALLOWED_TAGS.length + ')\n ' +
einstellungen.ALLOWED_TAGS.join(', ') + '\n\n' +
'ALLOWED_ATTR (' + einstellungen.ALLOWED_ATTR.length + ')\n ' +
einstellungen.ALLOWED_ATTR.join(', '));
});
}
Darstellung
Die Liste, die dieser Server gerade ausliefert:
Wird geladen …
Was erlaubt ist, steht an einer Stelle: HtmlSanitizerRichtextDefauls aus dem NuGet-Paket
databinding.WebSecurity.HtmlSanitizer. Trotz des Namens gehört die Klasse nicht zur Komponente
RichText, sie kommt aus dem Paket und ist überall verfügbar, wo es referenziert ist.
Erweitern in Program.cs
HtmlSanitizerRichtextDefauls.AddAllowedTags("a");
HtmlSanitizerRichtextDefauls.AddAllowedAttributes("href", "target", "rel");
Vor der ersten Verwendung setzen. Die Liste ist statisch; wer sie später ändert, ändert sie für alles, was danach kommt, und nicht für das, was schon gerendert wurde.
Ohne diese beiden Zeilen kennt die Voreinstellung weder das a-Tag noch Attribute: Ein eingefügter
Link würde samt Text entfernt.
Dieselbe Liste im Browser
/api/widgets/HtmlSanitizer/settings gibt sie in der Schreibweise aus, die DOMPurify erwartet:
{
"ALLOWED_TAGS": [ "h1", "h2", "…", "a" ],
"ALLOWED_ATTR": [ "…", "href", "target", "rel" ]
}
Grossbuchstaben sind kein Zufall, der Controller serialisiert bewusst ohne Camel-Case-Regel, weil DOMPurify genau diese Schlüssel liest. Server und Browser arbeiten damit gegen dieselbe Liste, ohne dass sie irgendwo doppelt gepflegt wird.
Was zu beachten ist
- Die Liste gilt prozessweit, nicht je Feld oder je Anfrage. Ein Formular, das mehr erlauben soll als ein anderes, lässt sich darüber nicht abbilden.
AddAllowedTagsergänzt, es ersetzt nicht. Mehrfache Aufrufe summieren sich.- Attribute sind unabhängig von Tags. Wer
hreferlaubt, erlaubt es überall, eine Zuordnung „dieses Attribut nur an diesem Tag" gibt es nicht. - Das Setzen in
Program.csist eine Anweisung an eine statische Klasse und kein Dienst. Sie taucht deshalb in keiner Registrierung auf und ist beim Suchen leicht zu übersehen.