-
Notifications
You must be signed in to change notification settings - Fork 227
Add minimal-api-file-upload skill #264
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mrsharm
merged 17 commits into
dotnet:main
from
mrsharm:musharm/implementing-form-file-uploads-minimal-apis
Mar 31, 2026
Merged
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
6b42e69
Move minimal-api-file-upload skill to aspnetcore plugin with 3 eval s…
mrsharm 5ff29b5
Increase eval timeouts to 180s to avoid timeout-related scoring penal…
mrsharm db4eaa1
Generalized and addressed feedback
mrsharm cdd8ba1
Improve eval scenarios: remove noisy tests, add dual-limit and securi…
mrsharm afef016
Add CODEOWNERS for aspnetcore plugin
mrsharm bc0604e
Fix eval: enrich prompts for skill activation, bump security review t…
mrsharm e426dc4
Drop size-limit scenario (baseline already 5.0), keep 3 high-signal s…
mrsharm 714da75
Reframe security review as file upload fix to improve skill activation
mrsharm d91cacf
Review
mrsharm a77e824
Contribution guide followed
mrsharm 218faf0
Updated name
mrsharm 538c33c
Apply suggestion from @Copilot
ViktorHofer 79395cb
Update plugins/dotnet-aspnet/skills/minimal-api-file-upload/SKILL.md
mrsharm a44a996
Address PR #264 review feedback from Brennan and Copilot
mrsharm 6c46f82
Delete pr-comments.md
ViktorHofer b4fd583
Delete pr-264-description.md
ViktorHofer 28a9e75
Merge branch 'main' into musharm/implementing-form-file-uploads-minim…
ViktorHofer File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| { | ||
| "name": "dotnet-aspnet", | ||
| "version": "0.1.0", | ||
| "description": "ASP.NET Core web development skills including middleware, endpoints, real-time communication, and API patterns.", | ||
| "skills": ["./skills/"] | ||
| } | ||
234 changes: 234 additions & 0 deletions
234
plugins/dotnet-aspnet/skills/minimal-api-file-upload/SKILL.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,234 @@ | ||
| --- | ||
| name: minimal-api-file-upload | ||
| description: File upload endpoints in ASP.NET minimal APIs (.NET 8+) | ||
| --- | ||
|
|
||
| # Implementing File Uploads in ASP.NET Core Minimal APIs | ||
|
|
||
| ## When to Use | ||
| - File upload endpoints in ASP.NET Core minimal APIs (.NET 8+) | ||
| - Handling IFormFile or IFormFileCollection parameters | ||
| - When you need size limits, content type validation, or streaming large files | ||
|
|
||
| ## When Not to Use | ||
| - MVC controllers → `[FromForm] IFormFile` works directly with attributes | ||
| - Simple JSON body → no file upload needed | ||
| - Very large files (> 1GB) → use streaming with `MultipartReader` instead | ||
|
|
||
| ## Inputs | ||
|
|
||
| | Input | Required | Description | | ||
| |-------|----------|-------------| | ||
| | File parameter(s) | Yes | IFormFile or IFormFileCollection | | ||
| | Size limits | Yes | Max file/request size | | ||
| | Allowed types | No | Content type or extension restrictions | | ||
|
|
||
|
ViktorHofer marked this conversation as resolved.
|
||
| ## Workflow | ||
|
|
||
| ### Step 1: CRITICAL — Understand IFormFile Binding in Minimal APIs | ||
|
|
||
| ```csharp | ||
| // In .NET 8+ minimal APIs, IFormFile binds automatically from multipart/form-data | ||
| // when it is the only complex parameter. | ||
| app.MapPost("/upload", (IFormFile file) => ...); | ||
|
|
||
| // CRITICAL: When you mix files with other form fields, use [FromForm] on all | ||
| // form-bound parameters (or group them into a single [FromForm] DTO). | ||
| app.MapPost("/upload-with-metadata", | ||
| ([FromForm] IFormFile file, [FromForm] string description) => | ||
| { | ||
| return Results.Ok(new { file.FileName, Description = description }); | ||
| }); | ||
|
|
||
| // Multiple files: IFormFileCollection also binds automatically from multipart/form-data. | ||
| // You only need [FromForm] if you mix it with other form fields, as shown above. | ||
| app.MapPost("/upload-multiple", (IFormFileCollection files) => | ||
| { | ||
| return Results.Ok(files.Select(f => new { f.FileName, f.Length })); | ||
| }); | ||
| ``` | ||
|
|
||
| ### Step 2: CRITICAL — File Size Limits Are Separate from Request Size Limits | ||
|
|
||
| ```csharp | ||
| // CRITICAL: There are TWO different size limits and you need to configure BOTH | ||
|
|
||
| // 1. Request body size limit (Kestrel level) — default is 30MB | ||
| builder.WebHost.ConfigureKestrel(options => | ||
| { | ||
| options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10 MB | ||
| }); | ||
|
|
||
| // 2. Form options — multipart body length limit — default is 128MB | ||
| builder.Services.Configure<FormOptions>(options => | ||
| { | ||
| options.MultipartBodyLengthLimit = 10 * 1024 * 1024; // 10 MB | ||
| options.ValueLengthLimit = 1024 * 1024; // 1 MB for form values | ||
| options.MultipartHeadersLengthLimit = 16384; // 16 KB for section headers | ||
| }); | ||
|
|
||
| // COMMON MISTAKE: Only increasing Kestrel MaxRequestBodySize | ||
| // upload still fails because FormOptions.MultipartBodyLengthLimit is exceeded | ||
|
|
||
| // COMMON MISTAKE: Only increasing FormOptions | ||
| // upload fails with "Request body too large" from Kestrel before reaching form parsing | ||
|
|
||
| // CRITICAL: Per-endpoint override with RequestSizeLimit attribute | ||
| app.MapPost("/upload-large", [RequestSizeLimit(200_000_000)] (IFormFile file) => | ||
| { | ||
| return Results.Ok(new { file.FileName, file.Length }); | ||
| }); | ||
|
|
||
| // CRITICAL: To disable the limit entirely (for streaming): | ||
| app.MapPost("/upload-unlimited", [DisableRequestSizeLimit] async (HttpContext context) => | ||
| { | ||
| // Handle manually | ||
| }); | ||
| ``` | ||
|
|
||
| ### Step 3: CRITICAL — Anti-Forgery Auto-Validates Form Uploads in .NET 8+ | ||
|
|
||
| ```csharp | ||
| // CRITICAL: In .NET 8+ with UseAntiforgery(), ALL form-bound endpoints | ||
| // automatically validate anti-forgery tokens, INCLUDING file uploads | ||
|
|
||
| builder.Services.AddAntiforgery(); | ||
| var app = builder.Build(); | ||
| app.UseAntiforgery(); | ||
|
|
||
| // This endpoint now REQUIRES an anti-forgery token: | ||
| app.MapPost("/upload", (IFormFile file) => Results.Ok(file.FileName)); | ||
| // Without the token → 400 Bad Request | ||
|
|
||
| // CRITICAL: For API-only file uploads (no anti-forgery needed), opt out: | ||
| app.MapPost("/api/upload", (IFormFile file) => Results.Ok(file.FileName)) | ||
| .DisableAntiforgery(); // CRITICAL: Must explicitly opt out | ||
|
|
||
| // COMMON MISTAKE: Getting 400 errors on file uploads and not realizing | ||
| // it's because UseAntiforgery() is in the pipeline | ||
|
|
||
| // WARNING: DisableAntiforgery() is safe for unauthenticated endpoints and | ||
| // endpoints using JWT bearer authentication. However, for endpoints | ||
| // authenticated with cookies, disabling antiforgery removes CSRF protection | ||
| // and exposes the endpoint to cross-site request forgery attacks. | ||
| // For cookie-authenticated endpoints, include a valid antiforgery token instead. | ||
| ``` | ||
|
|
||
| ### Step 4: CRITICAL — Validate File Content, Not Just Extension | ||
|
|
||
| ```csharp | ||
| app.MapPost("/upload", async (IFormFile file) => | ||
| { | ||
| // CRITICAL: Check content type AND file signature (magic bytes) | ||
| // NEVER trust file extension alone — it can be spoofed | ||
|
|
||
| // Allow only JPEG/PNG by default. To support more (e.g., GIF), | ||
| // add the MIME type here AND validate its magic bytes below. | ||
| var allowedTypes = new[] { "image/jpeg", "image/png" }; | ||
| if (!allowedTypes.Contains(file.ContentType, StringComparer.OrdinalIgnoreCase)) | ||
| return Results.BadRequest("File type not allowed"); | ||
|
|
||
| // CRITICAL: Check magic bytes for file type verification | ||
| using var stream = file.OpenReadStream(); | ||
| var header = new byte[8]; | ||
| var bytesRead = await stream.ReadAsync(header, 0, header.Length); | ||
| if (bytesRead < 4) | ||
| return Results.BadRequest("File content is too short or invalid"); | ||
|
|
||
|
ViktorHofer marked this conversation as resolved.
|
||
| // JPEG: FF D8 FF | ||
| // PNG: 89 50 4E 47 | ||
| var isJpeg = header[0] == 0xFF && header[1] == 0xD8 && header[2] == 0xFF; | ||
| var isPng = header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47; | ||
|
|
||
| // Determine the actual content type from magic bytes | ||
| string? detectedContentType = isJpeg ? "image/jpeg" : isPng ? "image/png" : null; | ||
| if (detectedContentType is null) | ||
| return Results.BadRequest("File content is not a supported image format (only JPEG and PNG are allowed)."); | ||
|
|
||
| // Ensure the declared Content-Type matches what the magic bytes detected | ||
| if (!string.Equals(file.ContentType, detectedContentType, StringComparison.OrdinalIgnoreCase)) | ||
| return Results.BadRequest("File content type does not match the declared ContentType header."); | ||
|
|
||
|
ViktorHofer marked this conversation as resolved.
|
||
| // CRITICAL: Never use the user-provided filename directly for the save path — it can | ||
| // contain path traversal characters (e.g., "../../../etc/passwd"). | ||
| // Generate a safe filename; derive the extension from validated content, not user input. | ||
| var extension = detectedContentType == "image/jpeg" ? ".jpg" : ".png"; | ||
| var safeFileName = $"{Guid.NewGuid()}{extension}"; | ||
| // NEVER: var path = Path.Combine("uploads", file.FileName); // Path traversal! | ||
|
|
||
| var filePath = Path.Combine("uploads", safeFileName); | ||
| Directory.CreateDirectory("uploads"); | ||
|
ViktorHofer marked this conversation as resolved.
|
||
| stream.Position = 0; | ||
| using var fileStream = File.Create(filePath); | ||
| await stream.CopyToAsync(fileStream); | ||
|
|
||
| return Results.Ok(new { FileName = safeFileName, file.Length }); | ||
| }); | ||
| ``` | ||
|
|
||
| ### Step 5: CRITICAL — Streaming Large Files Without Buffering | ||
|
|
||
| ```csharp | ||
| // CRITICAL: IFormFile relies on multipart form parsing that buffers content in memory | ||
| // (up to a threshold) then spills to temp files on disk. For very large uploads, | ||
| // this overhead is unnecessary if you can process the data in chunks. | ||
| // Use MultipartReader to stream directly — e.g., to a final storage location — | ||
| // without buffering the entire file first. | ||
|
|
||
| app.MapPost("/upload-stream", | ||
| [DisableRequestSizeLimit] | ||
| async (HttpContext context) => | ||
| { | ||
| // Extract the multipart boundary from the Content-Type header | ||
| var contentType = context.Request.ContentType; | ||
| if (contentType == null) | ||
| return Results.BadRequest("Missing Content-Type"); | ||
|
|
||
| // Safely parse the Content-Type header to avoid FormatException from MediaTypeHeaderValue.Parse | ||
| if (!MediaTypeHeaderValue.TryParse(contentType, out var mediaType)) | ||
| return Results.BadRequest("Invalid Content-Type"); | ||
|
|
||
| var boundary = HeaderUtilities.RemoveQuotes(mediaType.Boundary).Value; | ||
| if (string.IsNullOrWhiteSpace(boundary)) | ||
| return Results.BadRequest("Not a multipart request"); | ||
|
|
||
| var reader = new MultipartReader(boundary, context.Request.Body); | ||
|
|
||
| // CRITICAL: ReadNextSectionAsync returns null when there are no more sections | ||
| while (await reader.ReadNextSectionAsync() is { } section) | ||
| { | ||
| // Parse Content-Disposition to identify file sections | ||
| if (!ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var contentDisposition)) | ||
| continue; | ||
|
|
||
| if (contentDisposition.DispositionType.Equals("form-data") | ||
|
ViktorHofer marked this conversation as resolved.
|
||
| && !string.IsNullOrEmpty(contentDisposition.FileName.Value)) | ||
| { | ||
| // Sanitize the user-provided filename to prevent path traversal | ||
| var originalFileName = contentDisposition.FileName.Value ?? string.Empty; | ||
| var sanitizedFileName = Path.GetFileName(originalFileName.Trim('"')); | ||
| var safeFile = $"{Guid.NewGuid()}"; | ||
|
ViktorHofer marked this conversation as resolved.
|
||
|
|
||
| // CRITICAL: Stream directly to disk — avoids buffering in memory | ||
| Directory.CreateDirectory("uploads"); | ||
| using var fileStream = File.Create(Path.Combine("uploads", safeFile)); | ||
| await section.Body.CopyToAsync(fileStream); | ||
| } | ||
| } | ||
|
|
||
| return Results.Ok("Uploaded"); | ||
| }).DisableAntiforgery(); | ||
|
|
||
| // COMMON MISTAKE: Using IFormFile for very large files | ||
| // Multipart form parsing can buffer large uploads and consume memory/disk. | ||
| // Use MultipartReader for streaming directly to storage. | ||
| ``` | ||
|
|
||
| ## Common Mistakes | ||
|
|
||
| 1. **Only configuring one size limit**: Must configure BOTH Kestrel `MaxRequestBodySize` AND `FormOptions.MultipartBodyLengthLimit`. | ||
| 2. **400 errors from anti-forgery**: In .NET 8+, `UseAntiforgery()` auto-validates form uploads. Use `.DisableAntiforgery()` for API endpoints (safe for JWT/unauthenticated; do NOT disable for cookie-authenticated endpoints). | ||
| 3. **Trusting file.FileName**: User-provided filename can contain path traversal. Generate a safe filename with `Guid.NewGuid()` and derive the extension from validated content. | ||
| 4. **Trusting Content-Type only**: Content type is client-spoofable. Always check magic bytes for actual file type verification. | ||
| 5. **Using IFormFile for very large files**: Multipart form parsing buffers with a memory threshold and spills to temp files. Use `MultipartReader` to stream data in chunks directly to storage without buffering the entire file. | ||
| 6. **Deriving file extension from user input**: Prefer deriving the extension from the validated content type or magic bytes rather than `Path.GetExtension(file.FileName)`. If the original extension must be preserved, validate it against the detected content type. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| scenarios: | ||
| - name: "Implement secure file upload in ASP.NET Core 8 minimal API" | ||
| prompt: | | ||
|
ViktorHofer marked this conversation as resolved.
|
||
| I need to implement a file upload endpoint in my ASP.NET Core 8 minimal API. | ||
| The endpoint should accept image files (JPEG and PNG only), reject files over 10MB, | ||
| and save them to an "uploads" folder. My app already has UseAntiforgery() in the pipeline. | ||
| Show me the complete implementation including size limits configuration and the endpoint. | ||
| assertions: | ||
| - type: "output_matches" | ||
| pattern: "(IFormFile|IFormFileCollection)" | ||
| - type: "output_matches" | ||
| pattern: "(Guid\\.NewGuid|Path\\.GetRandomFileName)" | ||
| rubric: | ||
| - "Configures both the Kestrel request body size limit and the form multipart body length limit — not just one of them" | ||
| - "Handles the antiforgery middleware that would otherwise reject the upload with a 400 error" | ||
| - "Does not save to disk using the original user-provided filename — generates a safe name to prevent path traversal" | ||
| - "Verifies the actual file content (e.g., magic bytes or file signatures), not just the Content-Type header which can be spoofed" | ||
| timeout: 180 | ||
|
|
||
| - name: "Upload multiple files with metadata in minimal API" | ||
| prompt: | | ||
| Show me how to add a file upload endpoint to an ASP.NET Core 8 minimal API | ||
| that accepts multiple image files along with a text description field from | ||
| the same form submission. The total size can be up to 50MB. Just show me | ||
| the code — I don't need a full project. | ||
| assertions: | ||
| - type: "output_matches" | ||
| pattern: "(IFormFileCollection|IFormFile|List<IFormFile>)" | ||
| - type: "output_matches" | ||
| pattern: "(MaxRequestBodySize|MultipartBodyLengthLimit)" | ||
| rubric: | ||
| - "Configures both the Kestrel request body size limit and the form multipart body length limit for the 50MB requirement" | ||
| - "Uses [FromForm] correctly when mixing file parameters with non-file form fields like description" | ||
| - "Does not save uploaded files using the original user-provided filenames" | ||
| timeout: 180 | ||
|
|
||
| - name: "Stream very large file uploads without buffering" | ||
| prompt: | | ||
| I need to accept uploads of video files up to 2GB in my ASP.NET Core 8 minimal API. | ||
| Using IFormFile crashes with out-of-memory errors for files over 500MB. | ||
| How do I stream large uploads directly to disk without buffering the whole file? | ||
| assertions: | ||
| - type: "output_matches" | ||
| pattern: "(MultipartReader|ReadNextSectionAsync)" | ||
| - type: "output_matches" | ||
| pattern: "(DisableRequestSizeLimit|MaxRequestBodySize)" | ||
| rubric: | ||
| - "Uses MultipartReader to stream upload sections directly to disk instead of buffering via IFormFile" | ||
| - "Disables or increases the request size limit to accommodate 2GB files" | ||
| - "Does not use the user-provided filename directly when saving streamed sections to disk" | ||
| timeout: 180 | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.