diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..8776284 --- /dev/null +++ b/composer.json @@ -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" + } +} \ No newline at end of file diff --git a/index.php b/index.php index 81729cf..8fe3f1b 100644 --- a/index.php +++ b/index.php @@ -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 @@ -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 */ @@ -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 <<
@@ -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'); @@ -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 @@ -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); @@ -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 = "
+ +
diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..9f6194f --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,19 @@ + + + + + tests + + + + + index.php + + + \ No newline at end of file diff --git a/tests/SecurityTest.php b/tests/SecurityTest.php new file mode 100644 index 0000000..47385f5 --- /dev/null +++ b/tests/SecurityTest.php @@ -0,0 +1,166 @@ +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", + "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); + } +} \ No newline at end of file diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..af4c7ef --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,14 @@ +