A user reported that a PDF would not upload. The message said only PDFs are allowed. The file was a PDF. The user had renamed it, and the new name had no .pdf at the end.
The setup is a common one: a browser client, an ASP.NET Core API behind Azure Front Door, and Azure Blob Storage for the files. The uploads passed every test we had written. While I followed this one report, I found three places where an upload can fail because of its file name. I call them gates here. Two of them failed with a symptom that pointed somewhere else, and the third would have once the WAF blocks.
Gate 1: the browser and the API trust the file name
The client checked file.type === 'application/pdf' before it sent anything. The browser derives File.type from the file extension and does not read the bytes, so that check never looks at the content. You can see it with a file input and the same PDF under two names:
input.addEventListener("change", () => { const file = input.files![0]; console.log(file.name, JSON.stringify(file.type));});
// report.pdf "application/pdf"// report ""The renamed file has an empty type, or application/octet-stream.
The server does not correct this. The Content-Type of the multipart part is the browser’s guess, so a server check on that content type refuses the file too.
On the server there were two checks. The handler had its own PDF check. In front of it was a content-type check at the endpoint level, and it answered 415 before the handler ran. If I had only fixed the handler, the request would still have been refused.
Decide by the content
A PDF starts with the bytes %PDF-. Both sides now read the first five bytes and decide by them.
Client:
async function isPdf(file: File): Promise<boolean> { const head = new Uint8Array(await file.slice(0, 5).arrayBuffer()); return new TextDecoder("ascii").decode(head) === "%PDF-";}Server:
static async Task<bool> IsPdfAsync(Stream content, CancellationToken ct){ byte[] head = new byte[5]; int read = await content.ReadAtLeastAsync(head, 5, throwOnEndOfStream: false, ct); content.Position = 0; return read == 5 && head.AsSpan().SequenceEqual("%PDF-"u8);}Once the server has decided that the file is a PDF, it stores and serves it as application/pdf, whatever type the browser sent.
Three tests cover this:
- A PDF without an extension is accepted.
- A PDF named
.txtis accepted. - A text file named
.pdfis refused.
Two more places depended on the file name:
<input accept="application/pdf,.pdf">greys out the renamed file in the file picker, unless the user switches the picker to “all files”. Where the content decides, theacceptfilter has to go.- The stored name of such a file has no
.pdf. The download now appends it when it is missing, so the file opens on the user’s machine.
Gate 2: a 500 that looked transient
While I searched the logs for the refused PDF, I found a 500 on the same upload endpoint from a few weeks earlier. The PDF check was not involved. The request ran for about 27 seconds and then failed. The outer exception was an AggregateException with the message “Retry failed after 6 tries”.
A request that fails after retries looks like a storage or network problem, something that goes away. This one would have failed every time. The cause was in the inner exceptions:
HttpRequestException: Request headers must contain only ASCII characters.The upload wrote the user’s original file name into the blob metadata:
Metadata = new Dictionary<string, string> { ["originalFileName"] = fileName }Blob metadata travels as x-ms-meta-* HTTP request headers. The file name contained an umlaut. .NET’s HttpClient refuses to send a header with a non-ASCII value, so the request never reached Storage. The Azure SDK treated the exception as retryable and tried six times. That is where the 27 seconds came from. The file size had nothing to do with it.
In Application Insights, the outer exception only says that the retries failed. This query joins the failed requests to their exceptions:
requests| where name startswith "POST /api/..." and toint(resultCode) >= 500| join kind=leftouter (exceptions | project operation_Id, type, outerMessage) on operation_Id| project timestamp, resultCode, type, outerMessageThen open the exception and expand the inner exceptions.
Keep user strings out of headers
There are two ways to fix it:
- Don’t write the name into the metadata. Keep the display name in the database. Often nothing reads that metadata back, and then this is the simpler fix.
- If the name has to be on the blob, encode it on write and decode it on read:
BlobUploadOptions options = new(){ HttpHeaders = new BlobHttpHeaders { ContentType = contentType }, // Only ASCII values here; encode if a user string must go in: Metadata = new Dictionary<string, string> { ["originalFileName"] = Uri.EscapeDataString(fileName), },};The same fix applies to every other place that writes a user-supplied string into metadata. A contract test that uploads a file named Prüfung ß.pdf, for example against Azurite, catches the ones you miss.
Gate 3: the WAF would block ordinary uploads
Before shipping the fix for gate 1, I wanted to know whether the edge would let these uploads through. Front Door Premium ran a WAF policy with the Microsoft Default Rule Set (DRS) 2.1. The managed rules were in log-only mode, and the plan was to switch them to Block.
In log-only mode nothing is blocked, so every manual test passes the edge. That says nothing about Block mode. The only way to know is to read the WAF log.
The log showed a different problem. Most ordinary browser uploads matched two rules, renamed or not:
- General-200002, failed to parse the request body
- General-200003, multipart request body failed strict validation
In our logs, these uploads also matched the anomaly rule 949110, the one that blocks a request once the rule set action is Block. According to the DRS documentation, both rules have the severity Critical with a score of 5, and the WAF acts on a request at an anomaly score of 5 or more.
The numbers below are illustrative, but the proportions are what we saw. ID photos always matched, documents about half the time.
| Upload endpoint | Uploads | Would block |
|---|---|---|
| ID photos | 40 | 40 |
| Documents | 120 | 58 |
The policy already had exclusions for the upload. The form field file was excluded through RequestBodyPostArgNames for the SQL injection, XSS and protocol enforcement rule groups. They didn’t help, for two reasons:
- A field exclusion covers the value of the field for the listed rule groups. It does not cover body parsing, which is what 200002 and 200003 are about.
- It does not cover the multipart file name either. An apostrophe in a file name matched 920120 and 920121 on
MultipartFileName:file.
This query counts, per upload route, how many POSTs reached 949110. Run it against the Log Analytics workspace that receives the Front Door diagnostics:
let waf = AzureDiagnostics | where TimeGenerated > ago(30d) and Category == 'FrontDoorWebApplicationFirewallLog' | where ruleName_s endswith '949110' | distinct trackingReference_s;AzureDiagnostics| where TimeGenerated > ago(30d) and Category == 'FrontDoorAccessLog'| where httpMethod_s == 'POST'| extend path = tostring(parse_url(requestUri_s).Path)| where path matches regex @'/(documents|upload)$' // your upload routes| extend p = replace_regex(path, @'[0-9a-fA-F-]{36}', '{id}')| summarize total = count(), wouldBlock = countif(trackingReference_s in (waf)) by pTo see which rules scored a request, group FrontDoorWebApplicationFirewallLog by ruleName_s and details_matches_s for the upload paths.
We have not switched to Block yet. Before we do, three things have to happen:
- Narrow overrides for 200002 and 200003, on the upload paths only.
- A decision on file names with special characters such as an apostrophe.
- The same query again, against real traffic, until the upload routes show no request that would be blocked.
The file name is user input in three layers
All three failures came from the same value. The file name is a string the user controls, and it reached three layers:
- Validation: the browser derived the type from it, and the API trusted that type.
- Transport: it went into an HTTP header as blob metadata.
- Inspection: the WAF checked it as part of the multipart body.
Our tests passed all three gates. Real users rename files, use umlauts, and put apostrophes in names.
What I check now on an upload flow:
- The content decides the type, on the client and on the server.
- No user string goes into a header or into blob metadata without encoding.
- The WAF log for the upload paths is reviewed before the rule set is switched to Block.
- The tests include file names without an extension, with umlauts, with
ß,&,#and an apostrophe.
