Sicherer Bild-Upload in ASP.NET Core: die Prüfungen, auf die es ankommt
Eine Upload-Route nimmt Dateien von jedem an, der sie erreichen kann, daher braucht alles, was schiefgehen kann, eine eigene Prüfung. Diese Seite geht die Prüfungen im ASP.NET-Core-Beispiel in der Reihenfolge durch, in der ein Request auf sie trifft: woher der Request kam, der Ordner, die Größe, der Typ, die Bytes, der gespeicherte Name und wie die Datei danach ausgeliefert wird. Zu jeder Prüfung gibt es die Antwort, die wir erhalten haben, als wir sie absichtlich ausgehebelt haben.
Die Prüfungen, der Reihe nach
- Origin. Ein POST, den ein Browser als von einer anderen Website stammend kennzeichnet, erhält ein 403.
- Ordner. Der virtuelle Ordner muss sich innerhalb von
uploads/auflösen lassen und existieren (400 oder 404). Galerieordner in ASP.NET Core behandelt diese Prüfung. - Größe. Über 4,000,000 Bytes gibt es ein 413. Viel größere Requests werden schon früher gestoppt, durch die Limits in Größenlimits für Uploads in ASP.NET Core.
- Dateiendung. Nur
.jpg,.jpeg,.png,.gifund.webp. SVG fehlt, weil eine SVG-Datei, die von Ihrer eigenen Website ausgeliefert wird, Skripte ausführen kann. - Bytes. Die ersten Bytes müssen zur Dateiendung passen.
- Name. Der gespeicherte Name wird generiert, sodass der Name des Clients nie auf die Festplatte gelangt.
Vertrauen Sie weder ContentType noch FileName
IFormFile.ContentType ist der Content-Type des Multipart-Teils, den der Client schreibt. Ein Browser leitet ihn aus dem Dateinamen ab, und curl sendet, was man ihm sagt. Das Beispiel ignoriert ihn und liest stattdessen die ersten zwölf Bytes:
static bool SignatureMatches(string ext, byte[] head) => ext switch
{
".jpg" or ".jpeg" => head[0] == 0xFF && head[1] == 0xD8 && head[2] == 0xFF,
".png" => head.AsSpan(0, 8).SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }),
".gif" => head.AsSpan(0, 4).SequenceEqual("GIF8"u8),
".webp" => head.AsSpan(0, 4).SequenceEqual("RIFF"u8) && head.AsSpan(8, 4).SequenceEqual("WEBP"u8),
_ => false,
};Das stoppt ein in holiday.jpg umbenanntes Skript. Es beweist nicht, dass die Datei ein gültiges Bild ist: Eine Datei kann wie ein PNG beginnen und weiter hinten etwas anderes enthalten. Harmlos wird das hier dadurch, wie die Datei ausgeliefert wird (siehe unten). Wenn Sie mehr brauchen, dekodieren und kodieren Sie jeden Upload mit einer Bildbibliothek neu. Das Beispiel tut das nicht, um ohne Abhängigkeiten auszukommen.
// "Red Square.png" -> "red-square"
static string SafeBaseName(string fileName)
{
var name = Regex.Replace(Path.GetFileNameWithoutExtension(fileName).ToLowerInvariant(), "[^a-z0-9]+", "-").Trim('-');
if (name.Length > 40) name = name[..40].TrimEnd('-');
return name.Length == 0 ? "image" : name;
}Der Basisname wird kleingeschrieben und auf a-z, 0-9 und - reduziert, acht zufällige Hex-Zeichen aus RandomNumberGenerator werden angehängt, und die Datei wird mit FileMode.CreateNew geöffnet, was fehlschlägt, statt zu überschreiben.
Die Dateien sicher ausliefern
var uploads = RteFiles.UploadsFolder(app.Environment);
Directory.CreateDirectory(uploads);
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(uploads),
RequestPath = "/uploads",
ContentTypeProvider = new FileExtensionContentTypeProvider(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png",
[".gif"] = "image/gif", [".webp"] = "image/webp",
}),
OnPrepareResponse = ctx => ctx.Context.Response.Headers.XContentTypeOptions = "nosniff",
});Der standardmäßige FileExtensionContentTypeProvider von ASP.NET Core kennt .html, .svg und .js, sodass ein Standardaufruf von UseStaticFiles für den Upload-Ordner diese als Seiten und Skripte ausliefern würde. Der Provider des Beispiels kennt nur die fünf Bildtypen. Alles andere im Ordner erhält ein 404, weil ServeUnknownFileTypes ausgeschaltet bleibt. X-Content-Type-Options: nosniff weist den Browser an, den angegebenen Typ zu verwenden und nicht zu raten.
Cross-Site-Requests ohne Token
Die übliche Antwort von ASP.NET Core auf Cross-Site Request Forgery ist ein Antiforgery-Token. Die Requests des Editors für Upload und „New folder“ sind XHRs, die rte-files.js und das Galerie-Plugin erzeugen, und das aktuelle Release bietet keine Möglichkeit, ihnen einen Header oder ein Feld hinzuzufügen. Deshalb verzichten die Routen ausdrücklich auf die Token-Validierung und prüfen stattdessen, woher der Request kam:
// CSRF: the editor's upload and create-folder requests are XHRs that carry no antiforgery token,
// so these POSTs refuse requests that a browser marks as coming from another site instead.
// Browsers send Sec-Fetch-Site and Origin on these requests; tools like curl send neither.
static void RequireSameOrigin(HttpRequest request)
{
var site = request.Headers["Sec-Fetch-Site"].ToString();
var origin = request.Headers.Origin.ToString();
var foreign = site is "cross-site" or "same-site"
|| (origin.Length > 0 && !(Uri.TryCreate(origin, UriKind.Absolute, out var o)
&& string.Equals(o.Authority, request.Host.Value, StringComparison.OrdinalIgnoreCase)));
if (foreign) throw new RteError(403, "Cross-site request refused.");
}[ApiController]
[Route("rte")]
// [ApiController] actions are not antiforgery-checked unless the app adds a global
// AutoValidateAntiforgeryTokenAttribute. The editor's XHRs cannot send a token, so this opts out
// explicitly; RteFiles checks the request's origin instead.
[IgnoreAntiforgeryToken]Browser senden bei diesen Requests Sec-Fetch-Site und Origin, und eine Seite auf einer anderen Website kann sie nicht ändern. curl und serverseitiger Code senden keines von beiden und werden durchgelassen: Sie haben die Cookies des Benutzers nicht. Hinter einem Reverse Proxy, der den Header Host ändert, ergänzen Sie UseForwardedHeaders, damit Request.Host der öffentliche Host ist. In der Minimal-API-Variante ist dieselbe Entscheidung .DisableAntiforgery(). In .NET 8 ist sie erforderlich, wie Bilder aus einem Rich-Text-Editor in ASP.NET Core hochladen erklärt.
Nichts davon entscheidet, wer hochladen darf. Ergänzen Sie [Authorize] oder .RequireAuthorization(), sobald Ihre App eine Anmeldung hat.
Testen
Starten Sie das Beispielprojekt zu dieser Anleitung mit dotnet run und senden Sie ihm ein Skript mit dem Namen eines Fotos, eine SVG-Datei und einen Request, der angeblich von einer anderen Website kommt:
echo "not a picture" > holiday.jpg
curl -F "file=@holiday.jpg" http://localhost:5302/rte/upload
{"error":"The file is not a valid .jpg image."}
curl -F "file=@logo.svg" http://localhost:5302/rte/upload
{"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}
curl -H "Origin: https://evil.example" -F "file=@photo.jpg" http://localhost:5302/rte/upload
{"error":"Cross-site request refused."}
curl -I http://localhost:5302/uploads/blog/photo-65fc24fe.jpg
HTTP/1.1 200 OK
Content-Type: image/jpeg
X-Content-Type-Options: nosniffDie ersten beiden ergeben 400, der dritte 403. In der Galerie wird dieselbe gefälschte holiday.jpg ebenfalls abgelehnt, aber der Dialog zeigt seine eigene allgemeine Meldung, nicht die des Servers:

Dieselbe Anleitung für andere Plattformen
JavaScript · Next.js · PHP · Razor Pages · MVC 5 · Web Forms