Skip to content
Closed
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
196 changes: 196 additions & 0 deletions src/dotnet/skills/minimal-api-file-upload/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
---
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 — IFormFile Requires [FromForm] in Minimal APIs (Not Automatic)

```csharp
// COMMON MISTAKE: Expecting IFormFile to bind automatically
app.MapPost("/upload", (IFormFile file) => ...);
// In early .NET versions, this worked differently. In .NET 8:

// CRITICAL: IFormFile IS bound automatically from form data in .NET 8
// BUT when you mix IFormFile with other parameters, you need [FromForm]
app.MapPost("/upload-with-metadata",
([FromForm] IFormFile file, [FromForm] string description) =>
{
return Results.Ok(new { file.FileName, Description = description });
});

// CRITICAL: For multiple files, use IFormFileCollection
Comment on lines +28 to +43

Copilot AI Mar 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Step 1 is internally contradictory: it’s titled as if IFormFile requires [FromForm] (not automatic), but later in this section it states IFormFile is bound automatically in .NET 8. Please rewrite this section to present one clear rule (e.g., IFormFile binds from multipart automatically, but mixed parameters should be annotated with [FromForm] or grouped).

Suggested change
### Step 1: CRITICAL — IFormFile Requires [FromForm] in Minimal APIs (Not Automatic)
```csharp
// COMMON MISTAKE: Expecting IFormFile to bind automatically
app.MapPost("/upload", (IFormFile file) => ...);
// In early .NET versions, this worked differently. In .NET 8:
// CRITICAL: IFormFile IS bound automatically from form data in .NET 8
// BUT when you mix IFormFile with other parameters, you need [FromForm]
app.MapPost("/upload-with-metadata",
([FromForm] IFormFile file, [FromForm] string description) =>
{
return Results.Ok(new { file.FileName, Description = description });
});
// CRITICAL: For multiple files, use IFormFileCollection
### Step 1: CRITICAL — Understand IFormFile Binding and [FromForm] in Minimal APIs
```csharp
// In .NET 8 minimal APIs, IFormFile is bound automatically from multipart/form-data
// when it is the only body parameter.
app.MapPost("/upload", (IFormFile file) => ...);
// When you mix files with other form fields, annotate the body-bound parameters
// with [FromForm] (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 });
});
// For 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.

Copilot uses AI. Check for mistakes.
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 = 100 * 1024 * 1024; // 100 MB
});

// 2. Form options — multipart body length limit — default is 128MB
builder.Services.Configure<FormOptions>(options =>
{
options.MultipartBodyLengthLimit = 100 * 1024 * 1024; // 100 MB
Comment on lines +58 to +64

Copilot AI Mar 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These examples set the global Kestrel request body limit to 100MB, which can be confusing given the scenario is about enforcing a 10MB maximum. Consider using 10MB in the sample (or explicitly explain why the global limit is higher than the per-endpoint limit).

Suggested change
options.Limits.MaxRequestBodySize = 100 * 1024 * 1024; // 100 MB
});
// 2. Form options — multipart body length limit — default is 128MB
builder.Services.Configure<FormOptions>(options =>
{
options.MultipartBodyLengthLimit = 100 * 1024 * 1024; // 100 MB
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

Copilot uses AI. Check for mistakes.
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
```
Comment on lines +88 to +108

@halter73 halter73 Mar 4, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GrabYourPitchforks @blowdart This should be okay for unauthenticated endpoints and endpoints using JWT bearer authentication, but I worry that this might cause people to disable antiforgery for endpoints authenticated with cookies. I wonder what the best way to communicate this potential security threat in the skill document.


### 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

var allowedTypes = new[] { "image/jpeg", "image/png", "image/gif" };

Copilot AI Mar 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The allowed MIME types include image/gif, but the scenario/rubric for this skill is JPEG + PNG only. Including GIF here makes the example inconsistent with the stated requirements and may cause models to permit additional types.

Suggested change
var allowedTypes = new[] { "image/jpeg", "image/png", "image/gif" };
var allowedTypes = new[] { "image/jpeg", "image/png" };

Copilot uses AI. Check for mistakes.
if (!allowedTypes.Contains(file.ContentType))
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];
await stream.ReadAsync(header, 0, 8);

Copilot AI Mar 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When reading magic bytes, the code ignores the return value from ReadAsync. For empty/short files this leaves parts of header as zeroes and can misclassify content. Capture the bytes-read and fail fast if fewer than the required bytes are available (e.g., < 4 for PNG, < 3 for JPEG).

Suggested change
await stream.ReadAsync(header, 0, 8);
var bytesRead = await stream.ReadAsync(header, 0, header.Length);
if (bytesRead < 4)
return Results.BadRequest("File content is too short or invalid");

Copilot uses AI. Check for mistakes.
stream.Position = 0;

// 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;

if (!isJpeg && !isPng)
return Results.BadRequest("File content doesn't match declared type");

// CRITICAL: Generate a safe filename — never use user-provided filename directly
var safeFileName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
// NEVER: var path = Path.Combine("uploads", file.FileName); // Path traversal!
Comment on lines +136 to +138

Copilot AI Mar 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The “safe filename” still uses Path.GetExtension(file.FileName), which derives the extension from user-controlled input. Since the skill emphasizes not trusting client metadata, prefer choosing the extension based on the validated signature (map magic bytes to ".jpg"/".png") or omit the original extension entirely.

