WP Manifestindependent plugin directory
manifest / updates / git-updater

Git Updater WP Registry grade D. High-severity findings · 20 findingsWP RegistryHigh-severity findings · 20 findingsOpen the reportD #2 in Updates & Deployment

This WP plugin will update GitHub, Bitbucket, GitLab, and Gitea hosted plugins and themes

★ 3.3kstars
1.9Mrelease downloads
20kcomposer installs
14.4.2latest · 1mo ago

Install

Download git-updater-14.4.2.zip From the 14.4.2 release on GitHub, Sep 4, 2026

Upload it under Plugins → Add New Plugin → Upload Plugin, then activate.

Install straight from GitHub with WP-CLI:

wp plugin install https://github.com/afragen/git-updater/releases/download/14.4.2/git-updater-14.4.2.zip

On Packagist as afragen/github-updater:

composer require afragen/github-updater
Updates itself. It ships its own updater, so new versions appear under Dashboard → Updates.

A simple plugin to enable automatic updates to your GitHub hosted WordPress plugins, themes, and language packs. Additional API plugins available for Bitbucket, GitLab, Gitea, and Gist.

Comprehensive information regarding Git Updater is available in the Knowledge Base.

Install the latest version here.

Description

This plugin was originally designed to simply update any GitHub hosted WordPress plugin or theme. Your plugin or theme must contain a header in the style.css header or in the plugin's header denoting the location on GitHub. The format is as follows.

GitHub Plugin URI: https://github.com/afragen/git-updater

or

GitHub Theme URI: https://github.com/afragen/test-child

...where the above URI leads to the owner/repository of your theme or plugin. The URI format is https://github.com/<owner>/<repo>. You should not include any extensions like .git.

API Plugins

API plugins for Bitbucket, GitLab, Gitea, and Gist are available. API plugins are available for a one-click install from the Add-Ons tab.

Sponsor

Purchase a license at the Git Updater Store. An unlimited yearly license is very reasonable and allows for authenticated API requests. There is an initial free trial period. After the trial period Git Updater will not be able to make authenticated API requests.

You can sponsor me on GitHub to help with continued development and support.

Slack

We now have a Slack team for Git Updater. Please click here for an invite. You will be automatically added to the #general and #support channels. Please take a look at other channels too.

Translations

If you are a polyglot I would greatly appreciate translation contributions to GlotPress for Git Updater.

Releases

Newest 25 of 60 recorded releases. Each count is every asset in that release; expand a row for the breakdown.

Tag
Published
Assets
Downloads
14.4.2 latest
Sep 4, 2026 1mo ago
git-updater-14.4.2.zip
313
Sep 1, 2026 1mo ago
git-updater-14.4.1.zip
16
Aug 27, 2026 1mo ago
git-updater-14.4.0.zip
32
Aug 8, 2026 2mo ago
git-updater-14.3.0.zip
98
Aug 5, 2026 2mo ago
git-updater-14.2.3.zip
33
Aug 5, 2026 2mo ago
git-updater-14.2.2.zip
4
Aug 4, 2026 2mo ago
git-updater-14.2.1.zip
10
Jul 29, 2026 2mo ago
git-updater-14.2.0.zip
68
Jul 24, 2026 2mo ago
git-updater-14.1.0.zip
24
Jul 22, 2026 2mo ago
git-updater-14.0.2.zip
13
Jul 22, 2026 2mo ago
git-updater-14.0.1.zip
5
Jul 21, 2026 2mo ago
git-updater-14.0.0.zip
17
Jun 5, 2026 4mo ago
git-updater-13.0.1.zip
258
May 31, 2026 4mo ago
git-updater-13.0.0.zip
131
Mar 25, 2026 6mo ago
git-updater-12.24.2.zip
481
Mar 18, 2026 6mo ago
git-updater-12.24.1.zip
38
Mar 11, 2026 7mo ago
git-updater-12.24.0.zip
37
Feb 12, 2026 8mo ago
git-updater-12.23.1.zip
173
Feb 12, 2026 8mo ago
git-updater-12.23.0.zip
22
Jan 14, 2026 9mo ago
git-updater-12.22.0.zip
229
Dec 31, 2025 9mo ago
git-updater-12.21.0.zip
101
Dec 8, 2025 10mo ago
git-updater-12.20.2.zip
152
Nov 26, 2025 10mo ago
git-updater-12.20.1.zip
70
Nov 24, 2025 10mo ago
git-updater-12.20.0.zip
18
Sep 29, 2025 1 year ago
git-updater-12.19.0.zip
339
All releases on GitHub →

No active-site estimate. Release downloads fell from a median of 79k per release to about 30, so sites most likely update through a channel other than these release assets, and the cohort method would only be counting stragglers. Method.

D grade

Security

Git Updater 14.4.2 · audited by WP Registry

High-severity findings.

4 high 6 medium 10 low
Audited release
14.4.2
Findings
20
Worst severity
high
Content hash
65c1bba892a7fb6a81d21a7a…

Findings

  • high Unauthenticated REST API issues signed download URLs for private Git-hosted packages

    missing_auth

    plugins-api, themes-api, update-api, download-token, and download are registered with permission_callback __return_true. get_api_data() serves any slug returned by get_plugin_configs()/get_theme_configs() (every locally installed git-hosted plugin/theme). The private_package deny list is consulted only when that slug also exists in the git_updater_additions site option, so a private GitHub repo that was never added on the Additions tab is treated as public. For is_private repos (or any repo with a stored per-slug token, GitLab/Gitea, or uses_lite), the handler replaces download_link with an HMAC-SHA256 URL signed with wp_salt('auth'). plugins-api/themes-api get a 12-hour /download/{slug}?expires=&signature= URL; update-api gets /download-token/{slug}, which mints a 60-second signed URL. git_updater_lite_authorized_domains is checked only inside get_download_token() and is skipped when empty (the default). proxy_download() then wp_remote_get()s the upstream zip with the site's Authorization Bearer token and streams it to the caller. Observable: unauthenticated GET /wp-json/git-updater/v1/plugins-api?slug=<slug> (slug is visible in public wp-content/plugins/<slug>/ asset URLs) followed by GET of the returned download_link yields the private package zip. This also bypasses the advertised Lite Client Domains control.

    src/Git_Updater/REST/REST_API.php:109-201,501-649,819-855,871-988

    Recommendation

    Default-deny private (is_private) repos on the public API unless they are explicitly listed as shared. Apply the Lite domain allow-list (or the REST API key) on plugins-api, themes-api, and /download/, not only on /download-token/. Keep HMAC signed URLs as a second factor, not the only gate.

  • high CSRF on remote plugin/theme install (nonce verification disabled)

    csrf

    Install::install() runs from the Git Updater settings page callback (manage_options) whenever POST option_page=git_updater_install. The form calls settings_fields('git_updater_install') so a nonce is rendered, but install() never calls wp_verify_nonce/check_admin_referer, and the file phpcs-disables WordPress.Security.NonceVerification.Missing. There is also no current_user_can('install_plugins'|'install_themes') inside the handler. A logged-in administrator who visits an attacker page that POSTs git_updater_api=zipfile, git_updater_repo=<attacker-controlled zip URL>, and zipfile_slug=<slug> to options-general.php?page=git-updater&tab=git_updater_install_plugin will have Plugin_Upgrader/Theme_Upgrader install that zip. Zipfile_API::remote_install() uses the posted URI as download_link with no host allow-list. Consequence: attacker-chosen PHP lands in wp-content/plugins or themes (RCE as the admin who was CSRF'd).

    src/Git_Updater/Install.php:161-227,340-358

    Recommendation

    Call check_admin_referer('git_updater_install-options') (or the nonce settings_fields() already prints) and current_user_can('install_plugins') / current_user_can('install_themes') before running the upgrader. Restrict zipfile install URLs to https and, if possible, known git hosts.

  • high Unauthenticated download of private-repository packages via plugins-api/themes-api signed proxy URLs; Lite domain allow-list not enforced on these routes

    information_disclosure

    The git-updater/v1 plugins-api, themes-api and update-api REST routes are registered with permission_callback '__return_true' (REST_API.php:109-142). get_api_data() accepts any slug present in get_plugin_configs()/get_theme_configs() — every installed plugin/theme carrying a Git Updater header — and the only exclusion is the Additions 'private_package' flag (512-521), which cannot be set for repos declared via native plugin headers. The handler forces gu_disable_wpcron=false (527) so Base::get_remote_repo_meta() runs for an anonymous caller and queries the git host with the site's stored token. For private repos, per-slug-token repos, GitLab/Gitea repos and uses_lite repos (634-647) the response's download_link becomes a 12-hour HMAC-signed URL (sign_download_url, 761-774). proxy_download (871-989) verifies only that signature, then build_download_metadata() attaches the stored access token as an Authorization: Bearer header (1123-1128 -> Traits/Basic_Auth_Loader.php:82-84) and streams the private zip to the anonymous caller (922, 972). The response also discloses the repo's readme/changelog sections, URL, is_private and version list. The 'Lite Client Domains' allow-list the settings UI presents as the way to restrict private packages (Lite_Domains.php:253, filter git_updater_lite_authorized_domains) is consulted only inside get_download_token (823-844); plugins-api/themes-api never call it, so a caller bypasses it by not using update-api. Walk: GET /wp-json/git-updater/v1/plugins-api?slug=<private-plugin-folder> -> copy download_link -> GET /wp-json/git-updater/v1/download/<slug>?expires=..&signature=.. -> full zip of the private repo. Plugin folder names are trivially enumerable from front-end asset URLs. The signature is bound to slug+expires+wp_salt('auth') so there is no cross-slug replay; the token itself never leaves the server. Confirms the prior Grok 4.6 finding on this build.

    src/Git_Updater/REST/REST_API.php:109-142, 501-528, 634-647, 761-774, 871-989, 1123-1128

    Recommendation

    Default private repos (is_private, per-slug token, uses_lite) to NOT be served by the public API unless the admin explicitly opts a slug in; apply the git_updater_lite_authorized_domains allow-list and the private_package check uniformly in get_api_data, get_additions_api_data, get_download_token and proxy_download; do not mint signed URLs or run token-backed metadata fetches for anonymous callers of non-shared repos.

  • high CSRF in Remote Install (Install::install): no nonce or install_plugins check; attacker-chosen host receives the site's GitHub Bearer token and an arbitrary package is installed

    csrf

    Install::install() runs whenever the Git Updater settings page renders the install tab (Settings.php:347 -> Install.php:143-150) and gates only on $_POST['option_page'] === 'git_updater_install' (166); the settings_fields() nonce emitted by create_form() is never verified (the file phpcs-disables WordPress.Security.NonceVerification.Missing), and there is no current_user_can('install_plugins'/'install_themes') check (those caps only affect tab visibility, 114-119). Settings::create_admin_page (298) only rejects a *bad* _wpnonce, not a missing one. An administrator lured to an attacker page that auto-submits POST /wp-admin/options-general.php?page=git-updater&tab=git_updater_install_plugin with option_page=git_updater_install, git_updater_api=github, git_updater_repo=https://attacker.tld/o/r triggers: parse_header_uri -> base_uri=https://attacker.tld -> GitHub_API::remote_install builds download_link https://attacker.tld/api/v3/repos/o/r/zipball/master (API/GitHub_API.php:719-726) -> Plugin_Upgrader->install($url) (222). The upgrader_pre_download hook (213-218) adds download_package; get_type_for_credentials returns 'github' because $_POST['git_updater_api'] is set and the URL contains basename(git_updater_repo) (Traits/Basic_Auth_Loader.php:278-281), so add_auth_header sends 'Authorization: Bearer <github_access_token>' to attacker.tld (Basic_Auth_Loader.php:82-84). The attacker's server then returns a zip, which is installed into wp-content/plugins (or themes) — not activated, but any PHP file in it is directly requestable. With git_updater_api=zipfile the download URL is arbitrary (Zipfile_API.php:122), and a posted github_access_token field is persisted as the per-repo token (GitHub_API.php:736-738 -> Install.php:205-207). Because install_plugins is not checked, the path also works with DISALLOW_FILE_MODS. Confirms the prior Grok 4.6 finding; the token-exfiltration amplifier (GHE base_uri derived from the submitted URI) is additional.

    src/Git_Updater/Install.php:142-230

    Recommendation

    At the top of install() (non-CLI path) call check_admin_referer('git_updater_install-options') and require current_user_can('install_plugins') / ('install_themes') for the respective type; only attach stored credentials to hosts that match the configured provider domain (github.com/api.github.com or the saved enterprise server), never to a host derived from the submitted URI; restrict zipfile installs to https and validate the host.

  • medium CSRF can reset the REST API webhook key with a GET request

    csrf

    Remote_Management::reset_api_key() has no capability check and no nonce. It fires from Settings::update_settings() on admin_init whenever $pagenow is options.php or options-general.php (network: settings.php/edit.php) and $_REQUEST contains tab=git_updater_remote_management plus git_updater_reset_api_key. The settings form that triggers this is a POST without wp_nonce_field. Because the check reads $_REQUEST, a GET to /wp-admin/options-general.php?tab=git_updater_remote_management&git_updater_reset_api_key=1 as a logged-in administrator is enough. Consequence: git_updater_api_key is replaced with bin2hex(random_bytes(32)); existing Git Remote Updater / CI webhooks that used the old key fail until an admin copies the new value. The attacker does not learn the new key.

    src/Git_Updater/Remote_Management.php:96-99,219-236

    Recommendation

    Require POST, check_admin_referer, and current_user_can('manage_options') (or manage_network_options on multisite) before rotating git_updater_api_key. Add wp_nonce_field() to the reset form.

  • medium Empty REST API key compared with hash_equals allows first-request bypass of repos/flush/reset-branch

    auth_bypass

    ensure_api_key_is_set() writes a new git_updater_api_key to the database when the static is empty but never assigns that value back to Remote_Management::$api_key. REST handlers get_remote_repo_data, flush_repo_cache, and reset_branch compare the request key to (string) get_class_vars('Remote_Management','api_key') via hash_equals and, unlike Rest_Update::process_request, do not reject an empty key with `if ( ! $key )`. On the first request after install (or after the option is deleted), before Settings::page_init instantiates Remote_Management, the static is still false/''. hash_equals('', '') is true, so an unauthenticated client can omit or empty the key parameter. Observable: GET /wp-json/git-updater/v1/repos returns every git-hosted slug/type/version on the site (useful for the download finding); GET /flush-repo-cache?slug=... deletes the ghu-* cache option; GET /reset-branch?plugin=... can unset current_branch_* in git_updater if that option key already exists. Subsequent requests load the generated key and the bypass closes. Rest_Update::process_request is not affected because it bails on `! $key`.

    src/Git_Updater/Remote_Management.php:41-45

    Recommendation

    After generating the key, set self::$api_key to the new value (and re-read get_site_option). Reject empty keys before hash_equals, matching Rest_Update::process_request. Prefer moving the comparison into permission_callback so failures are HTTP 401.

  • medium Lite download-token domain allow-list trusts the caller-supplied X-GU-Site-Domain header

    auth_bypass

    get_download_token() implements the 'authorized domains' restriction by reading the X-GU-Site-Domain request header (REST_API.php:823) and comparing it to the configured allow-list (826-844). The header is entirely attacker-controlled, so any caller obtains a valid 60-second signed download URL for a domain-restricted private package by sending X-GU-Site-Domain: <allowed-domain>. The route has permission_callback '__return_true' (184-201). This is the only place the Lite Client Domains feature is enforced, so the feature provides no security boundary.

    src/Git_Updater/REST/REST_API.php:819-855

    Recommendation

    Bind clients with a per-domain secret or signed challenge (e.g. a per-site key issued by the server, or an HMAC over slug+domain+timestamp signed with a shared secret) rather than a bare self-asserted header; at minimum document that the header is not a security boundary.

  • medium CSRF: Remote Management 'Reset REST API key' has no nonce (form and handler)

    missing_nonce

    The reset form rendered by Remote_Management::add_admin_page (lines 96-99) contains no wp_nonce_field, and reset_api_key() (219-236) regenerates git_updater_api_key whenever $_REQUEST['tab']==='git_updater_remote_management' and $_REQUEST['git_updater_reset_api_key'] are present, with no nonce or explicit capability check. It is invoked unconditionally from Settings::redirect_on_save (Settings.php:778), which runs from Settings::update_settings on admin_init for options.php/options-general.php (Settings.php:120-123; hooks registered for users with manage_options via Base::load, Base.php:149). A cross-site GET or POST to /wp-admin/options.php?tab=git_updater_remote_management&git_updater_reset_api_key=1 by a logged-in administrator silently rotates the key, invalidating all configured webhooks, Git Remote Updater and CI integrations. Subscribers cannot reach it because the Settings hooks are only registered under can_update().

    src/Git_Updater/Remote_Management.php:96-99, 219-236

    Recommendation

    Add wp_nonce_field('gu_reset_api_key') to the form and verify it with check_admin_referer() plus current_user_can('manage_options'/'manage_network_options') inside reset_api_key(); only accept POST.

  • medium Freemius SDK 2.13.4: opt-in completion (_activate_new) runs from admin_menu with no nonce or capability check — subscriber can bind the site to an attacker-controlled Freemius account

    auth_bypass

    In anonymous/skipped mode, add_menu_action() (hooked to admin_menu for every wp-admin user) calls _install_with_new_user() whenever fs_action=git-updater_activate_new is present (class-freemius.php:18576-18580). The check_admin_referer in that method is commented out (17575) and there is no current_user_can on the path; _prepare_admin_menu (18504-18511) only bails when $this->_site is an object and unregistered, which is not the case after Skip. install_with_new_user() fetches the user and install from the Freemius API with the request-supplied user_secret_key/install_secret_key (any Freemius account that opted in with git-updater elsewhere has valid ones) and setup_account() stores them, registering the site under the attacker's account with attacker-chosen tracking flags. For this premium-flagged product the require_license_activation flag (which would force activation mode) can be cleared in the same request via ?require_license=false (constructor _load_account(), 16715-16719). Result: site URL/WP/PHP/plugin-theme inventory is reported to the attacker's Freemius dashboard and the admin's own opt-in/licensing flow is pre-empted. Identical in upstream SDK master; this is a bundled-SDK issue, not Git Updater-specific code.

    vendor/freemius/wordpress-sdk/includes/class-freemius.php:18576-18580, 17571-17606, 16715-16719

    Recommendation

    Gate _install_with_new_user()/_install_with_current_user() and the add_menu_action() branch on $this->is_user_admin(), and bind the redirect-back flow to a per-request state token stored before redirecting to Freemius instead of trusting bare keys in the URL. Remove the request-controlled require_license flag clearing from the constructor or restrict it to admins. Report upstream to Freemius.

  • medium Freemius SDK 2.13.4: clone-resolution AJAX handler is nonce-only and the nonce is printed to every wp-admin user — subscriber can re-home or re-create the Freemius install

    missing_capability

    Once a clone is flagged (clone_identification_timestamp set because the site URL changed vs the stored install), FS_Clone_Manager::_init() adds _add_clone_resolution_javascript to admin_footer unconditionally (161-163), so any logged-in user (e.g. on profile.php) receives wp_create_nonce('fs_handle_clone_resolution'). _clone_resolution_action_ajax_handler() (833-855) only calls check_ajax_referer and then executes resolve_cloned_sites() with the attacker's clone_action: new_home calls sync_install(['is_new_site'=>true]) (moves the install to this URL), long_term_duplicate calls _handle_long_term_duplicate() which deletes the local install and opts in again (consuming a license seat), and every branch removes the administrator's clone notice and pushes a resolution update to Freemius.

    vendor/freemius/wordpress-sdk/includes/managers/class-fs-clone-manager.php:833-855, 161-163

    Recommendation

    Add a capability check (is_super_admin() / current_user_can('manage_options')) at the top of _clone_resolution_action_ajax_handler() and only enqueue the clone-resolution JS/nonce when the clone notice is actually rendered to a manage_options user.

  • low Empty REST API key accepted on repos / flush-repo-cache / reset-branch during the first request after activation or CLI key reset (static never refreshed after generation)

    auth_bypass

    Remote_Management::__construct (31-34) copies get_site_option('git_updater_api_key') into self::$api_key and calls ensure_api_key_is_set(), which writes a fresh key to the option (41-45) but never updates the static. The REST handlers obtain the key lazily via get_class_vars('Remote_Management','api_key') (REST_API.php:443, 710, 1159), so in the first request that ever instantiates the class the compared value is (string) false === ''. WordPress' REST 'required' check only rejects a missing param, and validate_callback 'sanitize_text_field' returns '' (not false) for an empty value (100-104), so GET /wp-json/git-updater/v1/repos?key= succeeds with hash_equals('', '') === true and returns the full inventory of Git Updater-managed plugins/themes; reset-branch?key=&plugin=<slug> rewrites current_branch_<slug> in the git_updater option and flush-repo-cache?key=&slug=<slug> deletes the cache. The class is otherwise only instantiated from the settings-page admin_init (Settings.php:441), so the window is the single first request after activation (before an admin opens options-general.php), and it re-opens after 'wp git-updater reset-api-key' (WP_CLI/CLI.php:66-68): that deletes the option but ensure_api_key_is_set() sees the still-truthy static and does not regenerate, leaving the option absent until the next instantiation. The 'update' route is unaffected (Rest_Update.php:272 rejects an empty key).

    src/Git_Updater/Remote_Management.php:31-45

    Recommendation

    Assign the generated key to self::$api_key inside ensure_api_key_is_set() (re-reading the option), reject empty keys before hash_equals in every REST handler, and in the CLI reset command generate the new key unconditionally instead of relying on the stale static.

  • low verify_download_signature logs the expected (valid) HMAC to the PHP error log on every failed verification

    debug_code

    When a download signature does not match, verify_download_signature() writes slug, expires, the submitted signature, the *expected* valid signature and the secret length to error_log (REST_API.php:795-807). On sites with WP_DEBUG_LOG writing to a web-readable wp-content/debug.log this hands out valid signed-download URLs for arbitrary slug/expires pairs (an attacker chooses expires far in the future) and lets an attacker fill logs with chosen slugs. Impact is limited today because signed URLs are already freely obtainable from plugins-api (see the high finding), but the log oracle survives any future tightening of that route.

    src/Git_Updater/REST/REST_API.php:795-807

    Recommendation

    Remove the log line or log only the slug and a 'signature mismatch' message; never log the expected MAC or secret metadata.

  • low Bearer token attached by repo type, not destination host; Authorization forwarded across redirects

    information_disclosure

    Basic_Auth_Loader::get_credentials() decides to send the site-wide github_access_token (or per-repo token) purely on `'github' === $type || $this instanceof GitHub_API` (line 155). It computes $credentials['enterprise'] at line 163 but nothing checks it, so add_auth_header() (lines 82-84) attaches `Authorization: Bearer <token>` to any URL the update flow hands it. WordPress's bundled Requests re-sends the original headers on cross-host redirects (wp-includes/Requests/src/Requests.php:809), so the header follows any 3xx. Concrete unintended destinations today: (1) a wordpress.org-hosted plugin that also carries a `GitHub Plugin URI` header keeps its wp.org package in the transient (override_dot_org() returns false) but get_type_for_credentials() still resolves $repos[$slug]->git === 'github', so an admin-run update sends the GitHub token to downloads.wordpress.org; (2) any `GitHub Plugin/Theme URI` or `GitHub Languages` header whose host does not contain 'github.com' (GU_Trait.php:932) becomes an 'enterprise' API base that receives the site's github.com token. Case 2 requires a plugin author or admin to set the header, so the marginal risk is credential exposure to a host the site already trusts for code, not to an arbitrary attacker.

    src/Git_Updater/Traits/Basic_Auth_Loader.php:60-101, 155-165

    Recommendation

    Bind the credential to the destination: only attach Authorization when the URL host is api.github.com/github.com/objects.githubusercontent.com or exactly equals the configured enterprise host for that repo; never attach it for downloads.wordpress.org. Hook `requests-requests.before_redirect` to strip Authorization when the redirect target's host differs from the original request host.

  • low Unauthenticated request input selects which stored secret is sent as the Bearer token during background refresh

    unsafe_input

    Base::load() runs on `init` and should_run_on_current_page() (GU_Trait.php:120) accepts $pagenow 'index.php', which WordPress sets for every front-end request. When a repo's cache TTL has lapsed, an unauthenticated page view triggers GitHub_API::api() with the stored token. Inside that call, get_slug_for_credentials() reads $_REQUEST['slug'] / $_REQUEST['plugin'] (lines 200-204) and get_credentials() uses the value as an option key: `$token = ! empty( $options[ $slug ] ) ? $options[ $slug ] : $token` (line 157). A visitor POSTing `slug=gitlab_access_token`, `slug=github_refresh_token`, or any per-repo token key causes that secret to be sent as `Authorization: Bearer` to the repo's configured API host. get_type_for_credentials() (lines 278-281) likewise lets `git_updater_api=github&git_updater_repo=/` force the GitHub type for any URL (basename('/') is '' and str_contains($url, '') is true). The destination host is still admin-configured (api.github.com or a GHE host), so this is cross-provider credential transmission between trusted hosts rather than exfiltration; a bogus token also yields a 401 that sets a 5-minute error_cache for the repo (API.php:219-230) and a forced OAuth refresh, throttled by the 30s/60s transients.

    src/Git_Updater/Traits/Basic_Auth_Loader.php:157, 198-227, 276-281

    Recommendation

    Only consult $_REQUEST/$_POST inside download_package() (the upgrader path, already capability-gated by core) and pass the slug/type explicitly from the API object in api(); reject $slug values that are not keys of the known repo config; require a non-empty git_updater_repo basename before honoring git_updater_api.

  • low Remote changelog/readme rendered to HTML without Parsedown safe mode or kses before caching

    missing_escaping

    get_remote_api_changes() runs the repository's CHANGES.md through Parsedown with no setSafeMode()/setMarkupEscaped() (API_Common.php:246-247) and Readme_Parser::faq_as_h4() concatenates raw question/answer HTML (Readme_Parser.php:110); the result is stored in the ghu-* option and surfaced as plugins_api/themes_api sections and via the git-updater/v1 REST API. Every in-scope core consumer applies wp_kses with the plugins/themes allowed-tags list (wp-admin/includes/plugin-install.php:582, class-wp-theme-install-list-table.php:524), and upgrade_notice is strip_tags'd by core, so a compromised or forked repo cannot reach script execution through these paths today. The unescaped HTML is nevertheless persisted and handed to any other consumer without defense in depth.

    src/Git_Updater/Traits/API_Common.php:246-248

    Recommendation

    Call $parser->setSafeMode( true ) and wrap the output in wp_kses_post() (or the plugins_allowedtags list) before set_repo_cache(); apply the same to the Readme_Parser sections so cached HTML is safe regardless of consumer.

  • low Freemius SDK: _set_data_debug_mode AJAX handler has no nonce and no capability check (license-key md5 oracle, debug-mode transient flip)

    missing_nonce

    wp_ajax_fs_set_data_debug_mode_11525 is registered unconditionally (class-freemius.php:1554). The handler's only gate is is_whitelabeled(true); it never calls check_ajax_referer or is_user_admin(). Any logged-in user on a whitelabeled install can POST an empty license_or_user_key to disable data-debug mode, or iterate keys against md5($key) !== $stored_key which returns distinguishable success/failure JSON, giving an oracle on the stored license/user key hash. Limited blast radius.

    vendor/freemius/wordpress-sdk/includes/class-freemius.php:20758-20811

    Recommendation

    Call $this->check_ajax_referer('set_data_debug_mode') (the template already sends security) and require is_user_admin(); use hash_equals() for the comparison.

  • low Freemius SDK: unauthenticated request parameter clears require_license_activation in the constructor

    missing_capability

    _load_account() runs inside the Freemius constructor on every request (git-updater loads the SDK on front-end and admin alike). If require_license_activation is true and the request carries require_license=false, the flag is persisted as false (class-freemius.php:16715-16719). Any visitor can therefore take the product out of forced license-activation mode; on its own this only suppresses the activation nag, but it is the enabler for the _activate_new opt-in hijack.

    vendor/freemius/wordpress-sdk/includes/class-freemius.php:16715-16719

    Recommendation

    Only honour require_license for admin users on the SDK's own admin pages (or drop the request-controlled override).

  • low wp-dismiss-notice: AJAX dismiss handler has a nonce but no capability check — any logged-in user can suppress notices site/network-wide

    missing_capability

    load_script() localises the wp-dismiss-notice nonce for every wp-admin visitor (147-152), and dismiss_admin_notice() (76-89) only calls check_ajax_referer. A subscriber can POST any option_name (e.g. license-3, used by src/Messages.php:135) with dismissible_length=forever and update_site_option('wpdn-'.md5(id),'forever') hides that notice for all administrators (network-wide on multisite). The key is hashed and the value is constrained to 'forever' or a timestamp, so it cannot be used for arbitrary option writes.

    vendor/afragen/wp-dismiss-notice/wp-dismiss-notice.php:76-89, 147-152

    Recommendation

    Require current_user_can('manage_options') (or the capability of the notice's audience) before writing, or store the dismissal per-user in user meta instead of a site option.

  • low wp-readme-parser: HTML sanitizer does not validate URL schemes — javascript: hrefs survive into parsed sections

    missing_escaping

    NativeHtmlSanitizerAdapter keeps a[href], blockquote[cite] and dt[id] without a protocol allowlist (44-61), and ParsedownAdapter runs Parsedown 1.8.0 without setSafeMode(). Verified by execution: [click](javascript:alert(1)) in a readme description yields <a href="javascript:alert(1)"> in $parser->sections['description']. In git-updater the sections are consumed via plugins_api, where core's install_plugin_information() re-runs wp_kses with bad-protocol stripping, so this is a library-level gap that becomes exploitable only if a caller echoes sections directly. Attacker would be the remote repo owner.

    vendor/afragen/wp-readme-parser/src/Adapters/NativeHtmlSanitizerAdapter.php:44-61, 132-147

    Recommendation

    Apply a scheme allowlist (http, https, mailto) to href and cite (equivalent of wp_kses_bad_protocol), and enable Parsedown::setSafeMode(true) in the adapter.

  • low Freemius SDK: TLS certificate verification disabled by default for API calls (FS_SDK__SSLVERIFY=false) with HTTP fallback

    insecure_config

    All calls to api.freemius.com are made with sslverify => false (config.php:393-395, FreemiusWordPress.php:444), and FS_Api::_call() switches to plain HTTP (api_force_http) on an SSL error. Requests are HMAC-signed so secrets are not exposed, but a network attacker can forge API responses (license status, install data, update metadata/download URLs). Git Updater removes the Freemius plugin-updater hooks (src/Git_Updater/GU_Freemius.php:179-187), which limits impact here to licensing/account state.

    vendor/freemius/wordpress-sdk/config.php:393-395

    Recommendation

    Define FS_SDK__SSLVERIFY as true (or let WordPress' default verification apply) and remove the automatic HTTP downgrade.

WP Registry hashes the installable build and reports on that exact bytes-for-bytes copy. Embargoed findings are withheld until they are disclosed, so a clean verdict means nothing public is outstanding. WP Manifest does not audit code itself.