From f738122ab69f01a557fe7a987903dbd81827f858 Mon Sep 17 00:00:00 2001 From: girishpanchal30 Date: Thu, 3 Sep 2026 12:31:14 +0530 Subject: [PATCH 1/2] fix: escape backreferences to prevent XSS --- inc/tag_replacer.php | 8 ++--- tests/test-srcset.php | 83 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/inc/tag_replacer.php b/inc/tag_replacer.php index d14bef54..6e0324f0 100644 --- a/inc/tag_replacer.php +++ b/inc/tag_replacer.php @@ -530,7 +530,7 @@ public function add_missing_srcset_attributes( $tag, $missing_srcsets, $new_url, $srcset_attr = $is_slashed ? 'srcset=\"' . addcslashes( $srcset_value, '"' ) . '\"' : 'srcset="' . $srcset_value . '"'; // Insert srcset attribute after the src attribute - $tag = preg_replace( '/(src=["\'][^"\']*["\'])/i', '$1 ' . $srcset_attr, $tag ); + $tag = preg_replace( '/(src=["\'][^"\']*["\'])/i', '$1 ' . addcslashes( $srcset_attr, '\\$' ), $tag ); } // Handle sizes attribute - skip if existing sizes contains calc() or complex formulas @@ -575,7 +575,7 @@ function ( $a, $b ) { $sizes_attr = $is_slashed ? 'sizes=\"' . addcslashes( $sizes_value, '"' ) . '\"' : 'sizes="' . $sizes_value . '"'; // Insert sizes attribute after srcset - $tag = preg_replace( '/(srcset=["\'][^"\']*["\'])/i', '$1 ' . $sizes_attr, $tag ); + $tag = preg_replace( '/(srcset=["\'][^"\']*["\'])/i', '$1 ' . addcslashes( $sizes_attr, '\\$' ), $tag ); } } } else { @@ -629,7 +629,7 @@ function ( $a, $b ) { $srcset_attr = $is_slashed ? 'srcset=\"' . addcslashes( $enhanced_srcset, '"' ) . '\"' : 'srcset="' . $enhanced_srcset . '"'; // Replace existing srcset - $tag = preg_replace( '/srcset=["\'][^"\']*["\']/i', $srcset_attr, $tag ); + $tag = preg_replace( '/srcset=["\'][^"\']*["\']/i', addcslashes( $srcset_attr, '\\$' ), $tag ); } return $tag; @@ -670,7 +670,7 @@ function ( $a, $b ) { $sizes_attr = $is_slashed ? 'sizes=\"' . addcslashes( $enhanced_sizes, '"' ) . '\"' : 'sizes="' . $enhanced_sizes . '"'; // Replace existing sizes - $tag = preg_replace( '/sizes=["\'][^"\']*["\']/i', $sizes_attr, $tag ); + $tag = preg_replace( '/sizes=["\'][^"\']*["\']/i', addcslashes( $sizes_attr, '\\$' ), $tag ); } return $tag; diff --git a/tests/test-srcset.php b/tests/test-srcset.php index ff5608fc..5e21c44e 100644 --- a/tests/test-srcset.php +++ b/tests/test-srcset.php @@ -464,11 +464,92 @@ public function test_add_missing_srcset_attributes_adds_simple_sizes() { } + /** + * Test that a "$0" srcset descriptor cannot inject a real onload attribute + */ + public function test_add_missing_srcset_attributes_prevents_backreference_injection() { + $tag_replacer = new Optml_Tag_Replacer(); + + $tag = 'Test'; + $missing_srcsets = [ + [ 'w' => 800, 'h' => 600, 'd' => 1, 's' => '$0 onload=alert(document.domain) x', 'b' => 0 ], + ]; + + $result = $tag_replacer->add_missing_srcset_attributes( $tag, $missing_srcsets, 'https://example.com/image.jpg', false ); + $img = $this->get_img_element( $result ); + + // The payload must stay inert literal text inside the srcset value, not become a real attribute + $this->assertFalse( $img->hasAttribute( 'onload' ) ); + $this->assertStringContainsString( '$0 onload=alert(document.domain) x', $img->getAttribute( 'srcset' ) ); + } + + /** + * Test that a "$1" srcset descriptor cannot inject a real onerror attribute + */ + public function test_add_missing_srcset_attributes_prevents_dollar_one_injection() { + $tag_replacer = new Optml_Tag_Replacer(); + + $tag = 'Test'; + $missing_srcsets = [ + [ 'w' => 800, 'h' => 600, 'd' => 1, 's' => '$1 onerror=alert(1) x', 'b' => 0 ], + ]; + + $result = $tag_replacer->add_missing_srcset_attributes( $tag, $missing_srcsets, 'https://example.com/image.jpg', false ); + $img = $this->get_img_element( $result ); + + $this->assertFalse( $img->hasAttribute( 'onerror' ) ); + $this->assertSame( 'https://example.com/image.jpg', $img->getAttribute( 'src' ) ); + } + + /** + * Test that enhance_existing_srcset does not expand a "$0" entry into a real attribute + */ + public function test_enhance_existing_srcset_prevents_backreference_injection() { + $tag_replacer = new Optml_Tag_Replacer(); + + $tag = 'Test'; + $new_entries = [ 'evil.jpg $0 onload=alert(document.domain) x' ]; + + $result = $tag_replacer->enhance_existing_srcset( $tag, $new_entries, false ); + $img = $this->get_img_element( $result ); + + $this->assertFalse( $img->hasAttribute( 'onload' ) ); + $this->assertStringContainsString( 'existing-300w.jpg 300w', $img->getAttribute( 'srcset' ) ); + } + + /** + * Test that enhance_existing_sizes does not expand a "$0" entry into a real attribute + */ + public function test_enhance_existing_sizes_prevents_backreference_injection() { + $tag_replacer = new Optml_Tag_Replacer(); + + $tag = 'Test'; + $new_entries = [ '$0 onload=alert(document.domain) x' ]; + + $result = $tag_replacer->enhance_existing_sizes( $tag, $new_entries, false ); + $img = $this->get_img_element( $result ); + + $this->assertFalse( $img->hasAttribute( 'onload' ) ); + $this->assertStringContainsString( '(max-width: 480px) 300px', $img->getAttribute( 'sizes' ) ); + } + + /** + * Parse a single img tag string and return its DOM element + */ + private function get_img_element( $tag_html ) { + $dom = new DOMDocument(); + $previous_setting = libxml_use_internal_errors( true ); + $dom->loadHTML( $tag_html ); + libxml_use_internal_errors( $previous_setting ); + + return $dom->getElementsByTagName( 'img' )->item( 0 ); + } + /** * Set up test environment */ public function setUp(): void { parent::setUp(); - + } } From 87f97b7164c9402b66b36b2ba93a41949eec0d5d Mon Sep 17 00:00:00 2001 From: girishpanchal30 Date: Thu, 3 Sep 2026 12:55:02 +0530 Subject: [PATCH 2/2] fix: add missing backslash-reference tests --- inc/tag_replacer.php | 16 +++++++--- tests/test-srcset.php | 72 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/inc/tag_replacer.php b/inc/tag_replacer.php index 6e0324f0..3ec4741e 100644 --- a/inc/tag_replacer.php +++ b/inc/tag_replacer.php @@ -527,10 +527,12 @@ public function add_missing_srcset_attributes( $tag, $missing_srcsets, $new_url, } else { // Add new srcset attribute $srcset_value = implode( ', ', $new_srcset_entries ); + // Escape backreference metacharacters so this value can't be expanded by preg_replace() below. + $srcset_value = addcslashes( $srcset_value, '\\$' ); $srcset_attr = $is_slashed ? 'srcset=\"' . addcslashes( $srcset_value, '"' ) . '\"' : 'srcset="' . $srcset_value . '"'; // Insert srcset attribute after the src attribute - $tag = preg_replace( '/(src=["\'][^"\']*["\'])/i', '$1 ' . addcslashes( $srcset_attr, '\\$' ), $tag ); + $tag = preg_replace( '/(src=["\'][^"\']*["\'])/i', '$1 ' . $srcset_attr, $tag ); } // Handle sizes attribute - skip if existing sizes contains calc() or complex formulas @@ -572,10 +574,12 @@ function ( $a, $b ) { ); $sizes_value = implode( ', ', $sizes_entries ); + // Escape backreference metacharacters before wrapping, see add_missing_srcset_attributes() above. + $sizes_value = addcslashes( $sizes_value, '\\$' ); $sizes_attr = $is_slashed ? 'sizes=\"' . addcslashes( $sizes_value, '"' ) . '\"' : 'sizes="' . $sizes_value . '"'; // Insert sizes attribute after srcset - $tag = preg_replace( '/(srcset=["\'][^"\']*["\'])/i', '$1 ' . addcslashes( $sizes_attr, '\\$' ), $tag ); + $tag = preg_replace( '/(srcset=["\'][^"\']*["\'])/i', '$1 ' . $sizes_attr, $tag ); } } } else { @@ -626,10 +630,12 @@ function ( $a, $b ) { ); $enhanced_srcset = implode( ', ', $all_entries ); + // Escape backreference metacharacters so this value can't be expanded by preg_replace() below. + $enhanced_srcset = addcslashes( $enhanced_srcset, '\\$' ); $srcset_attr = $is_slashed ? 'srcset=\"' . addcslashes( $enhanced_srcset, '"' ) . '\"' : 'srcset="' . $enhanced_srcset . '"'; // Replace existing srcset - $tag = preg_replace( '/srcset=["\'][^"\']*["\']/i', addcslashes( $srcset_attr, '\\$' ), $tag ); + $tag = preg_replace( '/srcset=["\'][^"\']*["\']/i', $srcset_attr, $tag ); } return $tag; @@ -667,10 +673,12 @@ function ( $a, $b ) { ); $enhanced_sizes = implode( ', ', $all_entries ); + // Escape backreference metacharacters before wrapping, see enhance_existing_srcset() above. + $enhanced_sizes = addcslashes( $enhanced_sizes, '\\$' ); $sizes_attr = $is_slashed ? 'sizes=\"' . addcslashes( $enhanced_sizes, '"' ) . '\"' : 'sizes="' . $enhanced_sizes . '"'; // Replace existing sizes - $tag = preg_replace( '/sizes=["\'][^"\']*["\']/i', addcslashes( $sizes_attr, '\\$' ), $tag ); + $tag = preg_replace( '/sizes=["\'][^"\']*["\']/i', $sizes_attr, $tag ); } return $tag; diff --git a/tests/test-srcset.php b/tests/test-srcset.php index 5e21c44e..befee5d9 100644 --- a/tests/test-srcset.php +++ b/tests/test-srcset.php @@ -533,6 +533,78 @@ public function test_enhance_existing_sizes_prevents_backreference_injection() { $this->assertStringContainsString( '(max-width: 480px) 300px', $img->getAttribute( 'sizes' ) ); } + /** + * Test that a backslash-form "\0" srcset descriptor cannot inject a real onload attribute + */ + public function test_add_missing_srcset_attributes_prevents_backslash_backreference_injection() { + $tag_replacer = new Optml_Tag_Replacer(); + + $tag = 'Test'; + $missing_srcsets = [ + [ 'w' => 800, 'h' => 600, 'd' => 1, 's' => '\0 onload=alert(document.domain) x', 'b' => 0 ], + ]; + + $result = $tag_replacer->add_missing_srcset_attributes( $tag, $missing_srcsets, 'https://example.com/image.jpg', false ); + $img = $this->get_img_element( $result ); + + $this->assertFalse( $img->hasAttribute( 'onload' ) ); + $this->assertStringContainsString( '\0 onload=alert(document.domain) x', $img->getAttribute( 'srcset' ) ); + } + + /** + * Test that enhance_existing_srcset does not expand a "\1" entry into a real attribute + */ + public function test_enhance_existing_srcset_prevents_backslash_backreference_injection() { + $tag_replacer = new Optml_Tag_Replacer(); + + $tag = 'Test'; + $new_entries = [ 'evil.jpg \1 onerror=alert(1) x' ]; + + $result = $tag_replacer->enhance_existing_srcset( $tag, $new_entries, false ); + $img = $this->get_img_element( $result ); + + $this->assertFalse( $img->hasAttribute( 'onerror' ) ); + $this->assertStringContainsString( 'existing-300w.jpg 300w', $img->getAttribute( 'srcset' ) ); + } + + /** + * Test that the slashed srcset output keeps its required escaping while a "$0" payload stays inert + */ + public function test_add_missing_srcset_attributes_slashed_prevents_backreference_injection() { + $tag_replacer = new Optml_Tag_Replacer(); + + $tag = 'Test'; + $missing_srcsets = [ + [ 'w' => 800, 'h' => 600, 'd' => 1, 's' => '$0 onload=alert(document.domain) x', 'b' => 0 ], + ]; + + $result = $tag_replacer->add_missing_srcset_attributes( $tag, $missing_srcsets, 'https://example.com/image.jpg', true ); + + // The required slashed escaping around the srcset value must be preserved as-is (not doubled). + $this->assertStringContainsString( 'srcset=\"', $result ); + $this->assertStringNotContainsString( 'srcset=\\\"', $result ); + // The payload must stay inert literal text, not become a real attribute breakout. + $this->assertStringNotContainsString( '\" onload=', $result ); + $this->assertStringContainsString( '$0 onload=alert(document.domain) x', $result ); + } + + /** + * Test that enhance_existing_srcset's slashed output keeps its required escaping while a "\0" payload stays inert + */ + public function test_enhance_existing_srcset_slashed_prevents_backslash_backreference_injection() { + $tag_replacer = new Optml_Tag_Replacer(); + + $tag = 'Test'; + $new_entries = [ 'evil.jpg \0 onload=alert(document.domain) x' ]; + + $result = $tag_replacer->enhance_existing_srcset( $tag, $new_entries, true ); + + $this->assertStringContainsString( 'srcset=\"', $result ); + $this->assertStringNotContainsString( 'srcset=\\\"', $result ); + $this->assertStringNotContainsString( '\" onload=', $result ); + $this->assertStringContainsString( 'existing-300w.jpg 300w', $result ); + } + /** * Parse a single img tag string and return its DOM element */