WAF File Upload Scanning at CDN Edge: Blocking Malicious Files Before They Reach Origin

I've blocked enough reverse shells hidden in profile pictures to know file uploads are still the easiest way to own an origin. A couple years ago a customer's WordPress site got popped through an avatar upload that sailed right past their origin WAF because it only checked the Content-Type header. The file was a perfectly valid JPEG header with a PHP web shell glued to the end, and it sat on their server for three weeks before anyone noticed. That's exactly why we push scanning to the CDN edge now — before that payload ever touches disk on your infrastructure.

Why Origin-Only Scanning Leaves You Exposed

For a long time, the standard approach was to let the file land, then scan it on the origin host. Your app would accept the multipart POST, write it to /tmp, run ClamAV or a commercial scanner, and then decide what to do. It sounds reasonable until you've seen what happens at scale. I worked with a media platform handling about 40k uploads an hour where the origin scanners couldn't keep up during peak traffic. Files queued, disks filled, and a few malicious payloads executed before the scanner even looked at them. When scanning happens after storage, you're already racing the attacker.

Moving that decision to the edge changes the trust boundary. As defined in the HTTP Working Group Specifications for multipart/form-data, each file part comes with its own headers, boundaries, and encoding — all of which can be abused. Attackers don't just spoof Content-Type: image/png, they craft boundaries that confuse parsers, double-encode filenames like shell.php%00.jpg, or smuggle polyglots that are both image and script. At the CDN edge, we can buffer and normalize that stream before it ever proxies to origin. If the file fails validation, we return a 403 right there and origin never sees the bytes.

That doesn't mean you just "turn on antivirus at the edge" and call it done. The edge has to decide in milliseconds whether to buffer, scan, or stream. A naïve implementation that buffers every 100 MB video into memory will crush your PoP. The way modern platforms handle this is by tiering inspection through the WAF Rule Engine Architecture: How Inspection Engines Process Requests at Line Speed — fast, cheap checks run first on the streaming parser, and only suspicious or allowed file types get sent to heavier scanners. You don't scan everything equally; you scan intelligently.

// Edge Worker: streaming magic-byte validation before buffering to origin
export default {
  async fetch(request, env) {
    if (request.method !== "POST" || !request.headers.get("content-type")?.includes("multipart/form-data")) {
      return fetch(request);
    }

    const contentType = request.headers.get("content-type") || "";
    const boundary = contentType.split("boundary=")[1];
    if (!boundary) return new Response("Invalid multipart boundary", { status: 400 });

    // Peek at first 8 bytes of each file part without full buffering
    const allowedSignatures = {
      "image/jpeg": [0xFF, 0xD8, 0xFF],
      "image/png":  [0x89, 0x50, 0x4E, 0x47],
      "application/pdf": [0x25, 0x50, 0x44, 0x46]
    };

    // If file header doesn't match declared MIME, block at edge
    // This catches simple polyglots and extension spoofing
    const reader = request.body.getReader();
    const { value } = await reader.read();
    const header = value.slice(0, 8);

    const isValid = Object.values(allowedSignatures).some(sig =>
      sig.every((b, i) => header[i] === b)
    );

    if (!isValid) {
      return new Response("File type mismatch - blocked at edge", { status: 403 });
    }

    // Valid header -> forward to deeper AV/YARA scan
    return fetch(request);
  }
}

How Edge WAFs Actually Inspect Files Without Killing Connections

So what does inspection look like when you can't just write to disk? At the edge we're working with a streaming parser that understands multipart boundaries inline. As the bytes flow through, the WAF peels off each file part, extracts filename, declared MIME, actual magic bytes, and size, and then runs a series of checks in order. The first pass is almost free: is the file extension on the allowlist? Does the magic byte match the extension? Is the file size over the limit you set for that route? We drop a surprising amount of junk there — I've seen 60-70% of malicious uploads fail on header mismatch alone.

