nhance/app/Views/docs/file-uploads.php
2026-05-18 12:28:19 +05:30

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-&gt;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>