A secure multi-user file-sharing service where authenticated users upload allow-listed files into out-of-webroot storage and hand out short-lived, cryptographically signed download links that a controller streams rather than exposing the disk path.
The platform lets a registered user upload a file, attach optional metadata, and generate a signed, expiring share link that anyone with the URL can use to download the file exactly once or until it expires. Files never live under the web root, are renamed to opaque IDs on disk, and are served through a PHP controller that authorizes the request, verifies the signature, and streams bytes with a forced Content-Disposition: attachment.
Threat model. The attacker is an anonymous internet client who may hold a leaked link, plus a low-privilege authenticated user probing for others' files. Concretely we defend against: unrestricted upload / web-shell drop (RCE), path traversal and direct-object access (IDOR), link forgery and expiry bypass, XSS via filenames/metadata, CSRF on upload/delete, and content-type sniffing. Out of scope: network-layer DoS, malware content scanning (we hook a stub), and compromise of the signing key itself (rotate via env).
Front controller routes every request; controllers depend on repositories that own all PDO access. Uploaded bytes go to a storage path outside public/, so there is no URL that maps to a file on disk — downloads are always mediated.
flowchart LR
U[Browser] -->|HTTPS| N[nginx]
N --> F[php-fpm: public/index.php]
F --> R[Router]
R --> UC[UploadController]
R --> DC[DownloadController]
R --> AC[AuthController]
UC --> FS[FileService + Validator]
FS --> ST[(storage/ out-of-webroot)]
UC --> FR[FileRepository -> PDO]
DC --> SL[SignedLink verify]
DC --> ST
FR --> DB[(MySQL)]
AC --> UR[UserRepository -> PDO]
Data flow for a download: client hits /d?id=…&exp=…&sig=… → SignedLink::verify() recomputes the HMAC over id|exp with hash_equals() and checks exp → repository loads the row → controller readfile()s from storage with hardened headers.
file-share/
├── public/ # the ONLY web-served directory
│ ├── index.php # front controller
│ └── assets/style.css
├── src/
│ ├── Http/Router.php
│ ├── Http/Csrf.php
│ ├── Controllers/{AuthController,UploadController,DownloadController}.php
│ ├── Repositories/{UserRepository,FileRepository}.php
│ ├── Services/{FileService,SignedLink,Validator}.php
│ └── Support/{Db,Session,Config}.php
├── storage/ # OUT of webroot — uploaded blobs live here
│ └── .gitignore
├── migrations/001_init.sql
├── tests/{SignedLinkTest,ValidatorTest}.php
├── composer.json # PSR-4: "App\\": "src/"
├── docker-compose.yml
├── Dockerfile
└── nginx.conf
CREATE TABLE users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role ENUM('user','admin') NOT NULL DEFAULT 'user',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE files (
id CHAR(32) PRIMARY KEY, -- opaque disk id (bin2hex(random_bytes(16)))
owner_id BIGINT UNSIGNED NOT NULL,
orig_name VARCHAR(255) NOT NULL, -- display only, always escaped on output
mime VARCHAR(127) NOT NULL,
size_bytes BIGINT UNSIGNED NOT NULL,
sha256 CHAR(64) NOT NULL,
downloads INT UNSIGNED NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_files_owner FOREIGN KEY (owner_id)
REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;Front controller + router. Every route is dispatched from one file; no other PHP is reachable from the web.
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use App\Http\Router;
use App\Support\Session;
Session::start(); // hardened session (below)
$router = new Router();
$router->get('/', [App\Controllers\UploadController::class, 'index']);
$router->post('/upload', [App\Controllers\UploadController::class, 'store']);
$router->get('/d', [App\Controllers\DownloadController::class, 'show']);
$router->post('/login', [App\Controllers\AuthController::class, 'login']);
$router->dispatch($_SERVER['REQUEST_METHOD'], parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));Hardened session.
<?php
declare(strict_types=1);
namespace App\Support;
final class Session
{
public static function start(): void
{
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Strict',
]);
session_start();
if (!isset($_SESSION['_born'])) {
session_regenerate_id(true); // prevent fixation
$_SESSION['_born'] = time();
}
}
}PDO data layer — the single place SQL is executed, always parameterized.
<?php
declare(strict_types=1);
namespace App\Support;
use PDO;
final class Db
{
private static ?PDO $pdo = null;
public static function conn(): PDO
{
if (self::$pdo === null) {
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4',
getenv('DB_HOST'), getenv('DB_NAME'));
self::$pdo = new PDO($dsn, getenv('DB_USER'), getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // real prepared statements
]);
}
return self::$pdo;
}
}<?php
declare(strict_types=1);
namespace App\Repositories;
use App\Support\Db;
final class FileRepository
{
public function insert(array $f): void
{
// Never concatenate — bound params only.
$sql = 'INSERT INTO files (id, owner_id, orig_name, mime, size_bytes, sha256)
VALUES (:id, :owner, :name, :mime, :size, :hash)';
Db::conn()->prepare($sql)->execute([
':id' => $f['id'], ':owner' => $f['owner_id'], ':name' => $f['orig_name'],
':mime' => $f['mime'], ':size' => $f['size_bytes'], ':hash' => $f['sha256'],
]);
}
public function find(string $id): ?array
{
$st = Db::conn()->prepare('SELECT * FROM files WHERE id = :id');
$st->execute([':id' => $id]);
return $st->fetch() ?: null;
}
}Upload validation — allow-list by extension and sniffed MIME, cap size, rename to an opaque id, store out of webroot.
<?php
declare(strict_types=1);
namespace App\Services;
final class Validator
{
private const MAX = 25 * 1024 * 1024; // 25 MB
private const ALLOW = [ // allow-list, not deny-list
'pdf' => 'application/pdf',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'txt' => 'text/plain',
'zip' => 'application/zip',
];
/** @return array{ext:string,mime:string} */
public function check(array $file): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new \RuntimeException('Upload failed');
}
if (!is_uploaded_file($file['tmp_name']) || $file['size'] > self::MAX) {
throw new \RuntimeException('Rejected: size/source');
}
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$mime = (new \finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
if (!isset(self::ALLOW[$ext]) || self::ALLOW[$ext] !== $mime) {
throw new \RuntimeException('File type not allowed');
}
return ['ext' => $ext, 'mime' => $mime];
}
}// VULNERABLE — trusts the client filename, keeps the extension, writes under webroot:
move_uploaded_file($_FILES['f']['tmp_name'], "public/uploads/{$_FILES['f']['name']}");
// Attacker uploads shell.php -> requests /uploads/shell.php -> RCE.
// FIX — opaque id, no client-controlled path, storage/ is outside public/:
$id = bin2hex(random_bytes(16));
$dst = dirname(__DIR__, 2) . "/storage/$id"; // no extension on disk
move_uploaded_file($file['tmp_name'], $dst);CSRF tokens with constant-time comparison, checked on every state-changing POST.
<?php
declare(strict_types=1);
namespace App\Http;
final class Csrf
{
public static function token(): string
{
return $_SESSION['csrf'] ??= bin2hex(random_bytes(32));
}
public static function assert(string $sent): void
{
if (!isset($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $sent)) {
http_response_code(419);
exit('CSRF token mismatch');
}
}
}Signed download links — HMAC over id|exp, verified in constant time; the controller authorizes then streams.
<?php
declare(strict_types=1);
namespace App\Services;
final class SignedLink
{
private static function key(): string { return getenv('LINK_KEY') ?: ''; }
public static function make(string $id, int $ttl = 3600): string
{
$exp = time() + $ttl;
$sig = hash_hmac('sha256', "$id|$exp", self::key());
return sprintf('/d?id=%s&exp=%d&sig=%s', urlencode($id), $exp, $sig);
}
public static function verify(string $id, int $exp, string $sig): bool
{
if ($exp < time()) {
return false;
}
$calc = hash_hmac('sha256', "$id|$exp", self::key());
return hash_equals($calc, $sig);
}
}<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Repositories\FileRepository;
use App\Services\SignedLink;
final class DownloadController
{
public function show(): void
{
$id = (string)($_GET['id'] ?? '');
$exp = (int)($_GET['exp'] ?? 0);
$sig = (string)($_GET['sig'] ?? '');
if (!ctype_xdigit($id) || !SignedLink::verify($id, $exp, $sig)) {
http_response_code(403);
exit('Invalid or expired link');
}
$row = (new FileRepository())->find($id);
if ($row === null) {
http_response_code(404);
exit('Not found');
}
$path = dirname(__DIR__, 2) . "/storage/$id";
header('Content-Type: application/octet-stream'); // never the sniffed type
header('X-Content-Type-Options: nosniff');
header('Content-Disposition: attachment; filename="'
. rawurlencode($row['orig_name']) . '"'); // escaped display name
header('Content-Length: ' . $row['size_bytes']);
readfile($path);
}
}Output escaping — any metadata shown in HTML (filename lists, owner email) is escaped at the point of output.
<?php
declare(strict_types=1);
function e(string $s): string {
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
// echo '<td>' . e($file['orig_name']) . '</td>';Auth uses password_hash($pw, PASSWORD_DEFAULT) on register and password_verify() on login, with session_regenerate_id(true) on privilege change.
Test the security-critical pure logic hardest: signature verification (valid, tampered, expired), the upload allow-list (good ext+mime, mismatched, oversize, .php), and traversal rejection. Add integration tests for CSRF rejection and 403 on unsigned /d.
<?php
declare(strict_types=1);
use App\Services\SignedLink;
use PHPUnit\Framework\TestCase;
final class SignedLinkTest extends TestCase
{
protected function setUp(): void { putenv('LINK_KEY=test-secret'); }
public function testValidLinkVerifies(): void
{
$url = SignedLink::make('abc123', 3600);
parse_str(parse_url($url, PHP_URL_QUERY), $q);
self::assertTrue(SignedLink::verify($q['id'], (int)$q['exp'], $q['sig']));
}
public function testTamperedSignatureFails(): void
{
$url = SignedLink::make('abc123', 3600);
parse_str(parse_url($url, PHP_URL_QUERY), $q);
self::assertFalse(SignedLink::verify($q['id'], (int)$q['exp'], $q['sig'] . '0'));
}
public function testExpiredLinkFails(): void
{
$sig = hash_hmac('sha256', 'abc123|1', 'test-secret');
self::assertFalse(SignedLink::verify('abc123', 1, $sig));
}
}| Risk | OWASP 2021 | Mitigation |
|---|---|---|
| Web-shell upload / RCE | A03, A08 | Allow-list ext and finfo MIME, opaque disk name, storage out of webroot, no exec perms |
| Path traversal in id | A01 | ctype_xdigit() id, fixed base dir, no user path segments |
| IDOR on downloads | A01 | Access mediated by signed HMAC link; direct disk URL impossible |
| Link forgery / expiry bypass | A02, A08 | hash_hmac + hash_equals, server-side exp check, key from env |
| CSRF on upload/delete | A01 | Per-session token, hash_equals, SameSite=Strict cookie |
| XSS via filename/metadata | A03 | htmlspecialchars(ENT_QUOTES) on all output; download forces attachment |
| SQL injection | A03 | 100% PDO prepared statements, EMULATE_PREPARES=false |
| Weak credentials/storage | A07, A02 | password_hash/password_verify, session regen, Secure+HttpOnly cookies |
| Content sniffing | A05 | Content-Type: octet-stream + X-Content-Type-Options: nosniff |
Run with Docker Compose: an nginx container serving only public/, a php-fpm 8.3 container mounting the app (with storage/ on a separate volume), and mysql. nginx must pass only .php under public/ to fpm and return 404 for everything else, so storage/ is never web-reachable.
server {
listen 80;
root /app/public; # ONLY public/ is served
index index.php;
location / { try_files $uri /index.php?$query_string; }
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass php:9000;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
}
client_max_body_size 26m; # match Validator::MAX
}Set DB_*, LINK_KEY (32+ random bytes), and php.ini limits (upload_max_filesize, post_max_size) via environment; run composer install --no-dev and apply migrations/001_init.sql on first boot. Terminate TLS at nginx so the Secure cookie flag is honored.
- Add one-time links: mark a share consumed after first successful download (atomic
UPDATE … WHERE downloads = 0). - Add per-user quotas and rate-limit uploads by IP + user id.
- Integrate a ClamAV scan step in
FileServicebefore the file is committed. - Add encrypted-at-rest storage (libsodium
crypto_secretstream) with a per-file key wrapped by the master key. - Replace HMAC links with short-lived JWTs and compare the trade-offs.
- OWASP Cheat Sheet: File Upload
- OWASP Cheat Sheet: Cross-Site Request Forgery Prevention
- PHP Manual:
password_hash,hash_hmac,hash_equals,finfo - PHP-FIG PSR-4 (autoloading) and PSR-12 (coding style)
- Prepared-Statements — the PDO pattern every repository here relies on
- CSRF-Tokens — the token/
hash_equalsscheme guarding uploads and deletes - Password-Based-Login — the
password_hash/password_verifyauth reused verbatim - Role-Based-Access-Control — extend the
rolecolumn to gate admin file management - Security-Audit-Checklist — the review lens applied to this build's risk table
- Docker-for-PHP — the nginx + php-fpm compose stack this project deploys on