{
  "schema": "https://ai-atoms.com/schemas/skill-v1.json",
  "type": "skill",
  "id": "skill/minimal-api-file-upload",
  "version": "1.0.0",
  "name": "minimal-api-file-upload",
  "description": "File upload endpoints in ASP.NET minimal APIs (.NET 8+)",
  "system_prompt_fragment": "# Implementing File Uploads in ASP.NET Core Minimal APIs\n\n## When to Use\n- File upload endpoints in ASP.NET Core minimal APIs (.NET 8+)\n- Handling IFormFile or IFormFileCollection parameters\n- When you need size limits, content type validation, or streaming large files\n\n## When Not to Use\n- MVC controllers → `[FromForm] IFormFile` works directly with attributes\n- Simple JSON body → no file upload needed\n- Very large files (> 1GB) → use streaming with `MultipartReader` instead\n\n## Inputs\n\n| Input | Required | Description |\n|-------|----------|-------------|\n| File parameter(s) | Yes | IFormFile or IFormFileCollection |\n| Size limits | Yes | Max file/request size |\n| Allowed types | No | Content type or extension restrictions |\n\n## Workflow\n\n### Step 1: CRITICAL — Understand IFormFile Binding in Minimal APIs\n\n```csharp\n// In .NET 8+ minimal APIs, IFormFile binds automatically from multipart/form-data\n// when it is the only complex parameter.\napp.MapPost(\"/upload\", (IFormFile file) => ...);\n\n// CRITICAL: When you mix files with other form fields, use [FromForm] on all\n// form-bound parameters (or group them into a single [FromForm] DTO).\napp.MapPost(\"/upload-with-metadata\",\n    ([FromForm] IFormFile file, [FromForm] string description) =>\n{\n    return Results.Ok(new { file.FileName, Description = description });\n});\n\n// Multiple files: IFormFileCollection also binds automatically from multipart/form-data.\n// You only need [FromForm] if you mix it with other form fields, as shown above.\napp.MapPost(\"/upload-multiple\", (IFormFileCollection files) =>\n{\n    return Results.Ok(files.Select(f => new { f.FileName, f.Length }));\n});\n```\n\n### Step 2: CRITICAL — File Size Limits Are Separate from Request Size Limits\n\n```csharp\n// CRITICAL: There are TWO different size limits and you need to configure BOTH\n\n// 1. Request body size limit (Kestrel level) — default is 30MB\nbuilder.WebHost.ConfigureKestrel(options =>\n{\n    options.Limits.MaxRequestBodySize = 10 * 1024 * 1024; // 10 MB\n});\n\n// 2. Form options — multipart body length limit — default is 128MB\nbuilder.Services.Configure<FormOptions>(options =>\n{\n    options.MultipartBodyLengthLimit = 10 * 1024 * 1024; // 10 MB\n    options.ValueLengthLimit = 1024 * 1024; // 1 MB for form values\n    options.MultipartHeadersLengthLimit = 16384; // 16 KB for section headers\n});\n\n// COMMON MISTAKE: Only increasing Kestrel MaxRequestBodySize\n// upload still fails because FormOptions.MultipartBodyLengthLimit is exceeded\n\n// COMMON MISTAKE: Only increasing FormOptions\n// upload fails with \"Request body too large\" from Kestrel before reaching form parsing\n\n// CRITICAL: Per-endpoint override with RequestSizeLimit attribute\napp.MapPost(\"/upload-large\", [RequestSizeLimit(200_000_000)] (IFormFile file) =>\n{\n    return Results.Ok(new { file.FileName, file.Length });\n});\n\n// CRITICAL: To disable the limit entirely (for streaming):\napp.MapPost(\"/upload-unlimited\", [DisableRequestSizeLimit] async (HttpContext context) =>\n{\n    // Handle manually\n});\n```\n\n### Step 3: CRITICAL — Anti-Forgery Auto-Validates Form Uploads in .NET 8+\n\n```csharp\n// CRITICAL: In .NET 8+ with UseAntiforgery(), ALL form-bound endpoints\n// automatically validate anti-forgery tokens, INCLUDING file uploads\n\nbuilder.Services.AddAntiforgery();\nvar app = builder.Build();\napp.UseAntiforgery();\n\n// This endpoint now REQUIRES an anti-forgery token:\napp.MapPost(\"/upload\", (IFormFile file) => Results.Ok(file.FileName));\n// Without the token → 400 Bad Request\n\n// CRITICAL: For API-only file uploads (no anti-forgery needed), opt out:\napp.MapPost(\"/api/upload\", (IFormFile file) => Results.Ok(file.FileName))\n    .DisableAntiforgery();  // CRITICAL: Must explicitly opt out\n\n// COMMON MISTAKE: Getting 400 errors on file uploads and not realizing\n// it's because UseAntiforgery() is in the pipeline\n\n// WARNING: DisableAntiforgery() is safe for unauthenticated endpoints and\n// endpoints using JWT bearer authentication. However, for endpoints\n// authenticated with cookies, disabling antiforgery removes CSRF protection\n// and exposes the endpoint to cross-site request forgery attacks.\n// For cookie-authenticated endpoints, include a valid antiforgery token instead.\n```\n\n### Step 4: CRITICAL — Validate File Content, Not Just Extension\n\n```csharp\napp.MapPost(\"/upload\", async (IFormFile file) =>\n{\n    // CRITICAL: Check content type AND file signature (magic bytes)\n    // NEVER trust file extension alone — it can be spoofed\n\n    // Allow only JPEG/PNG by default. To support more (e.g., GIF),\n    // add the MIME type here AND validate its magic bytes below.\n    var allowedTypes = new[] { \"image/jpeg\", \"image/png\" };\n    if (!allowedTypes.Contains(file.ContentType, StringComparer.OrdinalIgnoreCase))\n        return Results.BadRequest(\"File type not allowed\");\n\n    // CRITICAL: Check magic bytes for file type verification\n    using var stream = file.OpenReadStream();\n    var header = new byte[8];\n    var bytesRead = await stream.ReadAsync(header, 0, header.Length);\n    if (bytesRead < 4)\n        return Results.BadRequest(\"File content is too short or invalid\");\n\n    // JPEG: FF D8 FF\n    // PNG: 89 50 4E 47\n    var isJpeg = header[0] == 0xFF && header[1] == 0xD8 && header[2] == 0xFF;\n    var isPng = header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47;\n\n    // Determine the actual content type from magic bytes\n    string? detectedContentType = isJpeg ? \"image/jpeg\" : isPng ? \"image/png\" : null;\n    if (detectedContentType is null)\n        return Results.BadRequest(\"File content is not a supported image format (only JPEG and PNG are allowed).\");\n\n    // Ensure the declared Content-Type matches what the magic bytes detected\n    if (!string.Equals(file.ContentType, detectedContentType, StringComparison.OrdinalIgnoreCase))\n        return Results.BadRequest(\"File content type does not match the declared ContentType header.\");\n\n    // CRITICAL: Never use the user-provided filename directly for the save path — it can\n    // contain path traversal characters (e.g., \"../../../etc/passwd\").\n    // Generate a safe filename; derive the extension from validated content, not user input.\n    var extension = detectedContentType == \"image/jpeg\" ? \".jpg\" : \".png\";\n    var safeFileName = $\"{Guid.NewGuid()}{extension}\";\n    // NEVER: var path = Path.Combine(\"uploads\", file.FileName);     // Path traversal!\n\n    var filePath = Path.Combine(\"uploads\", safeFileName);\n    Directory.CreateDirectory(\"uploads\");\n    stream.Position = 0;\n    using var fileStream = File.Create(filePath);\n    await stream.CopyToAsync(fileStream);\n\n    return Results.Ok(new { FileName = safeFileName, file.Length });\n});\n```\n\n### Step 5: CRITICAL — Streaming Large Files Without Buffering\n\n```csharp\n// CRITICAL: IFormFile relies on multipart form parsing that buffers content in memory\n// (up to a threshold) then spills to temp files on disk. For very large uploads,\n// this overhead is unnecessary if you can process the data in chunks.\n// Use MultipartReader to stream directly — e.g., to a final storage location —\n// without buffering the entire file first.\n\napp.MapPost(\"/upload-stream\",\n    [DisableRequestSizeLimit]\n    async (HttpContext context) =>\n{\n    // Extract the multipart boundary from the Content-Type header\n    var contentType = context.Request.ContentType;\n    if (contentType == null)\n        return Results.BadRequest(\"Missing Content-Type\");\n\n    // Safely parse the Content-Type header to avoid FormatException from MediaTypeHeaderValue.Parse\n    if (!MediaTypeHeaderValue.TryParse(contentType, out var mediaType))\n        return Results.BadRequest(\"Invalid Content-Type\");\n\n    var boundary = HeaderUtilities.RemoveQuotes(mediaType.Boundary).Value;\n    if (string.IsNullOrWhiteSpace(boundary))\n        return Results.BadRequest(\"Not a multipart request\");\n\n    var reader = new MultipartReader(boundary, context.Request.Body);\n\n    // CRITICAL: ReadNextSectionAsync returns null when there are no more sections\n    while (await reader.ReadNextSectionAsync() is { } section)\n    {\n        // Parse Content-Disposition to identify file sections\n        if (!ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out var contentDisposition))\n            continue;\n\n        if (contentDisposition.DispositionType.Equals(\"form-data\")\n            && !string.IsNullOrEmpty(contentDisposition.FileName.Value))\n        {\n            // Sanitize the user-provided filename to prevent path traversal\n            var originalFileName = contentDisposition.FileName.Value ?? string.Empty;\n            var sanitizedFileName = Path.GetFileName(originalFileName.Trim('\"'));\n            var safeFile = $\"{Guid.NewGuid()}\";\n\n            // CRITICAL: Stream directly to disk — avoids buffering in memory\n            Directory.CreateDirectory(\"uploads\");\n            using var fileStream = File.Create(Path.Combine(\"uploads\", safeFile));\n            await section.Body.CopyToAsync(fileStream);\n        }\n    }\n\n    return Results.Ok(\"Uploaded\");\n}).DisableAntiforgery();\n\n// COMMON MISTAKE: Using IFormFile for very large files\n// Multipart form parsing can buffer large uploads and consume memory/disk.\n// Use MultipartReader for streaming directly to storage.\n```\n\n## Common Mistakes\n\n1. **Only configuring one size limit**: Must configure BOTH Kestrel `MaxRequestBodySize` AND `FormOptions.MultipartBodyLengthLimit`.\n2. **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).\n3. **Trusting file.FileName**: User-provided filename can contain path traversal. Generate a safe filename with `Guid.NewGuid()` and derive the extension from validated content.\n4. **Trusting Content-Type only**: Content type is client-spoofable. Always check magic bytes for actual file type verification.\n5. **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.\n6. **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.",
  "applicable_domains": [
    "code",
    "dotnet",
    "engineering"
  ],
  "invocation": [
    "/minimal-api-file-upload"
  ],
  "tags": [
    "dotnet-aspnet",
    "dotnet",
    "csharp",
    "microsoft"
  ],
  "authored_by": "anthropics",
  "source_url": "https://github.com/dotnet/skills/blob/main/plugins/dotnet-aspnet/skills/minimal-api-file-upload/SKILL.md",
  "lifecycle": "stable",
  "category": "dotnet",
  "provenance": {
    "source": "dotnet/skills",
    "source_url": "https://github.com/dotnet/skills/blob/main/plugins/dotnet-aspnet/skills/minimal-api-file-upload/SKILL.md",
    "author": "Microsoft / .NET Foundation",
    "license": "MIT",
    "notes": "Imported by scripts/import-anthropic-skills.py."
  }
}