fullstackhero/dotnet-starter-kit · error · CustomException

uploaded content-type mismatch

Error message

uploaded content-type mismatch

What it means

Finalize compares the Content-Type reported by the storage HEAD request against the content type declared when the upload was requested (case-insensitive). On mismatch the handler deletes the uploaded blob, removes the FileAsset row, and throws CustomException with HTTP 400 BadRequest — the stored bytes cannot be trusted to match the registered media type.

Solutions

  1. Set the exact same Content-Type header on the upload PUT as was declared when creating the upload request
  2. Re-create the upload request with the content type your client actually sends
  3. Sniff the file's MIME type client-side before requesting the upload and pass it consistently through both steps
  4. Check for proxies/gateways stripping or rewriting the Content-Type header

Example fix

// before
await fetch(uploadUrl, { method: 'PUT', body: file });
// after
await fetch(uploadUrl, { method: 'PUT', body: file,
  headers: { 'Content-Type': file.type } }); // must match declared contentType
Defensive patterns

Strategy: validation

Validate before calling

if (file.type !== declaredContentType) {
  throw new Error(`content-type ${file.type} does not match declared ${declaredContentType}`);
}

Try / catch

try { await api.finalizeUpload(assetId); }
catch (e) { if (e.status === 400 && e.message === 'uploaded content-type mismatch') { await requestNewUploadWith(file.type); } else throw e; }

Prevention

When it happens

Trigger: The client PUT the object with a Content-Type header different from the one bound into the presigned upload (e.g. browser defaulting to application/octet-stream, SDK omitting the header, or a proxy rewriting it).

Common situations: fetch/XHR uploads defaulting Content-Type when none is set; presign flow signed one content type but the uploader sent another (S3 then stores or rejects differently); MinIO storing application/octet-stream when the header was missing; upload code copied from a generic uploader that hardcodes a MIME type.

Related errors


AI-assisted analysis of fullstackhero/dotnet-starter-kit@3f2959e683 (2026-09-15). Data as JSON: /api/errors/446981f0d1c36d65. Report an issue: GitHub.

Appendix: source

Thrown at src/Modules/Files/Modules.Files/Features/v1/FinalizeUpload/FinalizeUploadCommandHandler.cs:71

        // Allow declared+1% slack (S3 may differ slightly on multipart). Reject larger sizes.
        var maxAllowed = asset.SizeBytes + Math.Max(1024L, asset.SizeBytes / 100);
        if (head.SizeBytes > maxAllowed)
        {
            await storage.RemoveAsync(asset.StorageKey, cancellationToken).ConfigureAwait(false);
            db.FileAssets.Remove(asset);
            await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
            throw new CustomException(
                $"uploaded size ({head.SizeBytes}) exceeds declared ({asset.SizeBytes})",
                (IEnumerable<string>?)null,
                HttpStatusCode.BadRequest);
        }

        if (!string.Equals(head.ContentType, asset.ContentType, StringComparison.OrdinalIgnoreCase))
        {
            await storage.RemoveAsync(asset.StorageKey, cancellationToken).ConfigureAwait(false);
            db.FileAssets.Remove(asset);
            await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
            throw new CustomException(
                "uploaded content-type mismatch",
                (IEnumerable<string>?)null,
                HttpStatusCode.BadRequest);
        }

        var scanResult = await scanner.ScanAsync(asset.StorageKey, cancellationToken).ConfigureAwait(false);
        asset.MarkAvailable(head.SizeBytes, scanResult);

        // Debit quota with the actual bytes. Refunded on hard purge by PurgeDeletedFilesJob.
        await quotas.RecordAsync(tenantId, QuotaResource.StorageBytes, head.SizeBytes, cancellationToken).ConfigureAwait(false);

        await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

        var correlationId = Activity.Current?.Id ?? Guid.NewGuid().ToString();

        // Outbox rather than the bus: a crash between the SaveChanges above and delivery would
        // otherwise leave the file marked available with no consumer ever told about it.
        await outbox.AddAsync(new FileFinalizedIntegrationEvent(

View on GitHub (pinned to 3f2959e683)