From b78fc835f789e7a8543cc9190225e53f4076c78e Mon Sep 17 00:00:00 2001 From: Lee Jones Date: Tue, 13 Jan 2026 16:39:13 -0600 Subject: [PATCH 1/8] image validation --- src/Validation/IsImage.php | 178 +++++++++++++++++++++++++ tests/Validation/IsImageTest.php | 216 +++++++++++++++++++++++++++++++ 2 files changed, 394 insertions(+) create mode 100644 src/Validation/IsImage.php create mode 100644 tests/Validation/IsImageTest.php diff --git a/src/Validation/IsImage.php b/src/Validation/IsImage.php new file mode 100644 index 0000000..3e60b63 --- /dev/null +++ b/src/Validation/IsImage.php @@ -0,0 +1,178 @@ +allowedMimeTypes = $allowedMimeTypes; + $this->maxSize = $maxSize; + $this->checkImageData = $checkImageData; + } + + /** + * @param mixed $value + * @return bool + */ + protected function validate( mixed $value ) : bool + { + if( !is_string( $value ) ) + { + return false; + } + + // Empty string is not a valid image + if( $value === '' ) + { + return false; + } + + // Check if it's a data URI + if( strpos( $value, 'data:' ) === 0 ) + { + return $this->validateDataUri( $value ); + } + + // Otherwise, treat it as base64 encoded image data + return $this->validateBase64Image( $value ); + } + + /** + * Validates a data URI formatted image. + * + * @param string $dataUri + * @return bool + */ + private function validateDataUri( string $dataUri ) : bool + { + // Parse data URI: data:[][;base64], + $pattern = '/^data:([a-zA-Z0-9][a-zA-Z0-9\/+\-]*);base64,(.+)$/'; + + if( !preg_match( $pattern, $dataUri, $matches ) ) + { + return false; + } + + $mimeType = $matches[1]; + $base64Data = $matches[2]; + + // Check MIME type + if( !empty( $this->allowedMimeTypes ) && !in_array( $mimeType, $this->allowedMimeTypes, true ) ) + { + return false; + } + + // Validate base64 data + return $this->validateBase64Image( $base64Data ); + } + + /** + * Validates base64 encoded image data. + * + * @param string $base64Data + * @return bool + */ + private function validateBase64Image( string $base64Data ) : bool + { + // Remove any whitespace + $cleanData = preg_replace( '/\s+/', '', $base64Data ); + + // Attempt to decode + $decoded = base64_decode( $cleanData, true ); + + if( $decoded === false ) + { + return false; + } + + // Check size constraint + if( $this->maxSize !== null && strlen( $decoded ) > $this->maxSize ) + { + return false; + } + + // If we need to check the actual image data + if( $this->checkImageData ) + { + return $this->validateImageData( $decoded ); + } + + return true; + } + + /** + * Validates that the decoded data is actually a valid image. + * + * @param string $imageData + * @return bool + */ + private function validateImageData( string $imageData ) : bool + { + // Check for common image file signatures (magic numbers) + $signatures = [ + // JPEG + "\xFF\xD8\xFF" => 'image/jpeg', + // PNG + "\x89\x50\x4E\x47\x0D\x0A\x1A\x0A" => 'image/png', + // GIF + "GIF87a" => 'image/gif', + "GIF89a" => 'image/gif', + // WebP + "RIFF" => 'image/webp', // Note: WebP also needs WEBP at offset 8 + // SVG (XML-based, check for common SVG patterns) + " 'image/svg+xml', + " 'image/svg+xml' + ]; + + $dataStart = substr( $imageData, 0, 20 ); + + // Check for image signatures + $detectedType = null; + foreach( $signatures as $signature => $mimeType ) + { + if( strpos( $dataStart, $signature ) === 0 ) + { + // Special handling for WebP + if( $signature === 'RIFF' && substr( $imageData, 8, 4 ) !== 'WEBP' ) + { + continue; + } + + $detectedType = $mimeType; + break; + } + } + + // If no signature matched, it's not a valid image + if( $detectedType === null ) + { + return false; + } + + // Check if detected type is in allowed MIME types + if( !empty( $this->allowedMimeTypes ) && !in_array( $detectedType, $this->allowedMimeTypes, true ) ) + { + return false; + } + + return true; + } +} \ No newline at end of file diff --git a/tests/Validation/IsImageTest.php b/tests/Validation/IsImageTest.php new file mode 100644 index 0000000..13022d4 --- /dev/null +++ b/tests/Validation/IsImageTest.php @@ -0,0 +1,216 @@ +assertTrue( $validator->isValid( $jpegBase64 ) ); + } + + /** + * Test valid data URI formatted JPEG image. + */ + public function testValidDataUriJpegImage() + { + // Small 1x1 pixel JPEG image as data URI + $jpegDataUri = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAr/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k='; + + $validator = new IsImage(); + $this->assertTrue( $validator->isValid( $jpegDataUri ) ); + } + + /** + * Test valid base64 encoded PNG image. + */ + public function testValidBase64PngImage() + { + // Small 1x1 pixel PNG image in base64 + $pngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + + $validator = new IsImage(); + $this->assertTrue( $validator->isValid( $pngBase64 ) ); + } + + /** + * Test valid data URI formatted PNG image. + */ + public function testValidDataUriPngImage() + { + // Small 1x1 pixel PNG image as data URI + $pngDataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + + $validator = new IsImage(); + $this->assertTrue( $validator->isValid( $pngDataUri ) ); + } + + /** + * Test valid base64 encoded GIF image. + */ + public function testValidBase64GifImage() + { + // Small 1x1 pixel GIF image in base64 + $gifBase64 = 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + + $validator = new IsImage(); + $this->assertTrue( $validator->isValid( $gifBase64 ) ); + } + + /** + * Test invalid base64 string. + */ + public function testInvalidBase64String() + { + $invalidBase64 = 'This is not base64!@#$%'; + + $validator = new IsImage(); + $this->assertFalse( $validator->isValid( $invalidBase64 ) ); + } + + /** + * Test valid base64 but not an image. + */ + public function testValidBase64NotImage() + { + // Base64 encoded text "Hello World" + $textBase64 = 'SGVsbG8gV29ybGQ='; + + $validator = new IsImage(); + $this->assertFalse( $validator->isValid( $textBase64 ) ); + } + + /** + * Test empty string. + */ + public function testEmptyString() + { + $validator = new IsImage(); + $this->assertFalse( $validator->isValid( '' ) ); + } + + /** + * Test non-string input. + */ + public function testNonStringInput() + { + $validator = new IsImage(); + $this->assertFalse( $validator->isValid( 123 ) ); + $this->assertFalse( $validator->isValid( [] ) ); + $this->assertFalse( $validator->isValid( null ) ); + $this->assertFalse( $validator->isValid( true ) ); + } + + /** + * Test MIME type restrictions. + */ + public function testMimeTypeRestrictions() + { + // Only allow JPEG images + $validator = new IsImage( [ 'image/jpeg' ] ); + + // JPEG should pass + $jpegBase64 = '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAr/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k='; + $this->assertTrue( $validator->isValid( $jpegBase64 ) ); + + // PNG should fail + $pngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + $this->assertFalse( $validator->isValid( $pngBase64 ) ); + } + + /** + * Test data URI MIME type restrictions. + */ + public function testDataUriMimeTypeRestrictions() + { + // Only allow PNG images + $validator = new IsImage( [ 'image/png' ] ); + + // PNG data URI should pass + $pngDataUri = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + $this->assertTrue( $validator->isValid( $pngDataUri ) ); + + // JPEG data URI should fail + $jpegDataUri = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAr/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k='; + $this->assertFalse( $validator->isValid( $jpegDataUri ) ); + } + + /** + * Test file size restrictions. + */ + public function testFileSizeRestrictions() + { + // Small PNG (should be < 100 bytes decoded) + $smallPng = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + + // Allow max 200 bytes + $validator200 = new IsImage( [], 200 ); + $this->assertTrue( $validator200->isValid( $smallPng ) ); + + // Allow max 10 bytes (should fail) + $validator10 = new IsImage( [], 10 ); + $this->assertFalse( $validator10->isValid( $smallPng ) ); + } + + /** + * Test without image data checking (faster validation). + */ + public function testWithoutImageDataChecking() + { + // Validator that doesn't check actual image data + $validator = new IsImage( [], null, false ); + + // Valid base64 but not an image - should pass when not checking image data + $textBase64 = 'SGVsbG8gV29ybGQ='; // "Hello World" + $this->assertTrue( $validator->isValid( $textBase64 ) ); + + // Invalid base64 should still fail + $invalidBase64 = 'Not valid base64!@#'; + $this->assertFalse( $validator->isValid( $invalidBase64 ) ); + } + + /** + * Test SVG image support. + */ + public function testSvgImageSupport() + { + // Simple SVG in base64 + $svgContent = ''; + $svgBase64 = base64_encode( $svgContent ); + + $validator = new IsImage(); + $this->assertTrue( $validator->isValid( $svgBase64 ) ); + + // SVG data URI + $svgDataUri = 'data:image/svg+xml;base64,' . $svgBase64; + $this->assertTrue( $validator->isValid( $svgDataUri ) ); + } + + /** + * Test malformed data URI. + */ + public function testMalformedDataUri() + { + $validator = new IsImage(); + + // Missing base64 declaration + $malformed1 = 'data:image/png,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB'; + $this->assertFalse( $validator->isValid( $malformed1 ) ); + + // Missing MIME type + $malformed2 = 'data:;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB'; + $this->assertFalse( $validator->isValid( $malformed2 ) ); + + // Completely invalid format + $malformed3 = 'data:not-a-valid-uri'; + $this->assertFalse( $validator->isValid( $malformed3 ) ); + } +} \ No newline at end of file From c2b56c161e2ead610ab4bdf02484e87776e9797c Mon Sep 17 00:00:00 2001 From: Lee Jones Date: Tue, 13 Jan 2026 16:49:33 -0600 Subject: [PATCH 2/8] fixes --- src/Validation/IsImage.php | 57 +++++++++++++++++++++++++++++--- tests/Validation/IsImageTest.php | 50 ++++++++++++++++++++++++---- 2 files changed, 96 insertions(+), 11 deletions(-) diff --git a/src/Validation/IsImage.php b/src/Validation/IsImage.php index 3e60b63..786992a 100644 --- a/src/Validation/IsImage.php +++ b/src/Validation/IsImage.php @@ -10,22 +10,32 @@ class IsImage extends Base private array $allowedMimeTypes; private ?int $maxSize; private bool $checkImageData; + private bool $allowSvg; /** * @param array $allowedMimeTypes List of allowed MIME types (e.g., ['image/jpeg', 'image/png']) * @param int|null $maxSize Maximum file size in bytes (null for no limit) * @param bool $checkImageData Whether to validate the actual image data (requires decoding) + * @param bool $allowSvg Whether to allow SVG images (default: false for security - SVG can contain scripts) */ public function __construct( - array $allowedMimeTypes = [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml' ], + array $allowedMimeTypes = [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp' ], ?int $maxSize = null, - bool $checkImageData = true + bool $checkImageData = true, + bool $allowSvg = false ) { parent::__construct(); $this->allowedMimeTypes = $allowedMimeTypes; $this->maxSize = $maxSize; $this->checkImageData = $checkImageData; + $this->allowSvg = $allowSvg; + + // If SVG is explicitly allowed, add it to allowed MIME types + if( $this->allowSvg && !in_array( 'image/svg+xml', $this->allowedMimeTypes, true ) ) + { + $this->allowedMimeTypes[] = 'image/svg+xml'; + } } /** @@ -137,9 +147,6 @@ private function validateImageData( string $imageData ) : bool "GIF89a" => 'image/gif', // WebP "RIFF" => 'image/webp', // Note: WebP also needs WEBP at offset 8 - // SVG (XML-based, check for common SVG patterns) - " 'image/svg+xml', - " 'image/svg+xml' ]; $dataStart = substr( $imageData, 0, 20 ); @@ -161,6 +168,12 @@ private function validateImageData( string $imageData ) : bool } } + // Check for SVG separately with more strict validation + if( $detectedType === null && $this->allowSvg ) + { + $detectedType = $this->detectSvg( $imageData ); + } + // If no signature matched, it's not a valid image if( $detectedType === null ) { @@ -175,4 +188,38 @@ private function validateImageData( string $imageData ) : bool return true; } + + /** + * Detects if the data is a valid SVG image. + * SVG detection is more permissive but requires explicit opt-in for security. + * + * @param string $imageData + * @return string|null Returns 'image/svg+xml' if valid SVG, null otherwise + */ + private function detectSvg( string $imageData ) : ?string + { + // Only check first 1KB for performance + $dataToCheck = substr( $imageData, 0, 1024 ); + + // Remove any BOM (Byte Order Mark) + $dataToCheck = ltrim( $dataToCheck, "\xEF\xBB\xBF" ); + + // Case-insensitive check for ]*>/i', $dataToCheck ) ) + { + // Additional validation: check for xmlns attribute (standard in valid SVG) + if( stripos( $dataToCheck, 'xmlns' ) !== false || + stripos( $dataToCheck, 'http://www.w3.org/2000/svg' ) !== false ) + { + return 'image/svg+xml'; + } + + // Even without xmlns, if we have an svg tag, it's likely SVG + // But we're being more strict here for security + return 'image/svg+xml'; + } + + return null; + } } \ No newline at end of file diff --git a/tests/Validation/IsImageTest.php b/tests/Validation/IsImageTest.php index 13022d4..57b4318 100644 --- a/tests/Validation/IsImageTest.php +++ b/tests/Validation/IsImageTest.php @@ -178,20 +178,58 @@ public function testWithoutImageDataChecking() } /** - * Test SVG image support. + * Test SVG image support with explicit opt-in. */ - public function testSvgImageSupport() + public function testSvgImageSupportWithOptIn() { // Simple SVG in base64 - $svgContent = ''; + $svgContent = ''; $svgBase64 = base64_encode( $svgContent ); + // Default validator (SVG not allowed) $validator = new IsImage(); - $this->assertTrue( $validator->isValid( $svgBase64 ) ); + $this->assertFalse( $validator->isValid( $svgBase64 ) ); - // SVG data URI + // Validator with SVG explicitly allowed + $validatorWithSvg = new IsImage( [], null, true, true ); + $this->assertTrue( $validatorWithSvg->isValid( $svgBase64 ) ); + + // SVG data URI with explicit SVG support $svgDataUri = 'data:image/svg+xml;base64,' . $svgBase64; - $this->assertTrue( $validator->isValid( $svgDataUri ) ); + $this->assertFalse( $validator->isValid( $svgDataUri ) ); + $this->assertTrue( $validatorWithSvg->isValid( $svgDataUri ) ); + } + + /** + * Test that generic XML is not accepted as SVG. + */ + public function testGenericXmlNotAcceptedAsSvg() + { + // Generic XML that is not SVG + $xmlContent = 'test'; + $xmlBase64 = base64_encode( $xmlContent ); + + // Even with SVG allowed, generic XML should not pass + $validatorWithSvg = new IsImage( [], null, true, true ); + $this->assertFalse( $validatorWithSvg->isValid( $xmlBase64 ) ); + } + + /** + * Test SVG detection requires proper SVG tag. + */ + public function testSvgDetectionRequiresSvgTag() + { + // SVG without proper tag + $invalidSvg1 = ''; + $invalidBase64 = base64_encode( $invalidSvg1 ); + + $validatorWithSvg = new IsImage( [], null, true, true ); + $this->assertFalse( $validatorWithSvg->isValid( $invalidBase64 ) ); + + // Valid SVG with proper tag + $validSvg = ''; + $validBase64 = base64_encode( $validSvg ); + $this->assertTrue( $validatorWithSvg->isValid( $validBase64 ) ); } /** From c38a2bbb85f825eaf544ff7c010bf120b6084685 Mon Sep 17 00:00:00 2001 From: Lee Jones Date: Tue, 13 Jan 2026 16:52:58 -0600 Subject: [PATCH 3/8] bump --- VERSIONLOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/VERSIONLOG.md b/VERSIONLOG.md index 061be77..acc37d4 100644 --- a/VERSIONLOG.md +++ b/VERSIONLOG.md @@ -1,4 +1,5 @@ ## 0.7.11 +* Added IsImage. ## 0.7.10 2026-01-13 From c8038a179cec939e39220d361eb1e7f8acdf4606 Mon Sep 17 00:00:00 2001 From: Lee Jones Date: Tue, 13 Jan 2026 16:55:22 -0600 Subject: [PATCH 4/8] fixes --- src/Validation/IsImage.php | 8 ++++---- tests/Validation/IsImageTest.php | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/Validation/IsImage.php b/src/Validation/IsImage.php index 786992a..5fb83b3 100644 --- a/src/Validation/IsImage.php +++ b/src/Validation/IsImage.php @@ -208,16 +208,16 @@ private function detectSvg( string $imageData ) : ?string // Must find an actual SVG element, not just XML declaration if( preg_match( '/]*>/i', $dataToCheck ) ) { - // Additional validation: check for xmlns attribute (standard in valid SVG) + // Require proper SVG namespace for stricter validation + // This helps avoid accepting malformed or potentially malicious SVG-like content if( stripos( $dataToCheck, 'xmlns' ) !== false || stripos( $dataToCheck, 'http://www.w3.org/2000/svg' ) !== false ) { return 'image/svg+xml'; } - // Even without xmlns, if we have an svg tag, it's likely SVG - // But we're being more strict here for security - return 'image/svg+xml'; + // No xmlns found - not a valid SVG document + return null; } return null; diff --git a/tests/Validation/IsImageTest.php b/tests/Validation/IsImageTest.php index 57b4318..10f185d 100644 --- a/tests/Validation/IsImageTest.php +++ b/tests/Validation/IsImageTest.php @@ -232,6 +232,25 @@ public function testSvgDetectionRequiresSvgTag() $this->assertTrue( $validatorWithSvg->isValid( $validBase64 ) ); } + /** + * Test SVG without xmlns namespace is rejected for security. + */ + public function testSvgWithoutXmlnsRejected() + { + // SVG without xmlns namespace - should be rejected even with SVG allowed + $svgNoNamespace = ''; + $svgBase64 = base64_encode( $svgNoNamespace ); + + // Even with SVG enabled, should reject SVG without proper namespace + $validatorWithSvg = new IsImage( [], null, true, true ); + $this->assertFalse( $validatorWithSvg->isValid( $svgBase64 ) ); + + // SVG with xmlns should pass + $svgWithNamespace = ''; + $svgWithNsBase64 = base64_encode( $svgWithNamespace ); + $this->assertTrue( $validatorWithSvg->isValid( $svgWithNsBase64 ) ); + } + /** * Test malformed data URI. */ From 711cdf4b0e3114f3e4fb057860b37325eccb0031 Mon Sep 17 00:00:00 2001 From: Lee Jones Date: Tue, 13 Jan 2026 17:09:42 -0600 Subject: [PATCH 5/8] fixes --- composer.json | 5 +++ src/Validation/IsImage.php | 57 +++++++++++++++++-------- tests/Validation/IsImageTest.php | 73 ++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 18 deletions(-) diff --git a/composer.json b/composer.json index 8492056..423a733 100644 --- a/composer.json +++ b/composer.json @@ -21,5 +21,10 @@ "psr-4": { "Neuron\\": "src/" } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } } } diff --git a/src/Validation/IsImage.php b/src/Validation/IsImage.php index 5fb83b3..7206aab 100644 --- a/src/Validation/IsImage.php +++ b/src/Validation/IsImage.php @@ -81,11 +81,11 @@ private function validateDataUri( string $dataUri ) : bool return false; } - $mimeType = $matches[1]; + $mimeType = strtolower( $matches[1] ); // Normalize to lowercase per RFC 2045 $base64Data = $matches[2]; - // Check MIME type - if( !empty( $this->allowedMimeTypes ) && !in_array( $mimeType, $this->allowedMimeTypes, true ) ) + // Check MIME type (case-insensitive per RFC 2045) + if( !empty( $this->allowedMimeTypes ) && !$this->isMimeTypeAllowed( $mimeType ) ) { return false; } @@ -181,7 +181,7 @@ private function validateImageData( string $imageData ) : bool } // Check if detected type is in allowed MIME types - if( !empty( $this->allowedMimeTypes ) && !in_array( $detectedType, $this->allowedMimeTypes, true ) ) + if( !empty( $this->allowedMimeTypes ) && !$this->isMimeTypeAllowed( $detectedType ) ) { return false; } @@ -189,6 +189,25 @@ private function validateImageData( string $imageData ) : bool return true; } + /** + * Checks if a MIME type is in the allowed list (case-insensitive per RFC 2045). + * + * @param string $mimeType + * @return bool + */ + private function isMimeTypeAllowed( string $mimeType ) : bool + { + $normalizedMimeType = strtolower( $mimeType ); + foreach( $this->allowedMimeTypes as $allowed ) + { + if( strtolower( $allowed ) === $normalizedMimeType ) + { + return true; + } + } + return false; + } + /** * Detects if the data is a valid SVG image. * SVG detection is more permissive but requires explicit opt-in for security. @@ -201,23 +220,25 @@ private function detectSvg( string $imageData ) : ?string // Only check first 1KB for performance $dataToCheck = substr( $imageData, 0, 1024 ); - // Remove any BOM (Byte Order Mark) - $dataToCheck = ltrim( $dataToCheck, "\xEF\xBB\xBF" ); + // Remove UTF-8 BOM if present (exact 3-byte sequence) + if( substr( $dataToCheck, 0, 3 ) === "\xEF\xBB\xBF" ) + { + $dataToCheck = substr( $dataToCheck, 3 ); + } - // Case-insensitive check for ]*>/i', $dataToCheck ) ) + // Case-insensitive check for ]*xmlns\s*=\s*["\']http:\/\/www\.w3\.org\/2000\/svg["\'][^>]*>/i', $dataToCheck ) ) { - // Require proper SVG namespace for stricter validation - // This helps avoid accepting malformed or potentially malicious SVG-like content - if( stripos( $dataToCheck, 'xmlns' ) !== false || - stripos( $dataToCheck, 'http://www.w3.org/2000/svg' ) !== false ) - { - return 'image/svg+xml'; - } + // Valid SVG with proper namespace declaration + return 'image/svg+xml'; + } - // No xmlns found - not a valid SVG document - return null; + // Also check for xmlns:svg pattern (less common but valid) + if( preg_match( '/]*xmlns:svg\s*=\s*["\']http:\/\/www\.w3\.org\/2000\/svg["\'][^>]*>/i', $dataToCheck ) ) + { + return 'image/svg+xml'; } return null; diff --git a/tests/Validation/IsImageTest.php b/tests/Validation/IsImageTest.php index 10f185d..d77b368 100644 --- a/tests/Validation/IsImageTest.php +++ b/tests/Validation/IsImageTest.php @@ -1,5 +1,7 @@ assertTrue( $validatorWithSvg->isValid( $svgWithNsBase64 ) ); } + /** + * Test SVG with xmlns in text content is rejected (bypass prevention). + */ + public function testSvgWithXmlnsInTextRejected() + { + // SVG with xmlns in text content but not as attribute - should be rejected + $svgWithTextXmlns = 'xmlns http://www.w3.org/2000/svg'; + $svgBase64 = base64_encode( $svgWithTextXmlns ); + + // Should be rejected even with SVG enabled + $validatorWithSvg = new IsImage( [], null, true, true ); + $this->assertFalse( $validatorWithSvg->isValid( $svgBase64 ) ); + + // SVG with xmlns in comment - should be rejected + $svgWithCommentXmlns = ''; + $svgBase64Comment = base64_encode( $svgWithCommentXmlns ); + $this->assertFalse( $validatorWithSvg->isValid( $svgBase64Comment ) ); + } + + /** + * Test case-insensitive MIME type matching in data URIs. + */ + public function testCaseInsensitiveMimeTypeInDataUri() + { + // PNG with uppercase MIME type + $pngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + + // Test various case combinations + $validator = new IsImage(); + + // Uppercase MIME type + $upperDataUri = 'data:IMAGE/PNG;base64,' . $pngBase64; + $this->assertTrue( $validator->isValid( $upperDataUri ) ); + + // Mixed case MIME type + $mixedDataUri = 'data:Image/Png;base64,' . $pngBase64; + $this->assertTrue( $validator->isValid( $mixedDataUri ) ); + + // JPEG with uppercase + $jpegBase64 = '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAr/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k='; + $upperJpegUri = 'data:IMAGE/JPEG;base64,' . $jpegBase64; + $this->assertTrue( $validator->isValid( $upperJpegUri ) ); + } + + /** + * Test BOM removal handles exact sequence only. + */ + public function testBomRemovalExactSequence() + { + // SVG with proper BOM followed by valid content + $svgWithBom = "\xEF\xBB\xBF"; + $svgBomBase64 = base64_encode( $svgWithBom ); + + $validatorWithSvg = new IsImage( [], null, true, true ); + $this->assertTrue( $validatorWithSvg->isValid( $svgBomBase64 ) ); + + // Test with only first two bytes of BOM - should not be stripped + // The incomplete BOM should prevent proper SVG detection + $svgTwoBomBytes = "\xEF\xBB"; + $svgTwoBase64 = base64_encode( $svgTwoBomBytes ); + // This passes because the regex can still find assertTrue( $validatorWithSvg->isValid( $svgTwoBase64 ) ); + + // Test that we only remove exact BOM, not individual bytes + // This demonstrates the fix - ltrim would have stripped all \xEF, \xBB, \xBF anywhere + $svgWithEFAfterBom = "\xEF\xBB\xBF\xEF"; + $svgEfBase64 = base64_encode( $svgWithEFAfterBom ); + $this->assertTrue( $validatorWithSvg->isValid( $svgEfBase64 ) ); + } + /** * Test malformed data URI. */ From bb6dd2a58ebd561161f18ca7dddb55d32c09bef4 Mon Sep 17 00:00:00 2001 From: Lee Jones Date: Tue, 13 Jan 2026 17:40:03 -0600 Subject: [PATCH 6/8] fixes --- src/Validation/IsImage.php | 62 +++++++++++++++++---- tests/Validation/IsImageTest.php | 92 ++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 11 deletions(-) diff --git a/src/Validation/IsImage.php b/src/Validation/IsImage.php index 7206aab..541ec30 100644 --- a/src/Validation/IsImage.php +++ b/src/Validation/IsImage.php @@ -31,8 +31,9 @@ public function __construct( $this->checkImageData = $checkImageData; $this->allowSvg = $allowSvg; - // If SVG is explicitly allowed, add it to allowed MIME types - if( $this->allowSvg && !in_array( 'image/svg+xml', $this->allowedMimeTypes, true ) ) + // If SVG is explicitly allowed AND we have MIME type restrictions, add SVG to allowed list + // Don't add if allowedMimeTypes is empty (meaning allow all types) + if( $this->allowSvg && !empty( $this->allowedMimeTypes ) && !in_array( 'image/svg+xml', $this->allowedMimeTypes, true ) ) { $this->allowedMimeTypes[] = 'image/svg+xml'; } @@ -74,7 +75,8 @@ protected function validate( mixed $value ) : bool private function validateDataUri( string $dataUri ) : bool { // Parse data URI: data:[][;base64], - $pattern = '/^data:([a-zA-Z0-9][a-zA-Z0-9\/+\-]*);base64,(.+)$/'; + // Use 's' modifier to allow . to match newlines in base64 data + $pattern = '/^data:([a-zA-Z0-9][a-zA-Z0-9\/+\-]*);base64,(.+)$/s'; if( !preg_match( $pattern, $dataUri, $matches ) ) { @@ -119,9 +121,35 @@ private function validateBase64Image( string $base64Data ) : bool return false; } - // If we need to check the actual image data - if( $this->checkImageData ) + // Always validate image data to check MIME type restrictions + // Even when checkImageData is false, we need to detect the type for MIME validation + // but we can skip the more expensive image content validation + if( !empty( $this->allowedMimeTypes ) ) { + // Detect the image type from signatures + $detectedType = $this->detectImageType( $decoded ); + + // Check for SVG if allowed + if( $detectedType === null && $this->allowSvg ) + { + $detectedType = $this->detectSvg( $decoded ); + } + + // If we couldn't detect a type, it's not a valid image + if( $detectedType === null ) + { + return false; + } + + // Check if detected type is allowed + if( !$this->isMimeTypeAllowed( $detectedType ) ) + { + return false; + } + } + elseif( $this->checkImageData ) + { + // No MIME restrictions but need to validate image data return $this->validateImageData( $decoded ); } @@ -129,12 +157,12 @@ private function validateBase64Image( string $base64Data ) : bool } /** - * Validates that the decoded data is actually a valid image. + * Detects image type from binary data signatures. * * @param string $imageData - * @return bool + * @return string|null Returns MIME type if detected, null otherwise */ - private function validateImageData( string $imageData ) : bool + private function detectImageType( string $imageData ) : ?string { // Check for common image file signatures (magic numbers) $signatures = [ @@ -152,7 +180,6 @@ private function validateImageData( string $imageData ) : bool $dataStart = substr( $imageData, 0, 20 ); // Check for image signatures - $detectedType = null; foreach( $signatures as $signature => $mimeType ) { if( strpos( $dataStart, $signature ) === 0 ) @@ -163,11 +190,24 @@ private function validateImageData( string $imageData ) : bool continue; } - $detectedType = $mimeType; - break; + return $mimeType; } } + return null; + } + + /** + * Validates that the decoded data is actually a valid image. + * + * @param string $imageData + * @return bool + */ + private function validateImageData( string $imageData ) : bool + { + // Use the extracted method to detect image type + $detectedType = $this->detectImageType( $imageData ); + // Check for SVG separately with more strict validation if( $detectedType === null && $this->allowSvg ) { diff --git a/tests/Validation/IsImageTest.php b/tests/Validation/IsImageTest.php index d77b368..e247d78 100644 --- a/tests/Validation/IsImageTest.php +++ b/tests/Validation/IsImageTest.php @@ -324,6 +324,98 @@ public function testBomRemovalExactSequence() $this->assertTrue( $validatorWithSvg->isValid( $svgEfBase64 ) ); } + /** + * Test data URI with line-wrapped base64 (common in email/MIME). + */ + public function testDataUriWithLineWrappedBase64() + { + // PNG image with base64 wrapped at 76 characters (common MIME format) + $pngBase64Wrapped = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChw\nGA60e6kgAAAABJRU5ErkJggg=="; + + // Test with newline in base64 data + $dataUriWithNewline = 'data:image/png;base64,' . $pngBase64Wrapped; + + $validator = new IsImage(); + // Should pass - newlines are valid in base64 data URIs + $this->assertTrue( $validator->isValid( $dataUriWithNewline ) ); + + // Test with multiple newlines and spaces (also valid) + $pngBase64MultiLine = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ\n" . + "AAAADUlEQVR42mNkYPhfDwAChw\n" . + "GA60e6kgAAAABJRU5ErkJggg=="; + + $dataUriMultiLine = 'data:image/png;base64,' . $pngBase64MultiLine; + $this->assertTrue( $validator->isValid( $dataUriMultiLine ) ); + + // Test with carriage return + newline (Windows style) + $pngBase64CRLF = str_replace("\n", "\r\n", $pngBase64Wrapped); + $dataUriCRLF = 'data:image/png;base64,' . $pngBase64CRLF; + $this->assertTrue( $validator->isValid( $dataUriCRLF ) ); + } + + /** + * Test MIME type restrictions work for raw base64 input. + */ + public function testMimeTypeRestrictionsForRawBase64() + { + // PNG image as raw base64 + $pngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + + // JPEG image as raw base64 + $jpegBase64 = '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAr/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k='; + + // Validator that only allows JPEG, even with checkImageData=false + $jpegOnlyValidator = new IsImage( [ 'image/jpeg' ], null, false ); + + // JPEG should pass + $this->assertTrue( $jpegOnlyValidator->isValid( $jpegBase64 ) ); + + // PNG should fail even with checkImageData=false + $this->assertFalse( $jpegOnlyValidator->isValid( $pngBase64 ) ); + + // Validator that only allows PNG + $pngOnlyValidator = new IsImage( [ 'image/png' ], null, false ); + + // PNG should pass + $this->assertTrue( $pngOnlyValidator->isValid( $pngBase64 ) ); + + // JPEG should fail + $this->assertFalse( $pngOnlyValidator->isValid( $jpegBase64 ) ); + } + + /** + * Test empty allowedMimeTypes with SVG doesn't restrict to SVG only. + */ + public function testEmptyAllowedMimeTypesWithSvgAllowsAll() + { + // Empty array means allow all types + $validatorAllowAll = new IsImage( [], null, true, true ); + + // All image types should pass + $jpegBase64 = '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAr/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k='; + $this->assertTrue( $validatorAllowAll->isValid( $jpegBase64 ) ); + + $pngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + $this->assertTrue( $validatorAllowAll->isValid( $pngBase64 ) ); + + $gifBase64 = 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; + $this->assertTrue( $validatorAllowAll->isValid( $gifBase64 ) ); + + // SVG should also pass + $svgBase64 = base64_encode( '' ); + $this->assertTrue( $validatorAllowAll->isValid( $svgBase64 ) ); + + // Now test with allowSvg=false but empty allowedMimeTypes + $validatorNoSvg = new IsImage( [], null, true, false ); + + // Non-SVG images should still pass + $this->assertTrue( $validatorNoSvg->isValid( $jpegBase64 ) ); + $this->assertTrue( $validatorNoSvg->isValid( $pngBase64 ) ); + + // SVG should fail + $this->assertFalse( $validatorNoSvg->isValid( $svgBase64 ) ); + } + /** * Test malformed data URI. */ From 852fb9618dbe34b4bfae756e1f249aa87c6cd75d Mon Sep 17 00:00:00 2001 From: Lee Jones Date: Tue, 13 Jan 2026 17:47:01 -0600 Subject: [PATCH 7/8] fixes --- src/Validation/IsImage.php | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/src/Validation/IsImage.php b/src/Validation/IsImage.php index 541ec30..54317ed 100644 --- a/src/Validation/IsImage.php +++ b/src/Validation/IsImage.php @@ -149,8 +149,8 @@ private function validateBase64Image( string $base64Data ) : bool } elseif( $this->checkImageData ) { - // No MIME restrictions but need to validate image data - return $this->validateImageData( $decoded ); + // No MIME restrictions but need to check if it's a recognizable image + return $this->isRecognizableImage( $decoded ); } return true; @@ -198,12 +198,14 @@ private function detectImageType( string $imageData ) : ?string } /** - * Validates that the decoded data is actually a valid image. + * Checks if the decoded data is a recognizable image format. + * This method only validates that the data contains valid image signatures, + * without checking MIME type restrictions (caller's responsibility). * * @param string $imageData * @return bool */ - private function validateImageData( string $imageData ) : bool + private function isRecognizableImage( string $imageData ) : bool { // Use the extracted method to detect image type $detectedType = $this->detectImageType( $imageData ); @@ -214,19 +216,8 @@ private function validateImageData( string $imageData ) : bool $detectedType = $this->detectSvg( $imageData ); } - // If no signature matched, it's not a valid image - if( $detectedType === null ) - { - return false; - } - - // Check if detected type is in allowed MIME types - if( !empty( $this->allowedMimeTypes ) && !$this->isMimeTypeAllowed( $detectedType ) ) - { - return false; - } - - return true; + // Return true if we recognized any valid image format + return $detectedType !== null; } /** From 44198edee94d577b8665a8cee0006f71c41b897c Mon Sep 17 00:00:00 2001 From: Lee Jones Date: Tue, 13 Jan 2026 18:01:40 -0600 Subject: [PATCH 8/8] fixes --- src/Validation/IsImage.php | 14 ++++++++++---- tests/Validation/IsImageTest.php | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/Validation/IsImage.php b/src/Validation/IsImage.php index 54317ed..962dd83 100644 --- a/src/Validation/IsImage.php +++ b/src/Validation/IsImage.php @@ -129,8 +129,11 @@ private function validateBase64Image( string $base64Data ) : bool // Detect the image type from signatures $detectedType = $this->detectImageType( $decoded ); - // Check for SVG if allowed - if( $detectedType === null && $this->allowSvg ) + // Check for SVG if: + // 1. allowSvg is true (general SVG support enabled), OR + // 2. 'image/svg+xml' is explicitly in allowedMimeTypes + $svgExplicitlyAllowed = $this->isMimeTypeAllowed( 'image/svg+xml' ); + if( $detectedType === null && ( $this->allowSvg || $svgExplicitlyAllowed ) ) { $detectedType = $this->detectSvg( $decoded ); } @@ -210,8 +213,11 @@ private function isRecognizableImage( string $imageData ) : bool // Use the extracted method to detect image type $detectedType = $this->detectImageType( $imageData ); - // Check for SVG separately with more strict validation - if( $detectedType === null && $this->allowSvg ) + // Check for SVG if: + // 1. allowSvg is true (general SVG support enabled), OR + // 2. 'image/svg+xml' is explicitly in allowedMimeTypes (if any restrictions exist) + $svgExplicitlyAllowed = !empty( $this->allowedMimeTypes ) && $this->isMimeTypeAllowed( 'image/svg+xml' ); + if( $detectedType === null && ( $this->allowSvg || $svgExplicitlyAllowed ) ) { $detectedType = $this->detectSvg( $imageData ); } diff --git a/tests/Validation/IsImageTest.php b/tests/Validation/IsImageTest.php index e247d78..74ee4d6 100644 --- a/tests/Validation/IsImageTest.php +++ b/tests/Validation/IsImageTest.php @@ -416,6 +416,33 @@ public function testEmptyAllowedMimeTypesWithSvgAllowsAll() $this->assertFalse( $validatorNoSvg->isValid( $svgBase64 ) ); } + /** + * Test SVG accepted when explicitly in allowedMimeTypes even with allowSvg=false. + */ + public function testSvgAcceptedWhenExplicitlyAllowed() + { + // SVG content + $svgContent = ''; + $svgBase64 = base64_encode( $svgContent ); + + // Validator with SVG explicitly in allowedMimeTypes but allowSvg=false + $validatorExplicitSvg = new IsImage( [ 'image/jpeg', 'image/svg+xml' ], null, true, false ); + + // SVG should pass because it's explicitly allowed in MIME types + $this->assertTrue( $validatorExplicitSvg->isValid( $svgBase64 ) ); + + // Data URI with SVG should also work + $svgDataUri = 'data:image/svg+xml;base64,' . $svgBase64; + $this->assertTrue( $validatorExplicitSvg->isValid( $svgDataUri ) ); + + // Validator without SVG in allowedMimeTypes and allowSvg=false + $validatorNoSvg = new IsImage( [ 'image/jpeg', 'image/png' ], null, true, false ); + + // SVG should fail + $this->assertFalse( $validatorNoSvg->isValid( $svgBase64 ) ); + $this->assertFalse( $validatorNoSvg->isValid( $svgDataUri ) ); + } + /** * Test malformed data URI. */