313 lines
11 KiB
PHP
313 lines
11 KiB
PHP
<?php
|
|
/**
|
|
* File Upload Guard - content only
|
|
* app/Views/docs/file-uploads.php
|
|
*
|
|
* Documents the behavior of app/Filters/GlobalPostFileUploadGuard.php
|
|
*/
|
|
?>
|
|
|
|
<p>
|
|
File uploads are protected by <code>GlobalPostFileUploadGuard</code>, a
|
|
request filter that validates uploaded files before controller code runs. Its
|
|
job is to reject dangerous uploads early using extension checks, MIME checks,
|
|
magic-byte inspection, filename validation, and size limits.
|
|
</p>
|
|
|
|
<div class="callout info">
|
|
<span>i</span>
|
|
<div>
|
|
<strong>Where this logic lives</strong>
|
|
The implementation is in <code>app/Filters/GlobalPostFileUploadGuard.php</code>.
|
|
The filter is aliased in <code>app/Config/Filters.php</code> and is also
|
|
applied globally in the <code>before</code> filter chain.
|
|
</div>
|
|
</div>
|
|
|
|
<h2 id="overview">Overview</h2>
|
|
|
|
<div class="mermaid-wrapper">
|
|
<div class="mermaid">
|
|
flowchart TD
|
|
A[POST request with files] --> B[Run upload guard filter]
|
|
B --> C[Walk uploaded inputs]
|
|
C --> D[Validate one file]
|
|
D --> E[Check filename and extension]
|
|
E --> F[Check file size]
|
|
F --> G[Detect real MIME]
|
|
G --> H[Resolve expected MIME]
|
|
H --> I[Check magic bytes]
|
|
I --> J[Verify strict MIME match]
|
|
J --> K[Allow request]
|
|
E --> X[Block upload]
|
|
F --> X
|
|
G --> X
|
|
H --> X
|
|
I --> X
|
|
J --> X
|
|
</div>
|
|
</div>
|
|
|
|
<h2 id="when-it-runs">When it runs</h2>
|
|
|
|
<p>
|
|
The filter returns immediately unless all of the following are true:
|
|
</p>
|
|
|
|
<ol class="steps">
|
|
<li>
|
|
<strong>The request method is <code>POST</code></strong>
|
|
<p>Non-POST requests are ignored by the guard.</p>
|
|
</li>
|
|
<li>
|
|
<strong>The request actually contains uploaded files</strong>
|
|
<p>If <code>$request->getFiles()</code> is empty, the filter exits without doing anything.</p>
|
|
</li>
|
|
<li>
|
|
<strong>Each uploaded file is valid enough to inspect</strong>
|
|
<p>Oversized uploads that fail at the PHP upload layer are still blocked and logged with a specific reason.</p>
|
|
</li>
|
|
</ol>
|
|
|
|
<p>
|
|
The filter also recurses through nested file input arrays, so it protects both
|
|
single-file and multi-file form structures.
|
|
</p>
|
|
|
|
<h2 id="allowed-file-types">Allowed file types</h2>
|
|
|
|
<p>
|
|
The allowlist is defined through <code>$allowedMimeMap</code>. The filter
|
|
resolves an expected MIME from the client extension and rejects any extension
|
|
that does not map to an approved MIME.
|
|
</p>
|
|
|
|
<table>
|
|
<thead>
|
|
<tr><th>MIME type</th><th>Allowed extensions</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr><td><code>image/jpeg</code></td><td><code>jpg</code>, <code>jpeg</code></td></tr>
|
|
<tr><td><code>image/png</code></td><td><code>png</code></td></tr>
|
|
<tr><td><code>image/gif</code></td><td><code>gif</code></td></tr>
|
|
<tr><td><code>image/webp</code></td><td><code>webp</code></td></tr>
|
|
<tr><td><code>application/pdf</code></td><td><code>pdf</code></td></tr>
|
|
<tr><td><code>application/msword</code></td><td><code>doc</code></td></tr>
|
|
<tr><td><code>application/vnd.openxmlformats-officedocument.wordprocessingml.document</code></td><td><code>docx</code></td></tr>
|
|
<tr><td><code>application/vnd.oasis.opendocument.text</code></td><td><code>odt</code></td></tr>
|
|
<tr><td><code>text/rtf</code> / <code>application/rtf</code></td><td><code>rtf</code></td></tr>
|
|
<tr><td><code>application/vnd.ms-excel</code></td><td><code>xls</code></td></tr>
|
|
<tr><td><code>application/vnd.openxmlformats-officedocument.spreadsheetml.sheet</code></td><td><code>xlsx</code></td></tr>
|
|
<tr><td><code>application/vnd.oasis.opendocument.spreadsheet</code></td><td><code>ods</code></td></tr>
|
|
<tr><td><code>text/csv</code> / <code>application/csv</code></td><td><code>csv</code></td></tr>
|
|
<tr><td><code>text/plain</code></td><td><code>txt</code></td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<div class="callout warning">
|
|
<span>!</span>
|
|
<div>
|
|
<strong>Notable exclusions</strong>
|
|
SVG is explicitly removed. Archive formats such as <code>zip</code>,
|
|
<code>rar</code>, and <code>7z</code> are blocked. The filter also separates
|
|
<code>xlsx</code> from old Excel MIME handling and keeps <code>txt</code> and
|
|
<code>csv</code> distinct.
|
|
</div>
|
|
</div>
|
|
|
|
<h2 id="blocked-extensions">Blocked extensions</h2>
|
|
|
|
<p>
|
|
The guard maintains a large denylist in <code>$blockedExtensions</code> to
|
|
stop common executable, script, archive, config, and sensitive file types.
|
|
</p>
|
|
|
|
<table>
|
|
<thead>
|
|
<tr><th>Category</th><th>Examples</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr><td>PHP / server code</td><td><code>php</code>, <code>phtml</code>, <code>phar</code>, <code>jsp</code>, <code>asp</code>, <code>aspx</code></td></tr>
|
|
<tr><td>Scripts</td><td><code>js</code>, <code>ts</code>, <code>jsx</code>, <code>tsx</code>, <code>sh</code>, <code>bash</code>, <code>ps1</code>, <code>bat</code>, <code>cmd</code></td></tr>
|
|
<tr><td>Binaries</td><td><code>exe</code>, <code>dll</code>, <code>msi</code>, <code>apk</code>, <code>deb</code>, <code>rpm</code>, <code>bin</code></td></tr>
|
|
<tr><td>Archives</td><td><code>zip</code>, <code>rar</code>, <code>7z</code>, <code>tar</code>, <code>gz</code>, <code>iso</code></td></tr>
|
|
<tr><td>Config / secrets</td><td><code>env</code>, <code>ini</code>, <code>htaccess</code>, <code>htpasswd</code>, <code>key</code>, <code>pem</code>, <code>p12</code></td></tr>
|
|
<tr><td>Database / logs</td><td><code>sql</code>, <code>db</code>, <code>sqlite</code>, <code>log</code>, <code>bak</code></td></tr>
|
|
<tr><td>Markup / risky text</td><td><code>html</code>, <code>htm</code>, <code>xhtml</code>, <code>xml</code>, <code>svg</code></td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<p>
|
|
Multiple extensions are handled defensively. If a filename like
|
|
<code>invoice.php.pdf</code> or <code>report.jpg.js</code> contains any blocked
|
|
extension in its middle segments, the file is rejected.
|
|
</p>
|
|
|
|
<h2 id="validation-flow">Validation flow</h2>
|
|
|
|
<p>
|
|
Each uploaded file passes through this validation order:
|
|
</p>
|
|
|
|
<ol class="steps">
|
|
<li>
|
|
<strong>Upload validity check</strong>
|
|
<p>If PHP reports an invalid upload and the error is a server/form size issue, the guard blocks immediately.</p>
|
|
</li>
|
|
<li>
|
|
<strong>Filename safety check</strong>
|
|
<p>Rejects null bytes, path separators, and filenames longer than 255 characters.</p>
|
|
</li>
|
|
<li>
|
|
<strong>Multiple-extension detection</strong>
|
|
<p>Rejects files that hide blocked extensions inside multi-part names.</p>
|
|
</li>
|
|
<li>
|
|
<strong>Forbidden extension check</strong>
|
|
<p>Rejects uploads whose client extension is directly on the blocked list.</p>
|
|
</li>
|
|
<li>
|
|
<strong>File size limit</strong>
|
|
<p>The hard application limit is <code>25 MB</code>.</p>
|
|
</li>
|
|
<li>
|
|
<strong>Real MIME detection</strong>
|
|
<p>Uses PHP <code>finfo(FILEINFO_MIME_TYPE)</code> on the temporary uploaded file.</p>
|
|
</li>
|
|
<li>
|
|
<strong>Expected MIME resolution</strong>
|
|
<p>Maps the client extension to one expected MIME from the allowlist.</p>
|
|
</li>
|
|
<li>
|
|
<strong>Magic byte validation</strong>
|
|
<p>Checks the actual file header against known signatures for supported formats.</p>
|
|
</li>
|
|
<li>
|
|
<strong>Strict MIME match</strong>
|
|
<p>The detected MIME must match the expected MIME exactly; generic fallback MIME values are not accepted.</p>
|
|
</li>
|
|
</ol>
|
|
|
|
<pre><code class="language-php">if ($request->getMethod() !== 'post') {
|
|
return;
|
|
}
|
|
|
|
$files = $request->getFiles();
|
|
if (empty($files)) {
|
|
return;
|
|
}</code></pre>
|
|
|
|
<h2 id="magic-bytes-check">Magic bytes check</h2>
|
|
|
|
<p>
|
|
The guard performs deep header checks using <code>$magicBytes</code> for
|
|
several formats:
|
|
</p>
|
|
|
|
<table>
|
|
<thead>
|
|
<tr><th>Type</th><th>Signature rule</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr><td>JPEG</td><td><code>FF D8 FF</code></td></tr>
|
|
<tr><td>PNG</td><td><code>89 50 4E 47 0D 0A 1A 0A</code></td></tr>
|
|
<tr><td>GIF</td><td><code>GIF87a</code> or <code>GIF89a</code></td></tr>
|
|
<tr><td>PDF</td><td><code>%PDF-</code></td></tr>
|
|
<tr><td>DOC / XLS (legacy)</td><td><code>D0 CF 11 E0</code></td></tr>
|
|
<tr><td>DOCX / XLSX / ODT / ODS</td><td><code>PK 03 04</code></td></tr>
|
|
<tr><td>WebP</td><td>Special-case check for <code>RIFF....WEBP</code></td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<p>
|
|
There is also an <code>scanForEmbeddedCode()</code> method in the filter, but
|
|
its invocation is currently commented out. The active protection path today is
|
|
the filename, extension, MIME, and magic-byte validation sequence.
|
|
</p>
|
|
|
|
<h2 id="route-coverage">Route coverage</h2>
|
|
|
|
<p>
|
|
This guard is registered in two relevant places:
|
|
</p>
|
|
|
|
<table>
|
|
<thead>
|
|
<tr><th>Location</th><th>Effect</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>app/Config/Filters.php</code> global <code>before</code> filters</td>
|
|
<td>Applies the guard to incoming requests globally before controller execution.</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>app/Config/Routes.php</code> <code>employeeRest</code> group</td>
|
|
<td>Also explicitly includes <code>GlobalPostFileUploadGuard</code> alongside rate-limit, app-signature, and JWT auth filters.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<pre><code class="language-php">$routes->group("employeeRest", ["filter" => ['GlobalPostFileUploadGuard', 'ratelimit', 'appSignature', 'authJWT']], function ($routes) {
|
|
// upload-related endpoints live here
|
|
});</code></pre>
|
|
|
|
<h2 id="blocked-response">Blocked response</h2>
|
|
|
|
<p>
|
|
When the filter rejects a file, it logs a critical event and immediately sends
|
|
a JSON error response with HTTP <code>403</code>.
|
|
</p>
|
|
|
|
<table>
|
|
<thead>
|
|
<tr><th>Response field</th><th>Meaning</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr><td><code>status</code></td><td><code>error</code></td></tr>
|
|
<tr><td><code>message</code></td><td>Security-policy rejection message including the reason.</td></tr>
|
|
<tr><td><code>debug</code></td><td>Detailed rejection reason only when <code>ENVIRONMENT === 'development'</code>.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<pre><code class="language-json">{
|
|
"status": "error",
|
|
"message": "File upload rejected: Security policy violation. Reason: MIME-extension mismatch.",
|
|
"debug": "MIME-extension mismatch"
|
|
}</code></pre>
|
|
|
|
<p>
|
|
The log entry includes the block reason, client IP, URI, input field, original
|
|
filename, MIME, extension, and size.
|
|
</p>
|
|
|
|
<h2 id="operational-notes">Operational notes</h2>
|
|
|
|
<ol class="steps">
|
|
<li>
|
|
<strong>Controller code never sees blocked files</strong>
|
|
<p>The filter sends the response directly and exits, so later controller logic does not run for rejected uploads.</p>
|
|
</li>
|
|
<li>
|
|
<strong>Client extension alone is never trusted</strong>
|
|
<p>The extension is only used to resolve the expected MIME; the real file MIME and header still have to match.</p>
|
|
</li>
|
|
<li>
|
|
<strong>25 MB is the app-level limit</strong>
|
|
<p>Server-side PHP upload limits can still reject larger files earlier, and the filter explicitly handles that error path.</p>
|
|
</li>
|
|
<li>
|
|
<strong>False positives are possible if MIME support differs by environment</strong>
|
|
<p>Because the check is strict, any environment mismatch in MIME detection can cause a block until the allowlist is updated deliberately.</p>
|
|
</li>
|
|
</ol>
|
|
|
|
<div class="callout success">
|
|
<span>+</span>
|
|
<div>
|
|
<strong>Practical takeaway</strong>
|
|
This is a defensive upload gate, not just a UI validator. If a new file type
|
|
must be accepted, update the allowlist, magic-byte rules, and operational
|
|
expectations together rather than changing only the frontend.
|
|
</div>
|
|
</div>
|