diff --git a/inc/tag_replacer.php b/inc/tag_replacer.php
index d14bef54..3ec4741e 100644
--- a/inc/tag_replacer.php
+++ b/inc/tag_replacer.php
@@ -527,6 +527,8 @@ 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
@@ -572,6 +574,8 @@ 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
@@ -626,6 +630,8 @@ 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
@@ -667,6 +673,8 @@ 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
diff --git a/tests/test-srcset.php b/tests/test-srcset.php
index ff5608fc..befee5d9 100644
--- a/tests/test-srcset.php
+++ b/tests/test-srcset.php
@@ -464,11 +464,164 @@ 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 = '
';
+ $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 = '
';
+ $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 = '
';
+ $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 = '
';
+ $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' ) );
+ }
+
+ /**
+ * 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 = '
';
+ $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 = '
';
+ $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 = '
';
+ $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 = '
';
+ $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
+ */
+ 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();
-
+
}
}