Sichere Bild-Uploads in ASP.NET MVC 5
Eine Upload-Action in MVC 5 nimmt eine Datei von jedem an, der die URL erreichen kann, und schreibt sie in einen Ordner Ihrer Website. Diese Seite geht durch, was RteController.Upload im Beispiel vor dem Schreiben prüft und welche dieser Prüfungen die Modellbindung von MVC stillschweigend überspringen kann. Sie behandelt außerdem, wie web.config verhindert, dass in uploads/ jemals Code ausgeführt wird, und welche Dateien eines MVC-Projekts IIS ausliefern würde, wenn Sie sie bereitstellen. Jedes Ergebnis hier wurde unter IIS Express gemessen.
Die Datei ohne 500 erhalten
[HttpPost]
public ActionResult Upload()
{
try
{
// Not action parameters: MVC binds HttpPostedFileBase from the *validated*
// Request.Files even under [ValidateInput(false)], so a file named "<b>.png"
// would fail with an HTML 500 before this code runs.
HttpPostedFileBase file = Request.Unvalidated.Files["file"];
string folder = Request.Unvalidated.Form["folder"];
string url = RteFiles.SaveUpload(HttpContext, file, folder);
return JsonStatus(200, new { url = url });
}
catch (RteException ex)
{
return JsonStatus(ex.Status, new { error = ex.Message });
}
}[ValidateInput(false)] am Controller schaltet die ASP.NET-Anforderungsüberprüfung für die Werte ab, die MVC bindet. Die eigenen Regeln des Beispiels prüfen diese Werte stattdessen. Das gilt nicht für einen Action-Parameter vom Typ HttpPostedFileBase. Mit Upload(HttpPostedFileBase file, string folder) erzeugte eine Datei namens <script>x.png weiterhin eine HTML-Seite mit 500 und „A potentially dangerous Request.Files value was detected from the client“. Das ist keine Lücke, denn der Request wurde abgelehnt. Die Ablehnung ist aber eine HTML-Seite, die Ihre Fehlerbehandlung nie zu sehen bekommt. Das Lesen von Request.Unvalidated.Files["file"] liefert dasselbe HttpPostedFileBase, und dann entscheiden die Prüfungen unten.
Was RteFiles prüft
StorageRoot root = GetRoot(context); // 401 here first in per-user mode
if (file == null || file.ContentLength == 0)
throw new RteException(400, "No file was uploaded.");
// FileName is whatever the browser sent (old IE sends a full path). Don't feed it
// to Path.GetFileName: on .NET Framework that throws on characters like " < > |.
string clientName = file.FileName ?? "";
clientName = clientName.Substring(Math.Max(clientName.LastIndexOf('/'), clientName.LastIndexOf('\\')) + 1);
int dot = clientName.LastIndexOf('.');
string ext = dot >= 0 ? clientName.Substring(dot).ToLowerInvariant() : "";
if (Array.IndexOf(ImageExtensions, ext) < 0)
throw new RteException(400, "Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded.");
if (file.ContentLength > MaxUploadBytes)
throw new RteException(413, "The image is larger than the 4 MB (4,000,000 bytes) limit.");
string dir = ResolveFolder(root, folder ?? "");
byte[] data = new byte[file.ContentLength];
Stream input = file.InputStream;
int read = 0;
while (read < data.Length)
{
int n = input.Read(data, read, data.Length - read);
if (n <= 0) break;
read += n;
}
// file.ContentType is chosen by the browser (or the attacker), so look at the bytes.
if (read != data.Length || !SignatureMatches(ext, data))
throw new RteException(400, "The file is not a valid " + ext.Substring(1).ToUpperInvariant() + " image.");- Dateiendung: .jpg, .jpeg, .png, .gif oder .webp.
.svg,.phpund.aspxerhalten ein 400, ganz gleich, was sie enthalten. - Größe: bis zu 4,000,000 Bytes. Größere Dateien erhalten ein JSON-413.
- Inhalt:
HttpPostedFileBase.ContentTypeist die Behauptung des Browsers, und alstext/htmlgesendete PNG-Bytes kamen genau so an. Deshalb werden die ersten Bytes mit der Signatur der Dateiendung verglichen (JPEGFF D8 FF, PNG89 50 4E 47 0D 0A 1A 0A, GIFGIF8, WEBPRIFF….WEBP). - Name: Der Dateiname des Clients wird von Hand am letzten Schrägstrich abgeschnitten, weil
Path.GetFileNamein .NET Framework bei<und|eine Ausnahme wirft. Danach wird er aufa-z0-9-reduziert und um 8 zufällige Hex-Zeichen ergänzt.FileMode.CreateNewüberschreibt nie eine Datei. - Ordner: Jedes Segment muss ein gültiger Ordnername sein, und der vollständige Pfad muss innerhalb von
Server.MapPath("~/uploads")bleiben.folder=../erhielt im Vertragstest ein 400.
// Turns a virtual folder ("", "products", "products/2026") into a directory inside the
// root. Every segment must be a valid folder name, which already rules out "..", ".",
// empty segments, "\", ":", drive letters, NUL and leading "/". The resolved path is
// then checked against the root anyway, so a mistake in the rules cannot escape it.
static string ResolveFolder(StorageRoot root, string folder)
{
if (folder.Length > 0)
{
foreach (string segment in folder.Split('/'))
{
if (!IsValidName(segment))
throw new RteException(400, "Invalid folder.");
}
}
string full = Path.GetFullPath(Path.Combine(root.Dir, folder.Replace('/', '\\')));
if (!(full.TrimEnd('\\') + "\\").StartsWith(root.Dir, StringComparison.OrdinalIgnoreCase))
throw new RteException(400, "Invalid folder.");
if (!Directory.Exists(full))
throw new RteException(404, "Folder not found.");
return full;
}In uploads/ läuft nie Code
Eine passende Signatur beweist nur, dass die Datei wie ein Bild beginnt. Der Ordner ist das zweite Schloss:
<!-- Uploaded files are served as static files and nothing else. Clearing the handler list
means that even if a .aspx, .ashx or .config ever lands in uploads/, IIS will not run
it - it gets 404, not executed. The extension allow-list also refuses to serve
anything that is not an image (.html, .svg, .txt ...), wherever it came from. -->
<location path="uploads">
<system.webServer>
<handlers>
<clear />
<add name="StaticFile" path="*" verb="GET,HEAD" modules="StaticFileModule" resourceType="File" requireAccess="Read" />
</handlers>
<security>
<requestFiltering>
<fileExtensions allowUnlisted="false">
<add fileExtension=".jpg" allowed="true" />
<add fileExtension=".jpeg" allowed="true" />
<add fileExtension=".png" allowed="true" />
<add fileExtension=".gif" allowed="true" />
<add fileExtension=".webp" allowed="true" />
</fileExtensions>
</requestFiltering>
</security>
<httpProtocol>
<customHeaders>
<add name="X-Content-Type-Options" value="nosniff" />
</customHeaders>
</httpProtocol>
</system.webServer>
</location>Wir haben das am MVC-Beispiel getestet. Eine einzeilige Seite <%= 6*7 %> im Stammverzeichnis der Website gab 42 zurück, wurde also ausgeführt. Dieselbe Datei in uploads/ antwortete mit 404.7 (Dateiendung nicht zulässig). Mit nur geleerter Handler-Liste und ohne Allowlist antwortete eine .aspx dort mit 404.3. Das haben wir am Web-Forms-Beispiel gemessen, das denselben Block verwendet. Ausgelieferte Bilder tragen X-Content-Type-Options: nosniff. Das Routing von MVC stört nicht, weil vorhandene Dateien unter /uploads/ von IIS ausgeliefert werden, bevor überhaupt eine Route versucht wird.
Was ein MVC-Ordner sonst noch preisgibt
Während die Website aus dem Projektordner lief, wie es run.ps1 tut, haben wir einige Projektdateien angefordert:
| URL | Antwort |
|---|---|
/RteFiles.cs, /RteMvc5Sample.csproj | 404.7 (IIS verweigert diese Dateiendungen) |
/web.config, /bin/RteMvc5Sample.dll | 404.8 (versteckte Segmente) |
/Views/Home/Index.cshtml | 404 (der BlockViewHandler in Views/web.config) |
/obj/project.assets.json | 200, mit Ihren lokalen Pfaden und der Paketliste |
Stellen Sie bin\, Views\, Global.asax, web.config und rte-files.js bereit, niemals obj\. Dieselbe Regel für versteckte Segmente ist der Grund, warum das Beispiel Galerieordner namens bin oder App_Data ablehnt: Ein Bild in uploads/bin/ antwortete mit 404.8.
Testen
Während das Beispielprojekt zu dieser Anleitung auf Port 5402 läuft:
echo "<script>alert(1)</script>" > fake.png
curl -F "file=@fake.png" http://localhost:5402/rte/upload
{"error":"The file is not a valid PNG image."}
curl -F "file=@fake.png;filename=x.svg" http://localhost:5402/rte/upload
{"error":"Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded."}Beide Antworten sind HTTP 400 mit JSON. Im Galerie-Dialog erzeugen dieselben Dateien die eigene Meldung des Dialogs „Upload failed for <name>.“, nicht den Text des Servers.
Dieselbe Anleitung für andere Plattformen
JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · Web Forms