惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

D
Docker
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
C
Cisco Blogs
Scott Helme
Scott Helme
Know Your Adversary
Know Your Adversary
NISL@THU
NISL@THU
C
Cyber Attacks, Cyber Crime and Cyber Security
D
Darknet – Hacking Tools, Hacker News & Cyber Security
C
CXSECURITY Database RSS Feed - CXSecurity.com
S
Schneier on Security
I
Intezer
Spread Privacy
Spread Privacy
AWS News Blog
AWS News Blog
V
Vulnerabilities – Threatpost
Cloudbric
Cloudbric
V2EX - 技术
V2EX - 技术
Google Online Security Blog
Google Online Security Blog
L
Lohrmann on Cybersecurity
Recent Commits to openclaw:main
Recent Commits to openclaw:main
L
LINUX DO - 热门话题
S
Secure Thoughts
T
The Exploit Database - CXSecurity.com
博客园 - 【当耐特】
Recent Announcements
Recent Announcements
Security Archives - TechRepublic
Security Archives - TechRepublic
Stack Overflow Blog
Stack Overflow Blog
罗磊的独立博客
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
K
Kaspersky official blog
阮一峰的网络日志
阮一峰的网络日志
博客园_首页
Latest news
Latest news
B
Blog
F
Full Disclosure
大猫的无限游戏
大猫的无限游戏
博客园 - 叶小钗
L
LangChain Blog
GbyAI
GbyAI
Last Week in AI
Last Week in AI
S
Security Affairs
Apple Machine Learning Research
Apple Machine Learning Research
N
Netflix TechBlog - Medium
Security Latest
Security Latest
Vercel News
Vercel News
Y
Y Combinator Blog
G
GRAHAM CLULEY
S
Securelist
T
Troy Hunt's Blog
Hacker News - Newest:
Hacker News - Newest: "LLM"
雷峰网
雷峰网

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Secure File Uploads: Seven Checks and Why Each One Exists
Shakil Alam · 2026-05-01 · via DEV Community
<p><strong>Part 3 of 4 — Laravel Architecture Patterns for Production</strong><br> <em>~9 min read · Security · Middleware · File handling</em></p> <p>A file upload is the moment you hand control to an untrusted user.</p> <p>Everything else in your application — form inputs, query parameters, JSON — is text. You validate it, sanitize it, store it in a database. A file upload is arbitrary binary data from a source you cannot verify, about to be written to your filesystem. The surface area is different. The failure modes are different. The consequences are different.</p> <p>Most tutorials cover the happy path: accept the file, store it, return a URL. The problem is never the happy path.</p> <p>Rename a PHP file to <code>image.jpg</code>. Upload it. Many Laravel applications accept it — because <code>getClientOriginalExtension()</code> returns <code>jpg</code> and <code>getClientMimeType()</code> returns <code>image/jpeg</code>. Both values the <em>client</em> provided. Neither verified by the server.</p> <p>This is how webshells get uploaded. This is how stored XSS gets planted in SVG files. This is the gap.</p> <p>The fix is seven independent checks, each closing a specific attack vector. The reason for seven — not one — is that no single check catches everything. A determined attacker probes each layer individually. Defense in depth means removing the easy paths at each layer.</p> <h2> What you will build </h2> <ul> <li>A middleware that validates seven independent properties of every uploaded file, each one named and explained</li> <li>Server-side MIME detection using <code>finfo</code> — not the browser's claim</li> <li>A unique name strategy that removes user-controlled strings from your filesystem paths entirely</li> <li>A storage pattern where no uploaded file is ever directly reachable via HTTP</li> </ul> <h2> The validation pipeline </h2> <div class="highlight js-code-highlight"> <pre class="highlight plaintext"><code>Upload arrives │ ▼ 1. Filename sanitization blocks: path traversal · double extensions · null bytes · hidden files │ ▼ 2. Extension allowlist blocks: file types with no legitimate use case │ ▼ 3. Client MIME allowlist blocks: mismatched browser-reported content type │ ▼ 4. Server MIME detection (finfo reads actual bytes) blocks: renamed files · spoofed extensions │ ▼ 5. Extension ↔ MIME cross-check blocks: shell.php.jpg · mismatched pairs │ ▼ 6. File size limit blocks: denial-of-service via oversized uploads │ ▼ 7. Magic bytes check blocks: crafted files · polyglot attacks │ ▼ Stored as unique name in storage/ — never in public/ </code></pre> </div> <h2> The middleware </h2> <p>The validation runs as middleware applied to upload routes. All validation aborts with <code>400</code> and a user-readable message:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight php"><code><span class="kd">class</span> <span class="nc">SecureFileUpload</span> <span class="p">{</span> <span class="c1">// Only what your application genuinely needs — keep this narrow</span> <span class="k">protected</span> <span class="kt">array</span> <span class="nv">$allowedExtensions</span> <span class="o">=</span> <span class="p">[</span><span class="s1">'jpg'</span><span class="p">,</span> <span class="s1">'jpeg'</span><span class="p">,</span> <span class="s1">'png'</span><span class="p">,</span> <span class="s1">'pdf'</span><span class="p">,</span> <span class="s1">'docx'</span><span class="p">,</span> <span class="s1">'xlsx'</span><span class="p">,</span> <span class="s1">'csv'</span><span class="p">];</span> <span class="k">protected</span> <span class="kt">array</span> <span class="nv">$allowedMimeTypes</span> <span class="o">=</span> <span class="p">[</span> <span class="s1">'image/jpeg'</span><span class="p">,</span> <span class="s1">'image/png'</span><span class="p">,</span> <span class="s1">'application/pdf'</span><span class="p">,</span> <span class="s1">'application/vnd.openxmlformats-officedocument.wordprocessingml.document'</span><span class="p">,</span> <span class="s1">'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'</span><span class="p">,</span> <span class="s1">'text/csv'</span><span class="p">,</span> <span class="s1">'text/plain'</span><span class="p">,</span> <span class="s1">'application/vnd.ms-excel'</span><span class="p">,</span> <span class="p">];</span> <span class="c1">// Maps each allowed extension to its permitted server-detected MIME types</span> <span class="k">protected</span> <span class="kt">array</span> <span class="nv">$extensionMimeMap</span> <span class="o">=</span> <span class="p">[</span> <span class="s1">'jpg'</span> <span class="o">=&gt;</span> <span class="p">[</span><span class="s1">'image/jpeg'</span><span class="p">],</span> <span class="s1">'jpeg'</span> <span class="o">=&gt;</span> <span class="p">[</span><span class="s1">'image/jpeg'</span><span class="p">],</span> <span class="s1">'png'</span> <span class="o">=&gt;</span> <span class="p">[</span><span class="s1">'image/png'</span><span class="p">],</span> <span class="s1">'pdf'</span> <span class="o">=&gt;</span> <span class="p">[</span><span class="s1">'application/pdf'</span><span class="p">],</span> <span class="s1">'docx'</span> <span class="o">=&gt;</span> <span class="p">[</span><span class="s1">'application/vnd.openxmlformats-officedocument.wordprocessingml.document'</span><span class="p">],</span> <span class="s1">'xlsx'</span> <span class="o">=&gt;</span> <span class="p">[</span><span class="s1">'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'</span><span class="p">],</span> <span class="s1">'csv'</span> <span class="o">=&gt;</span> <span class="p">[</span><span class="s1">'text/csv'</span><span class="p">,</span> <span class="s1">'text/plain'</span><span class="p">,</span> <span class="s1">'application/vnd.ms-excel'</span><span class="p">],</span> <span class="p">];</span> <span class="k">protected</span> <span class="kt">int</span> <span class="nv">$maxFileSizeKb</span><span class="p">;</span> <span class="k">public</span> <span class="k">function</span> <span class="n">__construct</span><span class="p">()</span> <span class="p">{</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">maxFileSizeKb</span> <span class="o">=</span> <span class="p">(</span><span class="n">int</span><span class="p">)</span> <span class="nf">config</span><span class="p">(</span><span class="s1">'files.upload_max_kb'</span><span class="p">,</span> <span class="mi">5120</span><span class="p">);</span> <span class="c1">// Default 5MB</span> <span class="p">}</span> <span class="k">public</span> <span class="k">function</span> <span class="n">handle</span><span class="p">(</span><span class="kt">Request</span> <span class="nv">$request</span><span class="p">,</span> <span class="kt">Closure</span> <span class="nv">$next</span><span class="p">)</span> <span class="p">{</span> <span class="k">foreach</span> <span class="p">(</span><span class="nv">$request</span><span class="o">-&gt;</span><span class="nf">allFiles</span><span class="p">()</span> <span class="k">as</span> <span class="nv">$file</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">walkFiles</span><span class="p">(</span><span class="nv">$file</span><span class="p">);</span> <span class="p">}</span> <span class="k">return</span> <span class="nv">$next</span><span class="p">(</span><span class="nv">$request</span><span class="p">);</span> <span class="p">}</span> <span class="k">protected</span> <span class="k">function</span> <span class="n">walkFiles</span><span class="p">(</span><span class="nv">$file</span><span class="p">):</span> <span class="kt">void</span> <span class="p">{</span> <span class="k">if</span> <span class="p">(</span><span class="nb">is_array</span><span class="p">(</span><span class="nv">$file</span><span class="p">))</span> <span class="p">{</span> <span class="k">foreach</span> <span class="p">(</span><span class="nv">$file</span> <span class="k">as</span> <span class="nv">$f</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">walkFiles</span><span class="p">(</span><span class="nv">$f</span><span class="p">);</span> <span class="p">}</span> <span class="k">return</span><span class="p">;</span> <span class="p">}</span> <span class="k">if</span> <span class="p">(</span><span class="nv">$file</span> <span class="k">instanceof</span> <span class="nc">UploadedFile</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">validateFile</span><span class="p">(</span><span class="nv">$file</span><span class="p">);</span> <span class="p">}</span> <span class="p">}</span> <span class="c1">// Orchestrates all seven checks in order</span> <span class="k">protected</span> <span class="k">function</span> <span class="n">validateFile</span><span class="p">(</span><span class="kt">UploadedFile</span> <span class="nv">$file</span><span class="p">):</span> <span class="kt">void</span> <span class="p">{</span> <span class="nv">$rawName</span> <span class="o">=</span> <span class="nv">$file</span><span class="o">-&gt;</span><span class="nf">getClientOriginalName</span><span class="p">();</span> <span class="nv">$extension</span> <span class="o">=</span> <span class="nb">strtolower</span><span class="p">(</span><span class="nv">$file</span><span class="o">-&gt;</span><span class="nf">getClientOriginalExtension</span><span class="p">());</span> <span class="nv">$clientMime</span> <span class="o">=</span> <span class="nv">$file</span><span class="o">-&gt;</span><span class="nf">getClientMimeType</span><span class="p">();</span> <span class="c1">// Check 1 — filename sanitization</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">sanitizeRawName</span><span class="p">(</span><span class="nv">$rawName</span><span class="p">);</span> <span class="c1">// Check 2 — extension allowlist</span> <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nb">in_array</span><span class="p">(</span><span class="nv">$extension</span><span class="p">,</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">allowedExtensions</span><span class="p">,</span> <span class="kc">true</span><span class="p">))</span> <span class="p">{</span> <span class="nf">abort</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="s2">"Files with .</span><span class="nv">$extension</span><span class="s2"> extension are not supported."</span><span class="p">);</span> <span class="p">}</span> <span class="c1">// Check 3 — client MIME allowlist</span> <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nb">in_array</span><span class="p">(</span><span class="nv">$clientMime</span><span class="p">,</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">allowedMimeTypes</span><span class="p">,</span> <span class="kc">true</span><span class="p">))</span> <span class="p">{</span> <span class="nf">abort</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="s1">'Unrecognized file type. Ensure the file is not corrupted.'</span><span class="p">);</span> <span class="p">}</span> <span class="c1">// Check 4 — server-side MIME detection</span> <span class="nv">$serverMime</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">detectServerMime</span><span class="p">(</span><span class="nv">$file</span><span class="p">);</span> <span class="c1">// Check 5 — extension ↔ MIME cross-check</span> <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">isMimeAllowedForExtension</span><span class="p">(</span><span class="nv">$extension</span><span class="p">,</span> <span class="nv">$serverMime</span><span class="p">))</span> <span class="p">{</span> <span class="nf">abort</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="s2">"Extension '.</span><span class="nv">$extension</span><span class="s2">' does not match the file's content. "</span> <span class="mf">.</span> <span class="s2">"Please ensure you have not renamed a file to a different extension."</span><span class="p">);</span> <span class="p">}</span> <span class="c1">// Check 6 — size limit</span> <span class="nv">$sizeKb</span> <span class="o">=</span> <span class="p">(</span><span class="n">int</span><span class="p">)</span> <span class="nb">ceil</span><span class="p">(</span><span class="nv">$file</span><span class="o">-&gt;</span><span class="nf">getSize</span><span class="p">()</span> <span class="o">/</span> <span class="mi">1024</span><span class="p">);</span> <span class="k">if</span> <span class="p">(</span><span class="nv">$sizeKb</span> <span class="o">&gt;</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">maxFileSizeKb</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$maxMb</span> <span class="o">=</span> <span class="nb">round</span><span class="p">(</span><span class="nv">$this</span><span class="o">-&gt;</span><span class="n">maxFileSizeKb</span> <span class="o">/</span> <span class="mi">1024</span><span class="p">,</span> <span class="mi">1</span><span class="p">);</span> <span class="nv">$fileMb</span> <span class="o">=</span> <span class="nb">round</span><span class="p">(</span><span class="nv">$sizeKb</span> <span class="o">/</span> <span class="mi">1024</span><span class="p">,</span> <span class="mi">1</span><span class="p">);</span> <span class="nf">abort</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="s2">"File too large. Maximum is </span><span class="si">{</span><span class="nv">$maxMb</span><span class="si">}</span><span class="s2">MB. Your file is </span><span class="si">{</span><span class="nv">$fileMb</span><span class="si">}</span><span class="s2">MB."</span><span class="p">);</span> <span class="p">}</span> <span class="c1">// Check 7 — magic bytes</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">lightweightMagicChecks</span><span class="p">(</span><span class="nv">$file</span><span class="p">,</span> <span class="nv">$extension</span><span class="p">);</span> <span class="p">}</span> <span class="k">protected</span> <span class="k">function</span> <span class="n">isMimeAllowedForExtension</span><span class="p">(</span><span class="kt">string</span> <span class="nv">$extension</span><span class="p">,</span> <span class="kt">string</span> <span class="nv">$serverMime</span><span class="p">):</span> <span class="kt">bool</span> <span class="p">{</span> <span class="nv">$allowed</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">extensionMimeMap</span><span class="p">[</span><span class="nv">$extension</span><span class="p">]</span> <span class="o">??</span> <span class="p">[];</span> <span class="k">return</span> <span class="nb">in_array</span><span class="p">(</span><span class="nv">$serverMime</span><span class="p">,</span> <span class="nv">$allowed</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span> <span class="p">}</span> <span class="p">}</span> </code></pre> </div> <p>The error messages matter. "Invalid file type" forces a support ticket. "Only one dot is allowed in the filename (e.g. <code>document.pdf</code>)" tells the user exactly what is wrong. When legitimate users trigger these checks by accident — and they will — specificity is the difference between a solvable problem and frustrated churn.</p> <h2> Check 1 — Filename sanitization </h2> <p>Before any content check, validate the name itself. Filenames are a distinct attack surface:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight php"><code><span class="k">protected</span> <span class="k">function</span> <span class="n">sanitizeRawName</span><span class="p">(</span><span class="kt">string</span> <span class="nv">$name</span><span class="p">):</span> <span class="kt">string</span> <span class="p">{</span> <span class="k">if</span> <span class="p">(</span><span class="nb">mb_strlen</span><span class="p">(</span><span class="nv">$name</span><span class="p">,</span> <span class="s1">'UTF-8'</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">100</span><span class="p">)</span> <span class="p">{</span> <span class="nf">abort</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="s1">'Filename too long. Please use 100 characters or fewer.'</span><span class="p">);</span> <span class="p">}</span> <span class="c1">// Path traversal: ../../etc/passwd</span> <span class="k">if</span> <span class="p">(</span><span class="nb">preg_match</span><span class="p">(</span><span class="s1">'#[\\\\/]|^\.+$#'</span><span class="p">,</span> <span class="nv">$name</span><span class="p">))</span> <span class="p">{</span> <span class="nf">abort</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="s1">'Filename cannot contain path separators.'</span><span class="p">);</span> <span class="p">}</span> <span class="c1">// Null bytes and control characters</span> <span class="k">if</span> <span class="p">(</span><span class="nb">preg_match</span><span class="p">(</span><span class="s1">'/[\x00-\x1F\x7F]/'</span><span class="p">,</span> <span class="nv">$name</span><span class="p">))</span> <span class="p">{</span> <span class="nf">abort</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="s1">'Filename contains invalid characters. Please rename the file.'</span><span class="p">);</span> <span class="p">}</span> <span class="c1">// Double-dot path traversal</span> <span class="k">if</span> <span class="p">(</span><span class="nf">str_contains</span><span class="p">(</span><span class="nv">$name</span><span class="p">,</span> <span class="s1">'..'</span><span class="p">))</span> <span class="p">{</span> <span class="nf">abort</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="s2">"Filename cannot contain '..'."</span><span class="p">);</span> <span class="p">}</span> <span class="c1">// Double-extension attack: shell.php.jpg</span> <span class="c1">// The browser sees .jpg, the server may execute .php</span> <span class="k">if</span> <span class="p">(</span><span class="nb">substr_count</span><span class="p">(</span><span class="nv">$name</span><span class="p">,</span> <span class="s1">'.'</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">1</span><span class="p">)</span> <span class="p">{</span> <span class="nf">abort</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="s2">"Only one dot allowed in the filename (e.g. 'document.pdf')."</span><span class="p">);</span> <span class="p">}</span> <span class="c1">// Hidden files: .htaccess, .env</span> <span class="k">if</span> <span class="p">(</span><span class="nf">str_starts_with</span><span class="p">(</span><span class="nv">$name</span><span class="p">,</span> <span class="s1">'.'</span><span class="p">))</span> <span class="p">{</span> <span class="nf">abort</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="s1">'Filenames cannot start with a dot.'</span><span class="p">);</span> <span class="p">}</span> <span class="c1">// Strict allowlist: letters, numbers, underscores, dashes, one dot, spaces</span> <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nb">preg_match</span><span class="p">(</span><span class="s1">'/^[a-zA-Z0-9_\-\. ]+$/'</span><span class="p">,</span> <span class="nv">$name</span><span class="p">))</span> <span class="p">{</span> <span class="nf">abort</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="s1">'Filename contains unsupported characters. Use letters, numbers, dashes, and underscores.'</span><span class="p">);</span> <span class="p">}</span> <span class="k">return</span> <span class="nv">$name</span><span class="p">;</span> <span class="p">}</span> </code></pre> </div> <p>The double-extension rule is the highest-value check here. <code>shell.php.jpg</code> gets past a simple extension check because the last extension is <code>.jpg</code>. One dot, full stop.</p> <h2> Checks 2 and 3 — Extension and client MIME allowlisting </h2> <p>Both are necessary, even though neither is sufficient alone:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight php"><code><span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nb">in_array</span><span class="p">(</span><span class="nv">$extension</span><span class="p">,</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">allowedExtensions</span><span class="p">,</span> <span class="kc">true</span><span class="p">))</span> <span class="p">{</span> <span class="nf">abort</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="s2">"Files with .</span><span class="nv">$extension</span><span class="s2"> extension are not supported."</span><span class="p">);</span> <span class="p">}</span> <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nb">in_array</span><span class="p">(</span><span class="nv">$clientMime</span><span class="p">,</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">allowedMimeTypes</span><span class="p">,</span> <span class="kc">true</span><span class="p">))</span> <span class="p">{</span> <span class="nf">abort</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="s1">'Unrecognized file type. Ensure the file is not corrupted.'</span><span class="p">);</span> <span class="p">}</span> </code></pre> </div> <p><strong>Why both?</strong> An attacker who crafts a file with an allowed extension might supply an unexpected MIME type. An attacker who spoofs the MIME type might use a disallowed extension. Checking both raises the bar.</p> <p><strong>Why keep the allowlist narrow?</strong> Every extension you permit is an attack surface you are accepting. If your application does not need <code>.svg</code> uploads, remove it. If it does not need <code>.zip</code>, remove it. The default should be minimal; expansion should require a specific business case.</p> <h2> Check 4 — Server-side MIME detection </h2> <p>This is the check that actually matters. The previous two trusted the client. This one reads the file:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight php"><code><span class="k">protected</span> <span class="k">function</span> <span class="n">detectServerMime</span><span class="p">(</span><span class="kt">UploadedFile</span> <span class="nv">$file</span><span class="p">):</span> <span class="kt">string</span> <span class="p">{</span> <span class="c1">// finfo reads the file's actual bytes from the filesystem.</span> <span class="c1">// It does not care what the browser claimed.</span> <span class="c1">// A PHP file renamed to .jpg will be identified as text/x-php, not image/jpeg.</span> <span class="nv">$finfo</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">\finfo</span><span class="p">(</span><span class="no">FILEINFO_MIME_TYPE</span><span class="p">);</span> <span class="nv">$mime</span> <span class="o">=</span> <span class="nv">$finfo</span><span class="o">-&gt;</span><span class="nb">file</span><span class="p">(</span><span class="nv">$file</span><span class="o">-&gt;</span><span class="nf">getRealPath</span><span class="p">());</span> <span class="c1">// Edge case: finfo correctly identifies CSV as text/plain (it is plain text).</span> <span class="c1">// But text/plain is not in the CSV slot on the allowlist.</span> <span class="c1">// The extension provides the context needed to resolve this.</span> <span class="k">if</span> <span class="p">(</span><span class="nv">$mime</span> <span class="o">===</span> <span class="s1">'text/plain'</span> <span class="o">&amp;&amp;</span> <span class="nb">strtolower</span><span class="p">(</span><span class="nv">$file</span><span class="o">-&gt;</span><span class="nf">getClientOriginalExtension</span><span class="p">())</span> <span class="o">===</span> <span class="s1">'csv'</span><span class="p">)</span> <span class="p">{</span> <span class="k">return</span> <span class="s1">'text/csv'</span><span class="p">;</span> <span class="p">}</span> <span class="k">return</span> <span class="p">(</span><span class="n">string</span><span class="p">)</span> <span class="nv">$mime</span><span class="p">;</span> <span class="p">}</span> </code></pre> </div> <p><strong>Why does the CSV edge case matter?</strong> A naive implementation would either reject all CSVs (wrong MIME type) or add <code>text/plain</code> to the global allowlist (which permits uploading arbitrary text files, including PHP in some environments). The explicit edge case handles the ambiguity without either problem.</p> <h2> Check 5 — Cross-checking extension against detected MIME </h2> <p>The check that closes the most common attack vector. The <code>extensionMimeMap</code> defined on the class is what makes this work — each allowed extension maps to the MIME types <code>finfo</code> legitimately returns for that format:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight php"><code><span class="c1">// CSV legitimately maps to multiple MIME types because different tools</span> <span class="c1">// and operating systems report it differently. The map accommodates</span> <span class="c1">// known variation without widening the gap for other formats.</span> <span class="s1">'csv'</span> <span class="o">=&gt;</span> <span class="p">[</span><span class="s1">'text/csv'</span><span class="p">,</span> <span class="s1">'text/plain'</span><span class="p">,</span> <span class="s1">'application/vnd.ms-excel'</span><span class="p">],</span> </code></pre> </div> <p>A file with extension <code>.jpg</code> whose <code>finfo</code>-detected MIME is <code>text/x-php</code> fails this check. The extension says one thing; the bytes say another. That inconsistency is the signal.</p> <h2> Check 6 — Size limits </h2> <p>The size check in <code>validateFile()</code> above reads the limit from config:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight php"><code><span class="nv">$this</span><span class="o">-&gt;</span><span class="n">maxFileSizeKb</span> <span class="o">=</span> <span class="p">(</span><span class="n">int</span><span class="p">)</span> <span class="nf">config</span><span class="p">(</span><span class="s1">'files.upload_max_kb'</span><span class="p">,</span> <span class="mi">5120</span><span class="p">);</span> </code></pre> </div> <p>Store the limit in <code>config('files.upload_max_kb')</code> rather than hardcoding it. This lets you adjust per environment — tighter on free tier plans, looser for premium users — without touching the middleware. The error message surfaces both the maximum and the actual file size so the user knows exactly what to change.</p> <h2> Check 7 — Magic bytes </h2> <p>The final layer reads the file's opening bytes directly:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight php"><code><span class="k">protected</span> <span class="k">function</span> <span class="n">lightweightMagicChecks</span><span class="p">(</span><span class="kt">UploadedFile</span> <span class="nv">$file</span><span class="p">,</span> <span class="kt">string</span> <span class="nv">$ext</span><span class="p">):</span> <span class="kt">void</span> <span class="p">{</span> <span class="nv">$fp</span> <span class="o">=</span> <span class="o">@</span><span class="nb">fopen</span><span class="p">(</span><span class="nv">$file</span><span class="o">-&gt;</span><span class="nf">getRealPath</span><span class="p">(),</span> <span class="s1">'rb'</span><span class="p">);</span> <span class="nv">$head</span> <span class="o">=</span> <span class="o">@</span><span class="nb">fread</span><span class="p">(</span><span class="nv">$fp</span><span class="p">,</span> <span class="mi">8</span><span class="p">)</span> <span class="o">?:</span> <span class="s1">''</span><span class="p">;</span> <span class="o">@</span><span class="nb">fclose</span><span class="p">(</span><span class="nv">$fp</span><span class="p">);</span> <span class="c1">// Every valid PDF begins with %PDF-</span> <span class="k">if</span> <span class="p">(</span><span class="nv">$ext</span> <span class="o">===</span> <span class="s1">'pdf'</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="nf">str_starts_with</span><span class="p">(</span><span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">bytesToAscii</span><span class="p">(</span><span class="nv">$head</span><span class="p">),</span> <span class="s1">'%PDF-'</span><span class="p">))</span> <span class="p">{</span> <span class="nf">abort</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="s1">'This PDF appears to be corrupted or invalid.'</span><span class="p">);</span> <span class="p">}</span> <span class="c1">// PNG has a fixed 8-byte signature defined in the spec</span> <span class="k">if</span> <span class="p">(</span><span class="nv">$ext</span> <span class="o">===</span> <span class="s1">'png'</span> <span class="o">&amp;&amp;</span> <span class="nv">$head</span> <span class="o">!==</span> <span class="s2">"</span><span class="se">\x89</span><span class="s2">PNG</span><span class="se">\r\n\x1a\n</span><span class="s2">"</span><span class="p">)</span> <span class="p">{</span> <span class="nf">abort</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="s1">'This PNG appears to be corrupted or invalid.'</span><span class="p">);</span> <span class="p">}</span> <span class="c1">// JPEG starts with FF D8 FF</span> <span class="k">if</span> <span class="p">(</span><span class="nb">in_array</span><span class="p">(</span><span class="nv">$ext</span><span class="p">,</span> <span class="p">[</span><span class="s1">'jpg'</span><span class="p">,</span> <span class="s1">'jpeg'</span><span class="p">],</span> <span class="kc">true</span><span class="p">))</span> <span class="p">{</span> <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nf">str_starts_with</span><span class="p">(</span><span class="nv">$head</span><span class="p">,</span> <span class="s2">"</span><span class="se">\xFF\xD8\xFF</span><span class="s2">"</span><span class="p">))</span> <span class="p">{</span> <span class="nf">abort</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="s1">'This JPEG appears to be corrupted or invalid.'</span><span class="p">);</span> <span class="p">}</span> <span class="p">}</span> <span class="c1">// DOCX, XLSX, PPTX are ZIP archives with a PK header</span> <span class="k">if</span> <span class="p">(</span><span class="nb">in_array</span><span class="p">(</span><span class="nv">$ext</span><span class="p">,</span> <span class="p">[</span><span class="s1">'docx'</span><span class="p">,</span> <span class="s1">'xlsx'</span><span class="p">,</span> <span class="s1">'pptx'</span><span class="p">,</span> <span class="s1">'zip'</span><span class="p">],</span> <span class="kc">true</span><span class="p">))</span> <span class="p">{</span> <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nb">preg_match</span><span class="p">(</span><span class="s1">'/^PK(\x03\x04|\x05\x06|\x07\x08)/'</span><span class="p">,</span> <span class="nv">$head</span><span class="p">))</span> <span class="p">{</span> <span class="nf">abort</span><span class="p">(</span><span class="mi">400</span><span class="p">,</span> <span class="s1">'This Office document appears corrupted or invalid.'</span><span class="p">);</span> <span class="p">}</span> <span class="p">}</span> <span class="p">}</span> <span class="k">protected</span> <span class="k">function</span> <span class="n">bytesToAscii</span><span class="p">(</span><span class="kt">string</span> <span class="nv">$bytes</span><span class="p">):</span> <span class="kt">string</span> <span class="p">{</span> <span class="c1">// Strip non-printable characters for string comparison (e.g. "%PDF-")</span> <span class="k">return</span> <span class="nb">preg_replace</span><span class="p">(</span><span class="s1">'/[^\x20-\x7E]/'</span><span class="p">,</span> <span class="s1">''</span><span class="p">,</span> <span class="nv">$bytes</span><span class="p">)</span> <span class="o">??</span> <span class="s1">''</span><span class="p">;</span> <span class="p">}</span> </code></pre> </div> <p><strong>Why 8 bytes?</strong> Magic byte signatures are typically 2–8 bytes. Reading the first 8 captures all common signatures without reading a meaningful chunk of the file.</p> <p><strong>A note on coverage:</strong> this check covers the formats most commonly exploited — PDF, PNG, JPEG, and Office documents. It is a structural sanity check, not a full file parser. Its job is to catch files where the opening bytes contradict the claimed format. It does not replace the MIME detection and cross-check above — all seven checks work together.</p> <h2> SVG: the format that deserves extra attention </h2> <p>SVG files are XML. XML can contain <code>&lt;script&gt;</code> tags. A valid, well-formed SVG with embedded JavaScript is a stored XSS vector — if you accept it from one user and serve it inline to another, you have created a script injection path. The <a href="https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html" rel="noopener noreferrer">OWASP File Upload Cheat Sheet</a> covers this and other format-specific risks in depth.</p> <p>Three options:</p> <ol> <li> <strong>Sanitize on upload</strong> — strip script tags and event handlers before storing. Complex to do correctly and easy to get wrong.</li> <li> <strong>Force download</strong> — serve SVGs with <code>Content-Disposition: attachment</code>, preventing inline browser rendering and therefore script execution.</li> <li> <strong>Remove SVGs from the allowlist</strong> — the safest option if there is no specific business requirement.</li> </ol> <p>If you do not have a defined reason to accept user-uploaded SVGs, option 3 takes one line and eliminates the risk entirely.</p> <h2> Always generate a unique name before saving </h2> <p>Regardless of the validation outcome, the filename used for storage should never be the one the user provided:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight php"><code><span class="k">public</span> <span class="k">static</span> <span class="k">function</span> <span class="n">uniqueName</span><span class="p">(</span><span class="nv">$file</span><span class="p">,</span> <span class="nv">$postfix</span> <span class="o">=</span> <span class="s1">''</span><span class="p">):</span> <span class="kt">string</span> <span class="p">{</span> <span class="k">if</span> <span class="p">(</span><span class="nv">$file</span> <span class="k">instanceof</span> <span class="nc">UploadedFile</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$file</span> <span class="o">=</span> <span class="nv">$file</span><span class="o">-&gt;</span><span class="nf">getClientOriginalName</span><span class="p">();</span> <span class="p">}</span> <span class="nv">$extension</span> <span class="o">=</span> <span class="k">self</span><span class="o">::</span><span class="nf">getExtension</span><span class="p">(</span><span class="nv">$file</span><span class="p">);</span> <span class="nv">$nameWithoutExtension</span> <span class="o">=</span> <span class="nc">Str</span><span class="o">::</span><span class="nf">of</span><span class="p">(</span><span class="nv">$file</span><span class="p">)</span> <span class="o">-&gt;</span><span class="nf">replaceLast</span><span class="p">(</span><span class="s2">".</span><span class="nv">$extension</span><span class="s2">"</span><span class="p">,</span> <span class="s1">''</span><span class="p">)</span> <span class="o">-&gt;</span><span class="nf">replace</span><span class="p">(</span><span class="s1">' '</span><span class="p">,</span> <span class="s1">'_'</span><span class="p">);</span> <span class="k">if</span> <span class="p">(</span><span class="nv">$nameWithoutExtension</span><span class="o">-&gt;</span><span class="nf">length</span><span class="p">()</span> <span class="o">&gt;</span> <span class="mi">100</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$nameWithoutExtension</span> <span class="o">=</span> <span class="nv">$nameWithoutExtension</span><span class="o">-&gt;</span><span class="nb">substr</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">100</span><span class="p">);</span> <span class="p">}</span> <span class="nv">$nameWithoutExtension</span> <span class="o">=</span> <span class="nv">$nameWithoutExtension</span> <span class="mf">.</span> <span class="nv">$postfix</span><span class="p">;</span> <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nb">method_exists</span><span class="p">(</span><span class="nc">Str</span><span class="o">::</span><span class="n">class</span><span class="p">,</span> <span class="s1">'transliterate'</span><span class="p">))</span> <span class="p">{</span> <span class="nv">$nameWithoutExtension</span> <span class="o">=</span> <span class="nb">preg_replace</span><span class="p">(</span><span class="s1">'/[^\x20-\x7E]/'</span><span class="p">,</span> <span class="s1">'_'</span><span class="p">,</span> <span class="nv">$nameWithoutExtension</span><span class="p">);</span> <span class="k">return</span> <span class="nv">$nameWithoutExtension</span> <span class="mf">.</span> <span class="s1">'_'</span> <span class="mf">.</span> <span class="nb">uniqid</span><span class="p">()</span> <span class="mf">.</span> <span class="s1">'.'</span> <span class="mf">.</span> <span class="nv">$extension</span><span class="p">;</span> <span class="p">}</span> <span class="k">return</span> <span class="nc">Str</span><span class="o">::</span><span class="nf">transliterate</span><span class="p">(</span><span class="nv">$nameWithoutExtension</span><span class="p">)</span> <span class="mf">.</span> <span class="s1">'_'</span> <span class="mf">.</span> <span class="nb">uniqid</span><span class="p">()</span> <span class="mf">.</span> <span class="s1">'.'</span> <span class="mf">.</span> <span class="nv">$extension</span><span class="p">;</span> <span class="p">}</span> </code></pre> </div> <p><code>uniqid()</code> is microsecond-based and not cryptographically random. It is sufficient here because it combines with the sanitized filename prefix — two simultaneous uploads of <code>report.pdf</code> produce <code>report_6601a1f3b8e42.pdf</code> and <code>report_6601a1f3b8e43.pdf</code>, with no collision. If your application requires stronger uniqueness guarantees — for example, if filenames are ever used as access tokens — substitute <code>Str::uuid()</code>. That is a one-line change and the stronger default.</p> <p><code>uniqueName()</code> depends on two helper methods. Here are simple implementations to drop into the same <code>File</code> class:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight php"><code><span class="k">public</span> <span class="k">static</span> <span class="k">function</span> <span class="n">getExtension</span><span class="p">(</span><span class="kt">string</span><span class="o">|</span><span class="nc">UploadedFile</span> <span class="nv">$file</span><span class="p">):</span> <span class="kt">string</span> <span class="p">{</span> <span class="k">if</span> <span class="p">(</span><span class="nv">$file</span> <span class="k">instanceof</span> <span class="nc">UploadedFile</span><span class="p">)</span> <span class="p">{</span> <span class="k">return</span> <span class="nb">strtolower</span><span class="p">(</span><span class="nv">$file</span><span class="o">-&gt;</span><span class="nf">getClientOriginalExtension</span><span class="p">());</span> <span class="p">}</span> <span class="k">return</span> <span class="nb">strtolower</span><span class="p">(</span><span class="nb">pathinfo</span><span class="p">(</span><span class="nv">$file</span><span class="p">,</span> <span class="no">PATHINFO_EXTENSION</span><span class="p">));</span> <span class="p">}</span> <span class="k">public</span> <span class="k">static</span> <span class="k">function</span> <span class="n">getHash</span><span class="p">(</span><span class="kt">UploadedFile</span> <span class="nv">$file</span><span class="p">):</span> <span class="kt">string</span> <span class="p">{</span> <span class="c1">// SHA-256 of the file contents — useful for detecting duplicate uploads</span> <span class="c1">// and verifying file integrity when serving back to users</span> <span class="k">return</span> <span class="nb">hash_file</span><span class="p">(</span><span class="s1">'sha256'</span><span class="p">,</span> <span class="nv">$file</span><span class="o">-&gt;</span><span class="nf">getRealPath</span><span class="p">());</span> <span class="p">}</span> </code></pre> </div> <p>And in the media trait:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight php"><code><span class="k">public</span> <span class="k">function</span> <span class="n">addMediaAs</span><span class="p">(</span> <span class="kt">UploadedFile</span> <span class="nv">$file</span><span class="p">,</span> <span class="kt">string</span> <span class="nv">$collection</span> <span class="o">=</span> <span class="s1">'default'</span><span class="p">,</span> <span class="kt">string</span> <span class="nv">$name</span> <span class="o">=</span> <span class="kc">null</span> <span class="p">):</span> <span class="kt">Media</span> <span class="p">{</span> <span class="nv">$uniqueName</span> <span class="o">=</span> <span class="nv">$name</span> <span class="o">??</span> <span class="nc">File</span><span class="o">::</span><span class="nf">uniqueName</span><span class="p">(</span><span class="nv">$file</span><span class="p">);</span> <span class="k">return</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">medias</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">create</span><span class="p">([</span> <span class="s1">'name'</span> <span class="o">=&gt;</span> <span class="nv">$file</span><span class="o">-&gt;</span><span class="nf">getClientOriginalName</span><span class="p">(),</span> <span class="c1">// Original name for display</span> <span class="s1">'file_name'</span> <span class="o">=&gt;</span> <span class="nv">$uniqueName</span><span class="p">,</span> <span class="c1">// Unique name for storage</span> <span class="s1">'mime_type'</span> <span class="o">=&gt;</span> <span class="nv">$file</span><span class="o">-&gt;</span><span class="nf">getMimeType</span><span class="p">(),</span> <span class="s1">'path'</span> <span class="o">=&gt;</span> <span class="nv">$file</span><span class="o">-&gt;</span><span class="nf">storeAs</span><span class="p">(</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">getMediaPath</span><span class="p">(</span><span class="nv">$collection</span><span class="p">),</span> <span class="nv">$uniqueName</span><span class="p">,</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">getMediaDisk</span><span class="p">(</span><span class="nv">$collection</span><span class="p">)</span> <span class="p">),</span> <span class="s1">'disk'</span> <span class="o">=&gt;</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">getMediaDisk</span><span class="p">(</span><span class="nv">$collection</span><span class="p">),</span> <span class="s1">'file_hash'</span> <span class="o">=&gt;</span> <span class="nc">File</span><span class="o">::</span><span class="nf">getHash</span><span class="p">(</span><span class="nv">$file</span><span class="p">),</span> <span class="s1">'collection'</span> <span class="o">=&gt;</span> <span class="nv">$collection</span><span class="p">,</span> <span class="s1">'size'</span> <span class="o">=&gt;</span> <span class="nv">$file</span><span class="o">-&gt;</span><span class="nf">getSize</span><span class="p">(),</span> <span class="p">]);</span> <span class="p">}</span> </code></pre> </div> <p><strong>Why keep the original name in <code>name</code> but use the unique name for <code>path</code>?</strong> The display name — shown to the user in the UI — should be what they uploaded. The storage path should have no connection to any user-provided string. The two concerns are separate.</p> <h2> Where files live after validation </h2> <p>Store outside the web root — in <code>storage/</code> — and serve through a controller that enforces authorization:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight php"><code><span class="k">public</span> <span class="k">function</span> <span class="n">stream</span><span class="p">(</span><span class="kt">Media</span> <span class="nv">$media</span><span class="p">):</span> <span class="kt">StreamedResponse</span> <span class="p">{</span> <span class="c1">// Authorization on every file access — no direct URL bypasses this</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">authorize</span><span class="p">(</span><span class="s1">'view'</span><span class="p">,</span> <span class="nv">$media</span><span class="p">);</span> <span class="k">return</span> <span class="nc">Storage</span><span class="o">::</span><span class="nf">disk</span><span class="p">(</span><span class="nv">$media</span><span class="o">-&gt;</span><span class="n">disk</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">response</span><span class="p">(</span> <span class="nv">$media</span><span class="o">-&gt;</span><span class="n">path</span><span class="p">,</span> <span class="nv">$media</span><span class="o">-&gt;</span><span class="n">name</span><span class="p">,</span> <span class="p">[</span><span class="s1">'Content-Type'</span> <span class="o">=&gt;</span> <span class="nv">$media</span><span class="o">-&gt;</span><span class="n">mime_type</span><span class="p">]</span> <span class="p">);</span> <span class="p">}</span> </code></pre> </div> <p><strong>Why never <code>public/</code>?</strong> A file in <code>public/</code> is directly reachable via HTTP — no authorization, no application code in the path. If a file with a server-executable extension reaches <code>public/</code> because validation has layers that can be circumvented, it can be executed by guessing its URL. Files in <code>storage/</code> have no direct URL. Everything goes through the controller.</p> <h2> Registering the middleware </h2> <p>Apply <code>SecureFileUpload</code> to upload routes only — never globally, since running all seven checks on every request that has no file would add overhead for no reason.<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight php"><code><span class="c1">// Laravel 11 — routes/web.php or routes/api.php</span> <span class="nc">Route</span><span class="o">::</span><span class="nf">post</span><span class="p">(</span><span class="s1">'/upload'</span><span class="p">,</span> <span class="p">[</span><span class="nc">MediaController</span><span class="o">::</span><span class="n">class</span><span class="p">,</span> <span class="s1">'store'</span><span class="p">])</span> <span class="o">-&gt;</span><span class="nf">middleware</span><span class="p">(</span><span class="nc">\App\Http\Middleware\SecureFileUpload</span><span class="o">::</span><span class="n">class</span><span class="p">);</span> <span class="c1">// Or as a group if you have multiple upload endpoints</span> <span class="nc">Route</span><span class="o">::</span><span class="nf">middleware</span><span class="p">(</span><span class="nc">\App\Http\Middleware\SecureFileUpload</span><span class="o">::</span><span class="n">class</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">group</span><span class="p">(</span><span class="k">function</span> <span class="p">()</span> <span class="p">{</span> <span class="nc">Route</span><span class="o">::</span><span class="nf">post</span><span class="p">(</span><span class="s1">'/media/upload'</span><span class="p">,</span> <span class="p">[</span><span class="nc">MediaController</span><span class="o">::</span><span class="n">class</span><span class="p">,</span> <span class="s1">'store'</span><span class="p">]);</span> <span class="nc">Route</span><span class="o">::</span><span class="nf">post</span><span class="p">(</span><span class="s1">'/documents/upload'</span><span class="p">,</span> <span class="p">[</span><span class="nc">DocumentController</span><span class="o">::</span><span class="n">class</span><span class="p">,</span> <span class="s1">'store'</span><span class="p">]);</span> <span class="p">});</span> <span class="c1">// Laravel 10 and earlier — same route syntax works, or alias it</span> <span class="c1">// in app/Http/Kernel.php under $routeMiddleware:</span> <span class="c1">// 'secure.upload' =&gt; \App\Http\Middleware\SecureFileUpload::class,</span> <span class="c1">// Then use -&gt;middleware('secure.upload') in your routes</span> </code></pre> </div> <blockquote> <p><strong>The key insight from this article:</strong> No single file validation check catches everything. <code>getClientMimeType()</code> trusts the browser. <code>getClientOriginalExtension()</code> trusts the browser. Only <code>finfo</code> reads the actual bytes. Seven independent checks layered together — each blocking a different attack path — is what makes upload validation meaningfully secure rather than just present.</p> </blockquote> <h2> Before you ship — checklist </h2> <ul> <li>[ ] <code>SecureFileUpload</code> middleware is applied to upload routes only — not globally</li> <li>[ ] <code>allowedExtensions</code> and <code>allowedMimeTypes</code> are minimal — only what the application genuinely needs</li> <li>[ ] <code>finfo</code> server-side MIME detection is running (not just trusting <code>getClientMimeType()</code>)</li> <li>[ ] <code>extensionMimeMap</code> covers every extension in <code>allowedExtensions</code> </li> <li>[ ] Magic bytes check covers JPEG in addition to PDF, PNG, and Office formats</li> <li>[ ] SVG is either removed from the allowlist or served with <code>Content-Disposition: attachment</code> </li> <li>[ ] <code>File::uniqueName()</code> is called before every <code>storeAs()</code> — original filename never used as storage path</li> <li>[ ] Files are stored in <code>storage/</code> — nothing uploaded goes into <code>public/</code> </li> <li>[ ] File access goes through a controller that calls <code>$this-&gt;authorize()</code> </li> </ul> <p><em>Previous: <a href="https://blog.shakiltech.com/laravel-queue-architecture/" rel="noopener noreferrer">Part 2 — Queue Architecture</a></em></p>