diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 7a38356554..17137fc7ae 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -53,6 +53,11 @@ "name": "dotnet-test", "source": "./plugins/dotnet-test", "description": "Skills for running, diagnosing, and migrating .NET tests: test execution, filtering, platform detection, and MSTest workflows." + }, + { + "name": "dotnet-aspnet", + "source": "./plugins/dotnet-aspnet", + "description": "ASP.NET Core web development skills including middleware, endpoints, real-time communication, and API patterns." } ] } \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 606417c48b..7c37dd13f0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -79,6 +79,10 @@ /plugins/dotnet-ai/skills/technology-selection/ @luisquintanilla @artl93 /tests/dotnet-ai/technology-selection/ @luisquintanilla @artl93 +# dotnet-aspnet (ASP.NET Core web development) +/plugins/dotnet-aspnet/ @BrennanConroy @adityamandaleeka @halter73 +/tests/dotnet-aspnet/ @BrennanConroy @adityamandaleeka @halter73 + # dotnet-data (data access, Entity Framework) /plugins/dotnet-data/skills/optimizing-ef-core-queries/ @dotnet/efteam /tests/dotnet-data/optimizing-ef-core-queries/ @dotnet/efteam diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 7a38356554..17137fc7ae 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -53,6 +53,11 @@ "name": "dotnet-test", "source": "./plugins/dotnet-test", "description": "Skills for running, diagnosing, and migrating .NET tests: test execution, filtering, platform detection, and MSTest workflows." + }, + { + "name": "dotnet-aspnet", + "source": "./plugins/dotnet-aspnet", + "description": "ASP.NET Core web development skills including middleware, endpoints, real-time communication, and API patterns." } ] } \ No newline at end of file diff --git a/README.md b/README.md index 7e14d680ee..28dcb6b57e 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ This repository contains the .NET team's curated set of core skills and custom a | [dotnet-ai](plugins/dotnet-ai/) | AI and ML skills for .NET: technology selection, LLM integration, agentic workflows, RAG pipelines, MCP, and classic ML with ML.NET. | | [dotnet-template-engine](plugins/dotnet-template-engine/) | .NET Template Engine skills: template discovery, project scaffolding, and template authoring. | | [dotnet-test](plugins/dotnet-test/) | Skills for running, diagnosing, and migrating .NET tests: test execution, filtering, platform detection, and MSTest workflows. | +| [dotnet-aspnet](plugins/dotnet-aspnet/) | ASP.NET Core web development skills including middleware, endpoints, real-time communication, and API patterns. | ## Installation diff --git a/plugins/dotnet-aspnet/plugin.json b/plugins/dotnet-aspnet/plugin.json new file mode 100644 index 0000000000..9f8e6ba2ae --- /dev/null +++ b/plugins/dotnet-aspnet/plugin.json @@ -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/"] +} diff --git a/plugins/dotnet-aspnet/skills/minimal-api-file-upload/SKILL.md b/plugins/dotnet-aspnet/skills/minimal-api-file-upload/SKILL.md new file mode 100644 index 0000000000..829980df04 --- /dev/null +++ b/plugins/dotnet-aspnet/skills/minimal-api-file-upload/SKILL.md @@ -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 | + +## 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(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"); + + // 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."); + + // 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"); + 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") + && !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()}"; + + // 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. diff --git a/tests/dotnet-aspnet/minimal-api-file-upload/eval.yaml b/tests/dotnet-aspnet/minimal-api-file-upload/eval.yaml new file mode 100644 index 0000000000..441097f87a --- /dev/null +++ b/tests/dotnet-aspnet/minimal-api-file-upload/eval.yaml @@ -0,0 +1,51 @@ +scenarios: + - name: "Implement secure file upload in ASP.NET Core 8 minimal API" + prompt: | + 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)" + - 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