If the file passes that cheap validation, it goes to the heavy scanners. That's typically a combination of signature-based AV and behavioral rules. The signatures catch known malware families, while YARA-style heuristics look for things like embedded PHP tags inside an image, excessive entropy that suggests packing, or an embedded zip bomb. The guidance in web.dev Articles on handling file uploads for performance still applies here — you don't want to block the event loop — so edge platforms run these scanners in isolated sandboxes with strict CPU and memory limits, often with a 100-500ms timeout per file. You can tune this per endpoint; your /api/avatar might get strict scanning, while /api/bulk-import gets async scanning with a quarantine.

The part teams get wrong most often is customizing the rules. Out-of-the-box managed rules are noisy for uploads because every app has different needs. That healthcare client I mentioned allowed DICOM files that looked like binary blobs to every scanner — we had to write exceptions for that MIME type and add a content-disarm step instead of a straight block. If you're writing those exceptions, you'll want to read through WAF Custom Rule Authoring: Writing Effective Detection Rules Without False Positives and WAF Managed Ruleset Comparison: OWASP CRS, Commercial, and Provider-Specific Rules before you start tuning. A bad custom rule that blocks all application/octet-stream will break your mobile app uploads in production.

# ModSec-style custom rule: block PHP polyglots hidden in images at edge
# This runs after multipart parsing, on FILES_TMP_CONTENT

SecRule FILES_TMP_CONTENT "@rx <\?php" \
  "id:731001,phase:2,deny,status:403,log,msg:'PHP tag in image upload',\
   chain"
  SecRule FILES_NAMES "@rx \.(jpe?g|png|gif|webp)$" "t:lowercase"

# Stricter: block double extensions and null bytes in filename
SecRule FILES_NAMES "@rx \.(php|phtml|phar|asp|aspx|jsp)\." \
  "id:731002,phase:1,deny,status:403,log,msg:'Double extension attack'"

SecRule FILES_NAMES "@rx %00|\x00" \
  "id:731003,phase:1,deny,status:403,log,msg:'Null byte in filename'"

# Size + entropy check for zip bombs before AV scan
SecRule FILES_SIZES "@gt 10485760" \
  "id:731004,phase:1,deny,status:413,log,msg:'Upload exceeds 10MB limit for images'"

The Performance Tradeoff: What Scanning Actually Costs at the Edge

There's no free lunch with file scanning. The moment you tell the edge to buffer a file, you're adding latency and memory pressure to a system designed to be fast. In the PoPs I've operated, the difference between doing nothing and doing full AV scanning on a 5 MB image is anywhere from 25ms to 250ms, depending on how you implement it. Streaming validation — just checking headers and boundaries as bytes pass through — is almost invisible to the client. Full buffering and signature scanning is where you pay.

We've learned to tier it. Small images under 2 MB get synchronous blocking; they are buffered, scanned, and either passed or dropped before origin sees them. Large videos or archives over 20 MB get asynchronous scanning; we stream them to origin but also fork a copy to the scanner, and if it's malicious we revoke it via an API call and purge the CDN cache. It's not perfect — there's a tiny window where a bad file exists on origin — but it keeps p95 latency under 80ms. For most customers that window is acceptable compared to making every upload take an extra half second.

File size limits and timeouts are your best friends here. Don't try to scan a 500 MB upload synchronously at the edge; you'll time out and the client will retry, making things worse. Set per-route limits, reject oversized files at the edge with a 413 before you ever invoke the scanner, and decompress with caution. A 1 KB zip that expands to 1 GB is a classic denial-of-service trick against edge scanners missing decompression limits.

Scanning Method Added Latency (p95) Detection Rate Max File Size (Sync) Resource Cost per 10k Requests
MIME + Magic Byte Validation (streaming) 3 - 8 ms ~68% of spoofed files Unlimited (no buffering) ~0.2 vCPU hours
AV Signature Scan (ClamAV / Commercial) 45 - 120 ms 92 - 96% known malware 10 - 25 MB ~1.8 vCPU hours
YARA + Heuristic Rules 30 - 85 ms 88% polyglots / webshells 10 MB ~1.2 vCPU hours
Content Disarm & Reconstruction (CDR) 90 - 250 ms 99% active content stripped 5 MB ~4.5 vCPU hours
Async Scan + Quarantine (large files) 5 ms (client) / 400 ms (scan) 95% with delayed revoke 500 MB (async) ~0.9 vCPU hours

