parser = new FileUploadParser(); } protected function tearDown(): void { // Clean up any temporary files created during tests $tempDir = sys_get_temp_dir(); $files = glob($tempDir . '/upload_*'); foreach ($files as $file) { if (is_file($file)) { unlink($file); } } } public function testParseMultipartSingleFile(): void { $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"upload\"; filename=\"test.txt\"\r\n" . "Content-Type: text/plain\r\n" . "\r\n" . "Hello World!\r\n" . "------FormBoundary123--\r\n"; $result = $this->parser->parseMultipart($body, $boundary); $this->assertCount(1, $result->all()); $file = $result->get('upload'); $this->assertNotNull($file); $this->assertSame('test.txt', $file->name); $this->assertSame('text/plain', $file->type); $this->assertSame(12, $file->size); // "Hello World!" length $this->assertSame(UploadError::OK, $file->error); $this->assertTrue(file_exists($file->tmpName)); $this->assertSame('Hello World!', file_get_contents($file->tmpName)); } public function testParseMultipartMultipleFiles(): void { $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"file1\"; filename=\"test1.txt\"\r\n" . "Content-Type: text/plain\r\n" . "\r\n" . "Content 1\r\n" . "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"file2\"; filename=\"test2.txt\"\r\n" . "Content-Type: text/plain\r\n" . "\r\n" . "Content 2\r\n" . "------FormBoundary123--\r\n"; $result = $this->parser->parseMultipart($body, $boundary); $this->assertCount(2, $result->all()); $file1 = $result->get('file1'); $file2 = $result->get('file2'); $this->assertNotNull($file1); $this->assertNotNull($file2); $this->assertSame('test1.txt', $file1->name); $this->assertSame('test2.txt', $file2->name); $this->assertSame('Content 1', file_get_contents($file1->tmpName)); $this->assertSame('Content 2', file_get_contents($file2->tmpName)); } public function testParseMultipartFileArray(): void { $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"files[]\"; filename=\"file1.txt\"\r\n" . "Content-Type: text/plain\r\n" . "\r\n" . "File 1\r\n" . "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"files[]\"; filename=\"file2.txt\"\r\n" . "Content-Type: text/plain\r\n" . "\r\n" . "File 2\r\n" . "------FormBoundary123--\r\n"; $result = $this->parser->parseMultipart($body, $boundary); $files = $result->get('files'); $this->assertIsArray($files); $this->assertCount(2, $files); $this->assertSame('file1.txt', $files[0]->name); $this->assertSame('file2.txt', $files[1]->name); $this->assertSame('File 1', file_get_contents($files[0]->tmpName)); $this->assertSame('File 2', file_get_contents($files[1]->tmpName)); } public function testParseMultipartNestedFileArray(): void { $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"docs[legal][]\"; filename=\"contract.pdf\"\r\n" . "Content-Type: application/pdf\r\n" . "\r\n" . "%PDF-1.4 PDF content here\r\n" . // Add PDF signature to match MIME type "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"docs[images][0]\"; filename=\"logo.png\"\r\n" . "Content-Type: image/png\r\n" . "\r\n" . "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A" . "PNG content here\r\n" . // Add PNG signature "------FormBoundary123--\r\n"; $result = $this->parser->parseMultipart($body, $boundary); $docs = $result->get('docs'); $this->assertIsArray($docs); $this->assertArrayHasKey('legal', $docs); $this->assertArrayHasKey('images', $docs); $legalFiles = $docs['legal']; $this->assertIsArray($legalFiles); $this->assertCount(1, $legalFiles); $this->assertSame('contract.pdf', $legalFiles[0]->name); $this->assertSame('application/pdf', $legalFiles[0]->type); $imageFiles = $docs['images']; $this->assertIsArray($imageFiles); $this->assertSame('logo.png', $imageFiles['0']->name); $this->assertSame('image/png', $imageFiles['0']->type); } public function testParseMultipartWithoutFilename(): void { $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"data\"\r\n" . "Content-Type: text/plain\r\n" . "\r\n" . "Just regular form data\r\n" . "------FormBoundary123--\r\n"; $result = $this->parser->parseMultipart($body, $boundary); // Should not create any files for parts without filename $this->assertCount(0, $result->all()); } public function testParseMultipartDefaultContentType(): void { // Use a parser with relaxed security for this test $config = new ParserConfig(validateFileExtensions: false, scanForMaliciousContent: false); $parser = new FileUploadParser($config); $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"upload\"; filename=\"binary.dat\"\r\n" . "\r\n" . "Binary data here\r\n" . "------FormBoundary123--\r\n"; $result = $parser->parseMultipart($body, $boundary); $file = $result->get('upload'); $this->assertNotNull($file); $this->assertSame('application/octet-stream', $file->type); } public function testParseMultipartRfc2231ExtendedFilename(): void { $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"upload\"; filename*=UTF-8''caf%C3%A9.txt\r\n" . "Content-Type: text/plain\r\n" . "\r\n" . "Content\r\n" . "------FormBoundary123--\r\n"; $result = $this->parser->parseMultipart($body, $boundary); $file = $result->get('upload'); $this->assertNotNull($file); $this->assertSame('café.txt', $file->name); // Should be properly decoded } public function testParseMultipartRfc2231InvalidFormat(): void { $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"upload\"; filename*=invalid_format\r\n" . "Content-Type: text/plain\r\n" . "\r\n" . "Content\r\n" . "------FormBoundary123--\r\n"; $result = $this->parser->parseMultipart($body, $boundary); $file = $result->get('upload'); $this->assertNotNull($file); $this->assertSame('invalid_format', $file->name); // Should return as-is for invalid format } public function testParseMultipartEmptyFile(): void { $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"upload\"; filename=\"empty.txt\"\r\n" . "Content-Type: text/plain\r\n" . "\r\n" . "\r\n" . "------FormBoundary123--\r\n"; $result = $this->parser->parseMultipart($body, $boundary); $file = $result->get('upload'); $this->assertNotNull($file); $this->assertSame('empty.txt', $file->name); $this->assertSame(0, $file->size); $this->assertSame('', file_get_contents($file->tmpName)); } public function testParseMultipartMalformedParts(): void { $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Malformed part without proper headers\r\n" . "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"valid\"; filename=\"test.txt\"\r\n" . "\r\n" . "Valid content\r\n" . "------FormBoundary123--\r\n"; $result = $this->parser->parseMultipart($body, $boundary); // Should ignore malformed parts and process valid ones $this->assertCount(1, $result->all()); $file = $result->get('valid'); $this->assertNotNull($file); $this->assertSame('test.txt', $file->name); } public function testParseMultipartMissingName(): void { $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; filename=\"test.txt\"\r\n" . "Content-Type: text/plain\r\n" . "\r\n" . "Content\r\n" . "------FormBoundary123--\r\n"; $result = $this->parser->parseMultipart($body, $boundary); // Should ignore parts without name attribute $this->assertCount(0, $result->all()); } public function testParseMultipartQuotedValues(): void { $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"upload\"; filename=\"my file.txt\"\r\n" . "Content-Type: text/plain\r\n" . "\r\n" . "Content with spaces in filename\r\n" . "------FormBoundary123--\r\n"; $result = $this->parser->parseMultipart($body, $boundary); $file = $result->get('upload'); $this->assertNotNull($file); $this->assertSame('my file.txt', $file->name); } public function testParseMultipartLargeFile(): void { $boundary = '----FormBoundary123'; $largeContent = str_repeat('A', 10000); // 10KB of 'A's $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"large\"; filename=\"large.txt\"\r\n" . "Content-Type: text/plain\r\n" . "\r\n" . $largeContent . "\r\n" . "------FormBoundary123--\r\n"; $result = $this->parser->parseMultipart($body, $boundary); $file = $result->get('large'); $this->assertNotNull($file); $this->assertSame(10000, $file->size); $this->assertSame($largeContent, file_get_contents($file->tmpName)); } public function testTemporaryFileCleanup(): void { $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"upload\"; filename=\"test.txt\"\r\n" . "Content-Type: text/plain\r\n" . "\r\n" . "Test content\r\n" . "------FormBoundary123--\r\n"; $result = $this->parser->parseMultipart($body, $boundary); $file = $result->get('upload'); $tmpPath = $file->tmpName; $this->assertTrue(file_exists($tmpPath)); // Simulate script end - the shutdown function should clean up // We can't easily test this automatically, but the file path is registered // for cleanup in the shutdown function $this->assertStringStartsWith(sys_get_temp_dir() . '/upload_', $tmpPath); } // Security Tests public function testBoundaryTooLong(): void { $config = new ParserConfig(maxBoundaryLength: 10); $parser = new FileUploadParser($config); $this->expectException(ParserSecurityException::class); $this->expectExceptionMessage('Multipart boundary too long'); $longBoundary = str_repeat('a', 15); $body = "--{$longBoundary}\r\nContent-Disposition: form-data; name=\"test\"; filename=\"test.txt\"\r\n\r\nvalue\r\n--{$longBoundary}--\r\n"; $parser->parseMultipart($body, $longBoundary); } public function testFileCountExceeded(): void { $config = new ParserConfig(maxFileCount: 2); $parser = new FileUploadParser($config); $this->expectException(ParserSecurityException::class); $this->expectExceptionMessage('File count exceeded'); $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"file1\"; filename=\"test1.txt\"\r\n\r\nContent 1\r\n" . "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"file2\"; filename=\"test2.txt\"\r\n\r\nContent 2\r\n" . "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"file3\"; filename=\"test3.txt\"\r\n\r\nContent 3\r\n" . "------FormBoundary123--\r\n"; $parser->parseMultipart($body, $boundary); } public function testFileSizeExceeded(): void { $config = new ParserConfig(maxFileSize: new Byte(50)); $parser = new FileUploadParser($config); $this->expectException(ParserSecurityException::class); $this->expectExceptionMessage('File size exceeded'); $boundary = '----FormBoundary123'; $largeContent = str_repeat('a', 100); // Exceeds 50 byte limit $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"upload\"; filename=\"large.txt\"\r\n\r\n" . "{$largeContent}\r\n" . "------FormBoundary123--\r\n"; $parser->parseMultipart($body, $boundary); } public function testTotalUploadSizeExceeded(): void { $config = new ParserConfig(maxTotalUploadSize: new Byte(100)); $parser = new FileUploadParser($config); $this->expectException(ParserSecurityException::class); $this->expectExceptionMessage('Total upload size exceeded'); $boundary = '----FormBoundary123'; $content1 = str_repeat('a', 60); // 60 bytes $content2 = str_repeat('b', 50); // 50 bytes - total 110 bytes > 100 limit $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"file1\"; filename=\"file1.txt\"\r\n\r\n" . "{$content1}\r\n" . "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"file2\"; filename=\"file2.txt\"\r\n\r\n" . "{$content2}\r\n" . "------FormBoundary123--\r\n"; $parser->parseMultipart($body, $boundary); } public function testBlockedFileExtension(): void { $config = new ParserConfig(blockedFileExtensions: ['php', 'exe']); $parser = new FileUploadParser($config); $this->expectException(ParserSecurityException::class); $this->expectExceptionMessage('File extension blocked'); $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"upload\"; filename=\"malicious.php\"\r\n\r\n" . "\r\n" . "------FormBoundary123--\r\n"; $parser->parseMultipart($body, $boundary); } public function testFileExtensionNotAllowed(): void { $config = new ParserConfig(allowedFileExtensions: ['txt', 'jpg']); $parser = new FileUploadParser($config); $this->expectException(ParserSecurityException::class); $this->expectExceptionMessage('File extension not allowed'); $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"upload\"; filename=\"document.pdf\"\r\n\r\n" . "PDF content\r\n" . "------FormBoundary123--\r\n"; $parser->parseMultipart($body, $boundary); } public function testMaliciousExecutableContent(): void { $config = new ParserConfig( scanForMaliciousContent: true, allowedFileExtensions: ['exe', 'txt'], // Allow exe to test content validation blockedFileExtensions: [] // Remove blocked extensions to test content ); $parser = new FileUploadParser($config); $this->expectException(ParserSecurityException::class); $this->expectExceptionMessage('Executable content detected'); $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"upload\"; filename=\"malware.exe\"\r\n\r\n" . "\x4D\x5A" . str_repeat("x", 100) . "\r\n" . // PE executable signature "------FormBoundary123--\r\n"; $parser->parseMultipart($body, $boundary); } public function testMaliciousPhpContent(): void { $config = new ParserConfig( scanForMaliciousContent: true, allowedFileExtensions: ['txt', 'php'], // Allow txt to test content validation blockedFileExtensions: [] // Remove blocked extensions to test content ); $parser = new FileUploadParser($config); $this->expectException(ParserSecurityException::class); $this->expectExceptionMessage('PHP code detected'); $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"upload\"; filename=\"shell.txt\"\r\n\r\n" . "\r\n" . "------FormBoundary123--\r\n"; $parser->parseMultipart($body, $boundary); } public function testMaliciousScriptContent(): void { $config = new ParserConfig( scanForMaliciousContent: true, allowedFileExtensions: ['html', 'txt'], // Allow html to test content validation blockedFileExtensions: [] // Remove blocked extensions to test content ); $parser = new FileUploadParser($config); $this->expectException(ParserSecurityException::class); $this->expectExceptionMessage('Suspicious script content detected'); $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"upload\"; filename=\"xss.html\"\r\n\r\n" . "\r\n" . "------FormBoundary123--\r\n"; $parser->parseMultipart($body, $boundary); } public function testMaliciousEvalContent(): void { $config = new ParserConfig( scanForMaliciousContent: true, allowedFileExtensions: ['js', 'txt'], // Allow js to test content validation blockedFileExtensions: [] // Remove blocked extensions to test content ); $parser = new FileUploadParser($config); $this->expectException(ParserSecurityException::class); $this->expectExceptionMessage('Suspicious script content detected'); $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"upload\"; filename=\"evil.js\"\r\n\r\n" . "eval(atob('YWxlcnQoJ2hhY2snKQ=='))\r\n" . "------FormBoundary123--\r\n"; $parser->parseMultipart($body, $boundary); } public function testMimeTypeMismatch(): void { $config = new ParserConfig(scanForMaliciousContent: true, strictMimeTypeValidation: true); $parser = new FileUploadParser($config); $this->expectException(ParserSecurityException::class); $this->expectExceptionMessage('MIME type mismatch'); $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"upload\"; filename=\"fake.jpg\"\r\n" . "Content-Type: image/jpeg\r\n\r\n" . "%PDF-1.4 This is actually a PDF file\r\n" . // PDF signature but claiming to be JPEG "------FormBoundary123--\r\n"; $parser->parseMultipart($body, $boundary); } public function testShellScriptDetection(): void { $config = new ParserConfig(scanForMaliciousContent: true); $parser = new FileUploadParser($config); $this->expectException(ParserSecurityException::class); $this->expectExceptionMessage('Executable content detected'); $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"upload\"; filename=\"script.txt\"\r\n\r\n" . "#!/bin/bash\nrm -rf /\r\n" . "------FormBoundary123--\r\n"; $parser->parseMultipart($body, $boundary); } public function testSystemCallDetection(): void { $config = new ParserConfig(scanForMaliciousContent: true); $parser = new FileUploadParser($config); $this->expectException(ParserSecurityException::class); $this->expectExceptionMessage('Suspicious script content detected'); $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"upload\"; filename=\"backdoor.txt\"\r\n\r\n" . "system('cat /etc/passwd')\r\n" . "------FormBoundary123--\r\n"; $parser->parseMultipart($body, $boundary); } // Security Configuration Tests public function testSecurityDisabled(): void { $config = new ParserConfig( scanForMaliciousContent: false, validateFileExtensions: false, allowedFileExtensions: [], // Empty to allow all when validation disabled blockedFileExtensions: [], // Empty to not block anything when validation disabled maxFileCount: 1000, maxFileSize: new Byte(10 * 1024 * 1024), maxTotalUploadSize: new Byte(100 * 1024 * 1024) ); $parser = new FileUploadParser($config); // Should not throw exception when security is disabled $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"upload\"; filename=\"malicious.php\"\r\n\r\n" . "\r\n" . "------FormBoundary123--\r\n"; $result = $parser->parseMultipart($body, $boundary); $file = $result->get('upload'); $this->assertNotNull($file); $this->assertSame('malicious.php', $file->name); } public function testWithinSecurityLimits(): void { $config = new ParserConfig( maxFileCount: 5, maxFileSize: new Byte(1024), maxTotalUploadSize: new Byte(5 * 1024), allowedFileExtensions: ['txt', 'jpg', 'png'], scanForMaliciousContent: true ); $parser = new FileUploadParser($config); // Should work fine within limits $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"file1\"; filename=\"test1.txt\"\r\n\r\n" . "Safe content 1\r\n" . "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"file2\"; filename=\"image.jpg\"\r\n\r\n" . "\xFF\xD8\xFF" . str_repeat('x', 10) . "\r\n" . // Valid JPEG signature "------FormBoundary123--\r\n"; $result = $parser->parseMultipart($body, $boundary); $this->assertCount(2, $result->all()); $file1 = $result->get('file1'); $file2 = $result->get('file2'); $this->assertNotNull($file1); $this->assertNotNull($file2); $this->assertSame('test1.txt', $file1->name); $this->assertSame('image.jpg', $file2->name); } public function testAllowedExtensionsEmptyList(): void { $config = new ParserConfig( allowedFileExtensions: [], // Empty list should allow all (except blocked) blockedFileExtensions: ['exe'], scanForMaliciousContent: false ); $parser = new FileUploadParser($config); // Should allow PDF when allowed list is empty $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"upload\"; filename=\"document.pdf\"\r\n\r\n" . "PDF content\r\n" . "------FormBoundary123--\r\n"; $result = $parser->parseMultipart($body, $boundary); $file = $result->get('upload'); $this->assertNotNull($file); $this->assertSame('document.pdf', $file->name); } public function testCompatibleMimeTypes(): void { $config = new ParserConfig(scanForMaliciousContent: true, strictMimeTypeValidation: true); $parser = new FileUploadParser($config); // Should allow compatible MIME types (image/jpg vs image/jpeg) $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"upload\"; filename=\"photo.jpg\"\r\n" . "Content-Type: image/jpg\r\n\r\n" . "\xFF\xD8\xFF" . str_repeat('x', 10) . "\r\n" . // Valid JPEG signature "------FormBoundary123--\r\n"; $result = $parser->parseMultipart($body, $boundary); $file = $result->get('upload'); $this->assertNotNull($file); $this->assertSame('photo.jpg', $file->name); } public function testNoExtensionFile(): void { $config = new ParserConfig(validateFileExtensions: true, allowedFileExtensions: ['txt']); $parser = new FileUploadParser($config); // Should allow files without extension (no validation performed) $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"upload\"; filename=\"README\"\r\n\r\n" . "This is a README file\r\n" . "------FormBoundary123--\r\n"; $result = $parser->parseMultipart($body, $boundary); $file = $result->get('upload'); $this->assertNotNull($file); $this->assertSame('README', $file->name); } public function testEmptyFilename(): void { $config = new ParserConfig(validateFileExtensions: true, allowedFileExtensions: ['txt']); $parser = new FileUploadParser($config); // Should allow empty filename (no validation performed) $boundary = '----FormBoundary123'; $body = "------FormBoundary123\r\n" . "Content-Disposition: form-data; name=\"upload\"; filename=\"\"\r\n\r\n" . "Content without filename\r\n" . "------FormBoundary123--\r\n"; $result = $parser->parseMultipart($body, $boundary); $file = $result->get('upload'); $this->assertNotNull($file); $this->assertSame('', $file->name); } }