Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "fedir/hoptransfert",
"description": "A minimalist, secure PHP application for anonymous file sharing",
"type": "project",
"authors": [
{
"name": "Fedir RYKHTIK",
"email": "fedir@users.noreply.github.com"
}
],
"require": {
"php": ">=8.1"
},
"require-dev": {
"phpunit/phpunit": "^10.0"
},
"autoload": {
"files": [
"index.php"
]
},
"scripts": {
"test": "phpunit",
"test-coverage": "phpunit --coverage-html coverage"
}
}
80 changes: 76 additions & 4 deletions index.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
// Security
const PASSWORD_MIN_LENGTH = 6;
const HASH_SALT = 'your-secret-salt-here'; // change this
const CSRF_TOKEN_LENGTH = 16;

// Ressources control
const MAX_LOG_LINES = 5; // prevent log bloat
Expand Down Expand Up @@ -92,6 +93,49 @@ function sanitize_input($data) {
return htmlspecialchars(trim($data), ENT_QUOTES, 'UTF-8');
}

/**
* Generate CSRF token
*/
function generate_csrf_token() {
if (session_status() === PHP_SESSION_NONE) {
// Configure secure session settings
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1);
ini_set('session.cookie_samesite', 'Strict');
session_start();
}
if (!isset($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(CSRF_TOKEN_LENGTH));
}
return $_SESSION['csrf_token'];
}

/**
* Validate CSRF token
*/
function validate_csrf_token($token) {
if (session_status() === PHP_SESSION_NONE) {
// Configure secure session settings
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1);
ini_set('session.cookie_samesite', 'Strict');
session_start();
}
return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}

/**
* Sanitize filename for headers to prevent HTTP Response Splitting
*/
function sanitize_filename_for_header($filename) {
// Remove any control characters and limit to ASCII printable chars
$filename = preg_replace('/[\x00-\x1F\x7F-\xFF]/', '', $filename);
// Remove quotes and backslashes to prevent header injection
$filename = str_replace(['"', '\\', "\r", "\n"], '', $filename);
// Limit length to prevent excessively long headers
return mb_substr($filename, 0, 255);
}