Copilot uses AI. Check for mistakes.

var filePath = Path.Combine("uploads", safeFileName);
Directory.CreateDirectory("uploads");
using var fileStream = File.Create(filePath);
await file.CopyToAsync(fileStream);

return Results.Ok(new { FileName = safeFileName, file.Length });
});
```

### Step 5: CRITICAL — Streaming Large Files Without Buffering

```csharp
// CRITICAL: IFormFile buffers the entire file in memory by default
// For large files, use MultipartReader for streaming
Comment on lines +152 to +153

Copilot AI Mar 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The statement that “IFormFile buffers the entire file in memory by default” is inaccurate for ASP.NET Core: multipart parsing uses buffering with a memory threshold and typically spills to a temp file. Please reword to the actual risk (request/form parsing can buffer large uploads and consume memory/disk) so the guidance is technically correct.

Copilot uses AI. Check for mistakes.

app.MapPost("/upload-stream",
[DisableRequestSizeLimit]
async (HttpContext context) =>
{
var boundary = context.Request.GetMultipartBoundary();
if (string.IsNullOrEmpty(boundary))
return Results.BadRequest("Not a multipart request");

var reader = new MultipartReader(boundary, context.Request.Body);
Comment on lines +159 to +163

Copilot AI Mar 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This example uses context.Request.GetMultipartBoundary(), but that helper isn’t defined anywhere in this repo and isn’t a built-in ASP.NET Core API. Please include the helper implementation (or switch to the standard boundary parsing approach) so the snippet is complete/compilable.

Copilot uses AI. Check for mistakes.

// CRITICAL: ReadNextSectionAsync returns null when there are no more sections
while (await reader.ReadNextSectionAsync() is { } section)
{
var contentDisposition = section.GetContentDispositionHeader();
if (contentDisposition == null) continue;

if (contentDisposition.IsFileDisposition())
{
Comment on lines +168 to +172

Copilot AI Mar 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

section.GetContentDispositionHeader() / contentDisposition.IsFileDisposition() also aren’t built-in APIs and aren’t defined in this repo. Either provide these helpers or show the canonical parsing with ContentDispositionHeaderValue so readers can use the sample as-is.

Copilot uses AI. Check for mistakes.
var fileName = contentDisposition.FileName.Value;
var safeFile = $"{Guid.NewGuid()}{Path.GetExtension(fileName)}";

// CRITICAL: Stream directly to disk — never buffer in memory
using var fileStream = File.Create(Path.Combine("uploads", safeFile));
await section.Body.CopyToAsync(fileStream);
}
}

return Results.Ok("Uploaded");
}).DisableAntiforgery();

// COMMON MISTAKE: Using file.CopyToAsync for very large files
// IFormFile buffers everything in memory first — can cause OutOfMemoryException
```

## 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.
3. **Trusting file.FileName**: User-provided filename can contain path traversal. Always generate a safe filename.
4. **Trusting Content-Type only**: Content type can be spoofed. Check magic bytes for actual file type.
5. **Using IFormFile for large files**: IFormFile buffers in memory. Use `MultipartReader` for streaming.
6. **Missing GetMultipartBoundary extension**: Must use `context.Request.GetMultipartBoundary()`, not parse manually.
14 changes: 14 additions & 0 deletions src/dotnet/tests/minimal-api-file-upload/eval.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
scenarios:
- name: file-upload-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.
rubric:
- "Configures BOTH Kestrel MaxRequestBodySize AND FormOptions.MultipartBodyLengthLimit — either using builder.WebHost.ConfigureKestrel or WebApplication options for the request body, AND services.Configure<FormOptions> for multipart limits. ONLY configuring one of these is INCORRECT."
- "Uses .DisableAntiforgery() on the file upload endpoint to prevent automatic 400 errors from the anti-forgery middleware, OR explicitly handles anti-forgery token submission. Simply ignoring the UseAntiforgery() pipeline issue is INCORRECT."
- "Generates a safe filename (e.g., using Guid.NewGuid() or similar) instead of using the user-provided IFormFile.FileName directly. Using file.FileName directly in file paths is a security vulnerability (path traversal). Sanitizing the user filename is acceptable but using it raw is INCORRECT."
- "Validates file content beyond just checking file extension — either checking ContentType against allowed MIME types, or checking magic bytes/file signatures, or both. Checking only the file extension is INSUFFICIENT because extensions can be spoofed."

Copilot AI Mar 2, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rubric criterion allows validating only ContentType (“either checking ContentType… or checking magic bytes…”), but the PR description explicitly calls out ContentType as client-spoofable and emphasizes magic-byte validation as a key gotcha. Tighten this rubric to require signature/magic-byte checking (at least for JPEG/PNG) so the eval enforces the intended lesson.

Copilot uses AI. Check for mistakes.
- "Correctly uses IFormFile parameter binding in the minimal API endpoint. The parameter should be IFormFile (not manually reading Request.Body or Request.Form). Must handle the case where the file exceeds size limits."
- "Uses [RequestSizeLimit] attribute or equivalent per-endpoint configuration for the 10MB limit, OR configures the limits globally. The size enforcement must happen at the correct layer — not just checking file.Length after the entire file has been buffered."