Skip to content

fix(seo): stop core guessing a destination for a 404 - #159

Merged
parisek merged 3 commits into
mainfrom
fix/no-404-permalink-guess
Aug 27, 2026
Merged

fix(seo): stop core guessing a destination for a 404#159
parisek merged 3 commits into
mainfrom
fix/no-404-permalink-guess

Conversation

@parisek

@parisek parisek commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Ported from a downstream project where it had been a hand-rolled add_filter() in functions.php. It is a reasonable default for this stack rather than a project quirk.

What core does

redirect_guess_404_permalink() matches the requested slug as a prefix and redirects to whatever comes back first:

// wp-includes/canonical.php:979
$where = $wpdb->prepare( 'post_name LIKE %s', $wpdb->esc_like( get_query_var( 'name' ) ) . '%' );

Two problems, and only the first is a matter of taste.

A wrong 301 is worse than a 404. A reader following a dead link is told the page moved and then shown something unrelated. A 404 is a fact; a guess is an answer, and it is confidently wrong.

The query cannot use the index. Leading value, trailing wildcard. It runs on every 404 that carries a name in the URL — a scan any visitor can ask for as often as they like, with no authentication and no rate limit.

Genuine canonical redirects are untouched

Checked rather than assumed. The guess is the last thing redirect_canonical() tries:

// wp-includes/canonical.php:214
if ( ! $redirect_url ) {
    $redirect_url = redirect_guess_404_permalink();

Trailing-slash, ?p=ID-to-slug and category-base have each had their turn before it. Those correct a request that names the right post, and they keep working.

On by default, which reverses core

Disabling the guess loses nothing correct — it only stops the guessing. The site it costs is one that renames slugs without leaving redirects behind and relies on the guess to catch the fallout:

// Base.php
protected bool $disable_404_permalink_guess = false;

Naming

Written as $guess_404_permalink = false first. Every flag in this group reads "true means the kit does the thing" — $disable_xmlrpc, $disable_emojis, $disable_feeds, $disable_self_pingbacks, $disable_file_editing.

The inverted name also broke the family's own test_no_hooks_registered_when_all_flags_disabled, because "all flags off" would have registered a filter. That failure was the signal the polarity was wrong rather than merely unusual — worth recording, because the test caught a naming problem, not a bug.

Tests

Two, both verified load-bearing by removal: flipping the default fails the default test, removing the wiring fails the registration test, and neither touches the other. Added to the existing RegisterSecurityHardeningHooksTest harness rather than a new file, and the property is registered in its bareInstanceWithAllFlagsOff() list.

1878 tests, PHPStan clean.

`redirect_guess_404_permalink()` matches the requested slug as a PREFIX --
`post_name LIKE 'about%'` -- and redirects to whatever comes back first. A
reader following a dead link is told the page moved and then shown
something else. That is worse than being told it is gone: a 404 is a fact,
a wrong 301 is an answer.

The query has a leading value and a trailing wildcard, so it cannot use the
post_name index, and it runs on every 404 that carries a name. That is a
scan any visitor can ask for as often as they like, which is the half of
this that is not a matter of taste.

Genuine canonical redirects are untouched, and that was checked rather than
assumed: the guess is the last thing redirect_canonical() tries
(canonical.php:215), after trailing-slash, ?p=ID-to-slug and category-base
have each had their turn. Those correct a request that names the right
post, and they keep working.

On by default, which reverses core. Disabling it loses nothing correct --
it only stops the guessing. The one site it costs is one that renames slugs
without leaving redirects behind and relies on the guess to catch the
fallout; that site sets the property false.

Named `$disable_404_permalink_guess` rather than `$guess_404_permalink`
after writing it the other way first. Every flag in this group reads
"true means the kit does the thing" -- $disable_xmlrpc, $disable_emojis,
$disable_feeds, $disable_self_pingbacks, $disable_file_editing -- and the
inverted name also broke the family's own
test_no_hooks_registered_when_all_flags_disabled, which was the signal that
the polarity was wrong rather than merely unusual.

Ported from a downstream project where it had been a hand-rolled
add_filter() in functions.php.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JMYy6JHLf4mU4H4Hd47spb (petr@pari.cz)
@parisek parisek self-assigned this Aug 27, 2026
parisek and others added 2 commits August 27, 2026 19:10
…pped it

The release-stamp workflow cut v1.44.0 at 16:23. PR #158 merged at 16:29,
and its Unreleased entry landed under the heading the stamp had just
written -- so CHANGELOG.md credited 1.44.0 with
$acfml_skip_frontend_field_translation while `git show v1.44.0:src/StarterBase.php`
does not contain it.

Nobody would have caught that by reading the file. It was found by checking
the tag before cutting the next release, which is now the reason to keep
doing that.

The entry moves to Unreleased, where it is true, and ships with the next
version alongside this branch's own change.

Carried in this PR rather than its own because the merge with main already
put this file in conflict here, and a two-line move does not earn a third
pull request. Recorded so the next reader knows why an unrelated section
moved in a 404 change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JMYy6JHLf4mU4H4Hd47spb (petr@pari.cz)
@parisek
parisek marked this pull request as ready for review August 27, 2026 17:12
@parisek
parisek merged commit 045f82b into main Aug 27, 2026
6 checks passed
@parisek
parisek deleted the fix/no-404-permalink-guess branch August 27, 2026 17:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant