Infinite recursion in Content SEO module causes fatal memory exhaustion on WooCommerce single product pages
Plugin: Nexter Extension Version: 4.7.1 Module: Content SEO Severity: Critical. Site-breaking fatal error. Status: Reproducible. Resolved by disabling the Content SEO module.
Summary
The Content SEO module enters infinite recursion when rendering WooCommerce single product pages. Nexter_Content_SEO_Title::filter_pre_document_title() hooks the pre_get_document_title filter, and within that filter chain Nexter_Content_SEO_Schema::get_replacements() calls wp_get_document_title(), which fires pre_get_document_title again. The cycle repeats until PHP memory is exhausted.
The result is a 500 error on every single product page. Raising the memory limit does not help; it only delays the crash.
Environment
ItemValueWordPress | 7.0.2
PHP | 8.1 (also reproduced on 8.3)
Web server | LiteSpeed
Theme | Kadence 1.5.2
WooCommerce | 10.9.4
Nexter Extension | 4.7.1
Nexter Blocks | 5.0.1
PHP memory limit | Reproduced at 512M, 1G, and 2G
Steps to reproduce
- Install WordPress with WooCommerce and at least one published product.
- Install and activate Nexter Extension 4.7.1.
- Enable the Content SEO module.
- Visit any single product page on the front end.
Expected: the product page renders.
Actual: the page hangs for several minutes, then returns a 500 error. PHP logs a memory exhaustion fatal.
Scope of the failure
- Affects single product pages only (is_product()).
- The shop archive page renders normally.
- Product category archives render normally.
- Regular pages and posts render normally.
- WooCommerce product list in wp-admin renders normally.
- Occurs on a newly created, empty simple product with no images, no custom fields, and no description. It is not data-specific.
Diagnostic evidence
Hook volume
Instrumenting the all hook recorded over 1.8 million hook fires on a single product page request before the process died. A normal page on the same site completes in roughly 20,000.
Backtrace
Captured by halting execution on the 500th call to get_the_date:
#0 WPCode_Snippet_Execute->{closure}() wp-includes/class-wp-hook.php:343
#1 WP_Hook->apply_filters() wp-includes/plugin.php:205
#2 apply_filters() wp-includes/general-template.php:2747
#3 get_the_date() nexter-extension/include/panel-settings/extensions/content-seo/class-seo-schema.php:1730
#4 Nexter_Content_SEO_Schema::get_replacements() .../class-seo-settings.php:87
#5 Nexter_Content_SEO_Settings::replace_variables() .../class-seo-title.php:198
#6 Nexter_Content_SEO_Title::resolve_string_with_context() .../class-seo-title.php:118
#7 Nexter_Content_SEO_Title::filter_document_title() .../class-seo-title.php:50
#8 Nexter_Content_SEO_Title::filter_pre_document_title() wp-includes/class-wp-hook.php:341
#9 WP_Hook->apply_filters() wp-includes/plugin.php:205
#10 apply_filters() wp-includes/general-template.php:1192
#11 wp_get_document_title() nexter-extension/.../class-seo-schema.php:1812
#12 Nexter_Content_SEO_Schema::get_replacements() .../class-seo-settings.php:87
#13 Nexter_Content_SEO_Settings::replace_variables() .../class-seo-title.php:198
#14 Nexter_Content_SEO_Title::resolve_string_with_context() .../class-seo-title.php:118
#15 Nexter_Content_SEO_Title::filter_document_title() .../class-seo-title.php:50
#16 Nexter_Content_SEO_Title::filter_pre_document_title() wp-includes/class-wp-hook.php:341
#17 WP_Hook->apply_filters() wp-includes/plugin.php:205
#18 apply_filters() wp-includes/general-template.php:1192
#19 wp_get_document_title() nexter-extension/.../class-seo-schema.php:1812Frames 11 through 19 repeat identically for the remaining depth of the trace.
The recursion cycle
class-seo-title.php:50 filter_pre_document_title() hooks pre_get_document_title
-> class-seo-title.php:118 resolve_string_with_context()
-> class-seo-settings.php:87 replace_variables()
-> class-seo-schema.php:1812 get_replacements() calls wp_get_document_title()
-> wp_get_document_title() fires pre_get_document_title
-> back to filter_pre_document_title() [LOOP]class-seo-schema.php:1730 calling get_the_date() sits inside the same get_replacements() method and is what made the loop visible in profiling.
Fatal errors logged
PHP Fatal error: Allowed memory size of 536870912 bytes exhausted
(tried to allocate 20480 bytes)
in wp-content/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php on line 489
PHP Fatal error: Allowed memory size of 2122317824 bytes exhausted
(tried to allocate 20480 bytes)
in wp-content/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php on line 489The crash location varies depending on where the allocation happens to fail. It is not meaningful. The allocation sizes are small (12KB to 262KB) even at a 2GB limit, which confirms unbounded recursion rather than a large single allocation.
Related symptom
The following also appears in the error log and may share a root cause:
Cron unschedule event error for hook: nxt_seo_recalc_score
Error code: could_not_set
Error message: The cron event list could not be saved.
Workaround
Disabling the Content SEO module resolves the issue immediately. Product pages render normally. All other Nexter Extension and Nexter Blocks functionality is unaffected.
Suggested fix
Add a re-entry guard to Nexter_Content_SEO_Title::filter_pre_document_title() so the filter returns early if it is already executing, for example:
php
private static $generating_title = false;
public static function filter_pre_document_title( $title ) {
if ( self::$generating_title ) {
return $title;
}
self::$generating_title = true;
$result = self::filter_document_title( $title );
self::$generating_title = false;
return $result;
}Alternatively, avoid calling wp_get_document_title() from within Nexter_Content_SEO_Schema::get_replacements() when that method is reached via the title filter chain, or resolve the title replacement variable from a cached value rather than re-invoking the full title generation.