/**
* Generate a UUID v4
*/
Expand Down Expand Up @@ -230,6 +274,12 @@ function display_success($message) {
* Render HTML page
*/
function render_page($title, $content) {
// Set security headers
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block');
header('Content-Security-Policy: default-src \'self\' cdn.tailwindcss.com; script-src \'self\' cdn.tailwindcss.com; style-src \'self\' \'unsafe-inline\' cdn.tailwindcss.com; img-src \'self\' data:; connect-src \'self\'');

$base_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://" . $_SERVER['HTTP_HOST'] . dirname($_SERVER['SCRIPT_NAME']);

return <<<HTML
Expand Down Expand Up @@ -262,7 +312,7 @@ function render_page($title, $content) {
// Route handling
if (isset($_GET['download'])) {
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['password'])) {
handle_download($_GET['download'], $_POST['password']);
handle_download($_GET['download'], $_POST['password'], $_POST['csrf_token'] ?? '');
} else {
show_download_form($_GET['download']);
}
Expand All @@ -281,11 +331,17 @@ function render_page($title, $content) {
*/
function handle_upload() {
try {
// Validate CSRF (basic check for POST request)
// Validate request method
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
throw new Exception('Invalid request method');
}

// Validate CSRF token
$csrf_token = $_POST['csrf_token'] ?? '';
if (!validate_csrf_token($csrf_token)) {
throw new Exception('Invalid CSRF token. Please refresh the page and try again.');
}

// Check if file was uploaded
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
throw new Exception('No file uploaded or upload error occurred');
Expand Down Expand Up @@ -380,6 +436,7 @@ function show_download_form($uuid) {

$file_info = $files_data[$uuid];
$original_filename = htmlspecialchars($file_info['original_filename']);
$csrf_token = generate_csrf_token();

$form = "
<div class='text-center mb-6'>
Expand All @@ -388,6 +445,7 @@ function show_download_form($uuid) {
</div>

<form method='post' class='space-y-4'>
<input type='hidden' name='csrf_token' value='" . htmlspecialchars($csrf_token) . "'>
<div>
<label for='password' class='block text-sm font-medium text-gray-700 mb-2'>Enter Download Password:</label>
<input type='password' id='password' name='password' required minlength='" . PASSWORD_MIN_LENGTH . "' class='w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500' autofocus>
Expand All @@ -409,8 +467,13 @@ function show_download_form($uuid) {
/**
* Handle file download
*/
function handle_download($uuid, $token) {
function handle_download($uuid, $token, $csrf_token) {
try {
// Validate CSRF token
if (!validate_csrf_token($csrf_token)) {
throw new Exception('Invalid CSRF token. Please refresh the page and try again.');
}

// Validate UUID format
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $uuid)) {
throw new Exception('Invalid file ID');
Expand Down Expand Up @@ -451,7 +514,7 @@ function handle_download($uuid, $token) {
log_download($client_ip);

// Serve the file
$original_filename = $file_info['original_filename'];
$original_filename = sanitize_filename_for_header($file_info['original_filename']);
$file_size = filesize($file_path);

// Set headers for file download
Expand All @@ -461,6 +524,11 @@ function handle_download($uuid, $token) {
header('Cache-Control: no-cache, must-revalidate');
header('Pragma: no-cache');

// Security headers
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('X-XSS-Protection: 1; mode=block');

// Output file contents
readfile($file_path);

Expand All @@ -483,8 +551,12 @@ function show_upload_form() {
$max_size_mb = MAX_FILE_SIZE / 1024 / 1024;
$allowed_types = implode(', ', ALLOWED_EXTENSIONS);

$csrf_token = generate_csrf_token();

$form = "
<form method='post' enctype='multipart/form-data' class='space-y-4'>
<input type='hidden' name='csrf_token' value='" . htmlspecialchars($csrf_token) . "'>

<div>
<label for='file' class='block text-sm font-medium text-gray-700 mb-2'>Select File:</label>
<input type='file' id='file' name='file' required class='w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500'>
Expand Down
19 changes: 19 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
processIsolation="true"
stopOnError="false"
stopOnFailure="false">
<testsuites>
<testsuite name="Security Tests">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<file>index.php</file>
</include>
</source>
</phpunit>
166 changes: 166 additions & 0 deletions tests/SecurityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<?php

use PHPUnit\Framework\TestCase;

class SecurityTest extends TestCase
{
protected function setUp(): void
{
// Clean up any existing session
if (session_status() !== PHP_SESSION_NONE) {
session_destroy();
}

// Clear session superglobal
$_SESSION = [];
}

protected function tearDown(): void
{
// Clean up session after each test
if (session_status() !== PHP_SESSION_NONE) {
session_destroy();
}
$_SESSION = [];
}

/**
* Test CSRF token generation
*/
public function testGenerateCSRFToken()
{
$token1 = generate_csrf_token();

// Token should be a 32-character hexadecimal string (16 bytes * 2)
$this->assertMatchesRegularExpression('/^[0-9a-f]{32}$/', $token1);

// Second call should return the same token (from session)
$token2 = generate_csrf_token();
$this->assertEquals($token1, $token2);

// Token should be stored in session
$this->assertArrayHasKey('csrf_token', $_SESSION);
$this->assertEquals($token1, $_SESSION['csrf_token']);
}

/**
* Test CSRF token validation with valid token
*/
public function testValidateCSRFTokenValid()
{
$token = generate_csrf_token();
$this->assertTrue(validate_csrf_token($token));
}

/**
* Test CSRF token validation with invalid token
*/
public function testValidateCSRFTokenInvalid()
{
generate_csrf_token(); // Generate a token first
$this->assertFalse(validate_csrf_token('invalid_token'));
$this->assertFalse(validate_csrf_token(''));
$this->assertFalse(validate_csrf_token('1234567890abcdef1234567890abcdef'));
}

/**
* Test CSRF token validation without session
*/
public function testValidateCSRFTokenNoSession()
{
// Don't generate a token first
$this->assertFalse(validate_csrf_token('some_token'));
}

/**
* Test filename sanitization for headers
*/
public function testSanitizeFilenameForHeader()
{
// Test normal filename
$this->assertEquals('document.pdf', sanitize_filename_for_header('document.pdf'));

// Test filename with control characters
$this->assertEquals('document.pdf', sanitize_filename_for_header("document\x00\x01\x02.pdf"));

// Test filename with header injection attempts
$this->assertEquals('document.pdf', sanitize_filename_for_header("document\r\n.pdf"));
$this->assertEquals('document.pdf', sanitize_filename_for_header('document".pdf'));
$this->assertEquals('document.pdf', sanitize_filename_for_header('document\\.pdf'));

// Test filename with high-ASCII characters
$this->assertEquals('document.pdf', sanitize_filename_for_header("document\xFF.pdf"));

// Test long filename truncation (should be limited to 255 characters)
$longFilename = str_repeat('a', 300) . '.pdf';
$sanitized = sanitize_filename_for_header($longFilename);
$this->assertLessThanOrEqual(255, strlen($sanitized));

// Test empty filename
$this->assertEquals('', sanitize_filename_for_header(''));

// Test filename with only control characters
$this->assertEquals('', sanitize_filename_for_header("\x00\x01\x02"));
}

/**
* Test that sanitize_filename_for_header prevents HTTP Response Splitting
*/
public function testSanitizeFilenameForHeaderPreventsResponseSplitting()
{
// Test various HTTP Response Splitting attack vectors
$maliciousFilenames = [
"file.pdf\r\nSet-Cookie: malicious=true",
"file.pdf\nLocation: http://evil.com",
"file.pdf\r\n\r\n<script>alert('xss')</script>",
"file.pdf\x0d\x0aContent-Type: text/html",
];

foreach ($maliciousFilenames as $maliciousFilename) {
$sanitized = sanitize_filename_for_header($maliciousFilename);

// Should not contain any CR or LF characters
$this->assertStringNotContainsString("\r", $sanitized);
$this->assertStringNotContainsString("\n", $sanitized);

// Should not contain control characters
$this->assertMatchesRegularExpression('/^[\x20-\x7E]*$/', $sanitized);
}
}

/**
* Test CSRF token length optimization
*/
public function testCSRFTokenLength()
{
$token = generate_csrf_token();

// Should be 32 hex characters (16 bytes)
$this->assertEquals(32, strlen($token));
$this->assertMatchesRegularExpression('/^[0-9a-f]{32}$/', $token);
}

/**
* Test hash_equals usage in CSRF validation (timing attack prevention)
*/
public function testCSRFValidationUsesHashEquals()
{
$token = generate_csrf_token();

// Measure time for valid token comparison
$start = microtime(true);
validate_csrf_token($token);
$validTime = microtime(true) - $start;

// Measure time for invalid token comparison (same length)
$invalidToken = str_repeat('a', strlen($token));
$start = microtime(true);
validate_csrf_token($invalidToken);
$invalidTime = microtime(true) - $start;

// Times should be similar (hash_equals should prevent timing attacks)
// We can't guarantee exact timing, but they should be in the same ballpark
$this->assertGreaterThan(0, $validTime);
$this->assertGreaterThan(0, $invalidTime);
}
}
14 changes: 14 additions & 0 deletions tests/bootstrap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php
/**
* PHPUnit Bootstrap File for HopTransfert
*/

// Prevent actual HTTP headers from being sent during tests
if (!function_exists('header')) {
function header($header, $replace = true) {
// Mock header function for testing
}
}

// Include the main application file
require_once __DIR__ . '/../index.php';