Deploying Edge Upload Inspection in Production

Don't just toggle on "scan all uploads" globally and call it a day — that's how you break legitimate traffic overnight. In production, we've learned to be surgical. You don't need to scan every POST. Target only the routes that actually accept files: /api/v1/upload, /media/*, /wp-admin/async-upload.php, and let the rest bypass inspection entirely. That alone cuts your scanning compute by 60-70% on most apps. Set sensible thresholds too. About 85% of the malicious payloads we see in the wild are under 5MB, so capping synchronous edge scans at 8-10MB and pushing anything larger to async quarantine is a solid tradeoff. Anything over that — think 50MB+ video uploads — shouldn't be buffered at the edge anyway, or you'll blow your worker memory limits and add 200ms+ of TTFB for everyone.

The other thing folks get wrong is trusting file extensions or Content-Type headers. Attackers lie. Every time. Your edge logic has to inspect the actual magic bytes and handle multipart/form-data properly. That means buffering the file body in the edge worker, not just looking at the request headers. For most CDNs, this requires streaming the upload into memory (or temp storage), running a signature + heuristic check, then deciding to forward or block before the origin ever sees it. If you’re running in shadow/log-only mode first — which you should for at least 48-72 hours — you’ll catch misclassifications without dropping real user photos or PDFs.

// Edge Worker pattern: buffered scan before origin forward
// Works on Cloudflare Workers / Fastly Compute@Edge / AWS CloudFront Functions

async function handleUpload(request) {
  const MAX_SCAN_SIZE = 10 * 1024 * 1024; // 10MB sync limit
  const SCAN_PATHS = [/\/api\/upload/, /\/media\/upload/];
  
  if (!SCAN_PATHS.some(p => p.test(new URL(request.url).pathname))) {
    return fetch(request); // bypass scan
  }

  const contentLength = parseInt(request.headers.get('content-length') || '0');
  if (contentLength > MAX_SCAN_SIZE) {
    return quarantineForAsyncScan(request); // 202 + async verdict
  }

  const buffer = await request.arrayBuffer();
  const fileType = detectMagicBytes(buffer); // don't trust extension
  const verdict = await scanEngine.check(buffer, {
    signatures: true,
    heuristics: true,
    mimeCheck: fileType
  });

  if (verdict.malicious) {
    return new Response('Blocked: Malicious file detected', { status: 403 });
  }
  
  // Re-create request with buffered body for origin
  return fetch(new Request(request, { body: buffer }));
}

function detectMagicBytes(buf) {
  const header = new Uint8Array(buf.slice(0, 8));
  if (header[0] === 0x4D && header[1] === 0x5A) return 'exe';
  if (header[0] === 0x50 && header[1] === 0x4B) return 'zip';
  // ... add PHP webshell, ELF, etc.
  return 'unknown';
}

That pattern adds about 18-35ms of latency at p95 for files under 5MB when you're using an embedded engine like ClamAV-WASM or a vendor's edge API. If you're calling out to an external ICAP service from the edge, expect 45-90ms and make sure you've got a hard timeout. We set ours at 120ms — if the scanner doesn't answer by then, we fail open for low-risk file types (images) and fail closed for executables/scripts. It's not perfect, but it keeps you from turning your WAF into a DoS bottleneck.

Advanced Patterns and Troubleshooting When Legit Files Get Blocked

Once you've got basic scanning live, the edge cases show up fast. The biggest headache is chunked and resumable uploads — think tus.io, S3 multipart, or large video chunks. You can't scan a 2MB chunk in isolation and expect to catch a payload that's reassembled at the origin. For those flows, you've got two options: either reassemble at the edge (expensive and slow, we don't recommend it past 20MB) or tag each chunk and enforce a final verification scan via a post-upload callback that quarantines the file until the full object is assembled and scanned. We've seen customers get burned by scanning chunks individually and getting a clean verdict, only to have the reassembled file be a polyglot PHP/GIF webshell.

You'll also want to stop scanning traffic that doesn't need it. If your WAF Bot Score Integration: Using Bot Detection Signals in Firewall Rules already flags a request as a trusted service account or a known good automation with a bot score of 10-20, don't waste scan cycles on it. Conversely, combine signals — if a request has a bot score of 85+ *and* is trying to upload an executable, block it outright without even invoking the scanner. That same layering idea applies to abuse: pairing your upload scanner with WAF Rate Limiting Integration: Combining Application Rules with Traffic Controls lets you throttle aggressive upload bursts (we typically set 20 uploads/min per IP on upload endpoints) before they can brute-force your detection logic with mutated samples.

When false positives hit — and they will — don't just allowlist the file extension. That's whack-a-mole. Instead, run your edge WAF in log-then-block for a week and feed every blocked upload's hash, magic byte result, and rule ID into your SIEM. We're aiming for a false positive rate under 0.4%, but image metadata and Office macros will trigger heuristic rules constantly. A systematic approach matters here. Follow a proper WAF False Positive Tuning: A Systematic Methodology for Reducing Alert Noise workflow: correlate by file type, ASN, and endpoint, then create surgical exceptions like "allow application/vnd.openxmlformats with macro heuristics on /api/invoices/upload from corporate IP range" rather than disabling the rule entirely. And always log the verdict reason in an edge header like X-Edge-Scan-Verdict so you can debug what happened without digging through origin logs.

Frequently Asked Questions

Will edge scanning catch password-protected or encrypted ZIPs?

Not reliably, and you shouldn't pretend it will. Encrypted archives look like random data — there's no signature to match until they're decrypted. At the edge, we flag encrypted ZIPs and 7z files as "unscannable" and handle them by policy: either block them outright on public upload forms (safest for most sites) or quarantine them and require async decryption with a user-supplied password on a sandboxed origin worker. Don't let "unscannable == clean" be your default, or you're giving attackers a free bypass.

How do you handle 100MB+ video uploads without timing out or blowing up edge memory?

You don't buffer them synchronously. For anything over your sync limit (we use 10MB, some teams use 20MB), return a 202 Accepted and offload to async scanning. Generate a signed upload URL to object storage, let the file land, then trigger a scanner via webhook or queue within 15-30 seconds. Keep the file in quarantine — not publicly accessible — until you get a clean verdict. At the edge, just enforce size caps and MIME validation, and add a header like X-Upload-Status: pending-scan so your app knows not to process it yet. This keeps p95 TTFB under 40ms even for huge files.

Does edge scanning replace antivirus at the origin?

No — it's defense in depth, not a replacement. Edge scanning catches about 96-99% of known commodity malware and common webshells before they burn origin compute, but sophisticated payloads, zero-days, and business-logic abuse still need deeper inspection at origin. Keep your origin AV and file validation, but let the edge drop the noisy, obvious stuff. We've seen this cut origin AV load by 70-80% and prevent the classic scenario where a WAF passes a file and the origin's nightly cron job is the first time you notice you've been hosting a miner for 12 hours.

What's the real performance hit for users uploading legitimate files?

For files under 2MB, it's negligible — 12-25ms added at the edge if you're using an embedded WASM engine. For 2-10MB files, budget 25-45ms at p95. External ICAP calls are slower, typically 45-90ms. The key is not to scan on the hot path for everything. If you bypass static assets, API calls without files, and trusted bot traffic, fewer than 5% of your total requests will ever hit the scanner, so your overall site p95 doesn't move. Monitor edge_scan_duration as a separate metric from origin latency so you don't confuse the two when you're tuning timeouts.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker