Skip to content

I18N: Ignore non-string values in the locale getters - #13335

Draft
sirreal wants to merge 1 commit into
WordPress:trunkfrom
sirreal:fix/locale-getter-type-validation
Draft

I18N: Ignore non-string values in the locale getters#13335
sirreal wants to merge 1 commit into
WordPress:trunkfrom
sirreal:fix/locale-getter-type-validation

Conversation

@sirreal

@sirreal sirreal commented Aug 31, 2026

Copy link
Copy Markdown
Member

get_locale(), get_user_locale() and determine_locale() each document @return string, then take that value from the WPLANG option and site option, the WPLANG constant, the $locale and $wp_local_package globals, the locale user meta row, $_GET['wp_lang'], $_COOKIE['wp_lang'], or the locale, option_WPLANG and determine_locale filters. None of those carry a type, and the only guards are empty() and truthiness, which a non-empty array passes.

An array locale reaches WP_Textdomain_Registry::set(), which uses it as an array key: TypeError: Cannot access offset of type array on array. That fires on the first just-in-time translation for any unloaded text domain. The registry's docblocks declare string $locale, so the fix belongs upstream of it.

Two inputs reach that state, both covered by tests here:

  • A locale user meta row holding an array. WP_User has no locale property, so $user_object->locale resolves to get_user_meta( $id, 'locale', true ) on every read. Admin requests and _locale=user JSON requests route determine_locale() through get_user_locale().
  • wp-login.php?wp_lang[]=de_DE, from an anonymous visitor. sanitize_locale_name() is a preg_replace(), which maps over an array subject and returns an array, and if ( ! $determined_locale ) then treats that array as usable.

The change

is_string() joins each existing non-empty check. The fallbacks are the ones already in place: en_US for the site locale, get_locale() for the user locale, the unfiltered value when a filter returns something else. Nothing here inspects the shape of the string, so no locale value in use today changes.

_doing_it_wrong() would recurse: it calls __(), which comes back through determine_locale() and get_user_locale().

wp_insert_user() also drops a non-string locale field, the one core write path for that row without a check; the profile handler, WP_REST_Users_Controller and register_new_user() each have one. That write guard cannot stand alone, hence the read-side guards: existing rows are never rewritten, update_user_meta() skips wp_insert_user(), and direct SQL skips core. get_locale() sits the same way, since sanitize_option() covers update_option() but not a direct write or an option_WPLANG filter.

Testing instructions

File On trunk With this patch
l10n/getUserLocale.php 6 failures, 1 error 21 pass
l10n/getLocale.php 24 failures 36 pass
l10n/getLocale.php, multisite 24 failures 30 pass
l10n/determineLocale.php 22 failures, 1 error 48 pass
user.php, the new test over 7 data sets 7 failures 7 pass

The two errors are the reproductions that run through to WP_Textdomain_Registry. PHPUnit escalates the "Array to string conversion" notice in get_path_from_lang_dir() into an exception, so they stop one frame short of the TypeError in set().

--group l10n, --group i18n and --group user gain no new failures.

Follow-ups

  • Checking locale shape through sanitize_locale_name() would rewrite stored locales that currently work, so it wants its own ticket.
  • sanitize_locale_name() returns an array for an array argument under a @return string docblock. Its other callers need a look first.
  • Hardening WP_Textdomain_Registry against its own callers.

Sweeping src/wp-includes/ and src/wp-admin/ for the same shape turned up more reachable cases, mostly guards that were adequate until PHP 8 stopped coercing arrays for internal functions. Separate tickets.

Trac ticket: TBD

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5
Used for: Investigation, the patch, the tests and this description, working from my brief and my decisions on where the guard belongs, what it checks and what to leave alone. Every added test was run red against unmodified trunk and green with the patch. This stays a draft until I have reviewed it line by line.


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

`get_locale()`, `get_user_locale()` and `determine_locale()` are each documented
to return a string, and each takes its value from a source that carries no type:
the `WPLANG` option and site option, the `WPLANG` constant, the `$locale` and
`$wp_local_package` globals, the `locale` user meta row, the `wp_lang` request
parameter and cookie, and the `locale`, `option_WPLANG` and `determine_locale`
filters. Only `empty()` and truthiness stood in the way, and a non-empty array
passes both.

A non-string locale reaches `WP_Textdomain_Registry::set()`, which uses it as an
array key, so the first just-in-time translation for an unloaded text domain
ends the request with "Cannot access offset of type array on array". On
`wp-login.php` that needs no authentication: `sanitize_locale_name()` applies
`preg_replace()`, which maps over an array subject and returns an array, so
`?wp_lang[]=de_DE` carries one straight through.

Add `is_string()` beside the non-empty checks that are already there, and fall
back the way each function already falls back: to `en_US` for the site locale,
to `get_locale()` for the user locale, and to the unfiltered value when a filter
returns something else. Shape is left alone; only the type and emptiness are
checked, so locales that work today keep working.

Also ignore a non-string `locale` field in `wp_insert_user()`, the one core
write path that stored it unchecked. That does not repair rows already in the
database, which is why the read side is guarded too.
@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

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