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">=></span> <span class="p">[</span><span class="s1">'image/jpeg'</span><span class="p">],</span> <span class="s1">'jpeg'</span> <span class="o">=></span> <span class="p">[</span><span class="s1">'image/jpeg'</span><span class="p">],</span> <span class="s1">'png'</span> <span class="o">=></span> <span class="p">[</span><span class="s1">'image/png'</span><span class="p">],</span> <span class="s1">'pdf'</span> <span class="o">=></span> <span class="p">[</span><span class="s1">'application/pdf'</span><span class="p">],</span> <span class="s1">'docx'</span> <span class="o">=></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">=></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">=></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">-></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">-></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">-></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">-></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">-></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">-></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">-></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">-></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">-></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">-></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">-></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">-></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">-></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">-></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">></span> <span class="nv">$this</span><span class="o">-></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">-></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">-></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">-></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">></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">></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">-></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">-></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">-></span><span class="nb">file</span><span class="p">(</span><span class="nv">$file</span><span class="o">-></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">&&</span> <span class="nb">strtolower</span><span class="p">(</span><span class="nv">$file</span><span class="o">-></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">=></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">-></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">-></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">&&</span> <span class="o">!</span><span class="nf">str_starts_with</span><span class="p">(</span><span class="nv">$this</span><span class="o">-></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">&&</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><script></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">-></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">-></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">-></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">-></span><span class="nf">length</span><span class="p">()</span> <span class="o">></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">-></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">-></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">-></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">-></span><span class="nf">medias</span><span class="p">()</span><span class="o">-></span><span class="nf">create</span><span class="p">([</span> <span class="s1">'name'</span> <span class="o">=></span> <span class="nv">$file</span><span class="o">-></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">=></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">=></span> <span class="nv">$file</span><span class="o">-></span><span class="nf">getMimeType</span><span class="p">(),</span> <span class="s1">'path'</span> <span class="o">=></span> <span class="nv">$file</span><span class="o">-></span><span class="nf">storeAs</span><span class="p">(</span> <span class="nv">$this</span><span class="o">-></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">-></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">=></span> <span class="nv">$this</span><span class="o">-></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">=></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">=></span> <span class="nv">$collection</span><span class="p">,</span> <span class="s1">'size'</span> <span class="o">=></span> <span class="nv">$file</span><span class="o">-></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">-></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">-></span><span class="n">disk</span><span class="p">)</span><span class="o">-></span><span class="nf">response</span><span class="p">(</span> <span class="nv">$media</span><span class="o">-></span><span class="n">path</span><span class="p">,</span> <span class="nv">$media</span><span class="o">-></span><span class="n">name</span><span class="p">,</span> <span class="p">[</span><span class="s1">'Content-Type'</span> <span class="o">=></span> <span class="nv">$media</span><span class="o">-></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">-></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">-></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' => \App\Http\Middleware\SecureFileUpload::class,</span> <span class="c1">// Then use ->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->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>
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。