Improve the script tag relocation for extremely large payloads
authorAlexander Ebert <ebert@woltlab.com>
Fri, 18 Oct 2024 12:56:15 +0000 (14:56 +0200)
committerAlexander Ebert <ebert@woltlab.com>
Fri, 18 Oct 2024 12:56:15 +0000 (14:56 +0200)
The previous approach would hit backtracking limits when dealing with excessively large payloads. This could happen with extreme numbers of smileys that are all handled through a single script tag.

See https://www.woltlab.com/community/thread/308944-fehlermeldung-unterhalb-des-footers/

wcfsetup/install/files/lib/util/HeaderUtil.class.php

index bd249efef6b49d5654ea8f91e62746290d1d560c..4d2f97f4046c20a2c9c5f58295f714d22c9cf816 100644 (file)
@@ -165,21 +165,42 @@ final class HeaderUtil
             }, self::$output);
         }
 
-        // move script tags to the bottom of the page
+        // Move script tags to the bottom of the page. This splits up the output
+        // into chunks that effectively end with `</script>`. This allows us to
+        // use a simple regex to find the start of the script tag and treating
+        // everything inbetween as the content of the script.
+        //
+        // The previous approach used a lazy match for the script content which
+        // could hit the backtracking limit for extremely large payloads.
         $javascript = [];
-        self::$output = \preg_replace_callback(
-            '~<script data-relocate="true"(?<attributes>[^>]*+)>(?P<script>.*?)</script>\s*~s',
-            static function ($matches) use (&$javascript) {
-                // Add an attribute to disable Cloudflare's Rocket Loader
-                if (!\str_contains($matches['attributes'], 'data-cfasync="false"')) {
-                    $matches['attributes'] = ' data-cfasync="false"' . $matches['attributes'];
-                }
+        $segments = \preg_split('~</script>~s', self::$output, flags: \PREG_SPLIT_NO_EMPTY);
 
-                $javascript[] = '<script' . $matches['attributes'] . '>' . $matches['script'] . '</script>';
-                return '';
-            },
-            self::$output
-        );
+        self::$output = '';
+        foreach ($segments as $segment) {
+            $hasMatch = false;
+
+            self::$output .= \preg_replace_callback(
+                '~<script data-relocate="true"(?<attributes>[^>]*+)>(?<script>.*+)$~s',
+                static function ($matches) use (&$javascript, &$hasMatch) {
+                    // Add an attribute to disable Cloudflare's Rocket Loader
+                    if (!\str_contains($matches['attributes'], 'data-cfasync="false"')) {
+                        $matches['attributes'] = ' data-cfasync="false"' . $matches['attributes'];
+                    }
+
+                    $javascript[] = '<script' . $matches['attributes'] . '>' . $matches['script'] . '</script>';
+
+                    $hasMatch = true;
+
+                    return '';
+                },
+                $segment,
+                limit: 1
+            );
+
+            if (!$hasMatch) {
+                self::$output .= '</script>';
+            }
+        }
 
         $placeholder = '<!-- ' . WCF::getRequestNonce('JAVASCRIPT_RELOCATE_POSITION') . ' -->';
         if (($placeholderPosition = \strrpos(self::$output, $placeholder)) !== false) {