Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
]
}
4 changes: 4 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .github/plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
]
}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions plugins/dotnet-aspnet/plugin.json
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/"]
}
Comment thread
ViktorHofer marked this conversation as resolved.
234 changes: 234 additions & 0 deletions plugins/dotnet-aspnet/skills/minimal-api-file-upload/SKILL.md
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 |

Comment thread
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");

Comment thread
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.");

Comment thread
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");
Comment thread
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")
Comment thread
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()}";
Comment thread
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.
51 changes: 51 additions & 0 deletions tests/dotnet-aspnet/minimal-api-file-upload/eval.yaml
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: |
Comment thread
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