Skip to content

1.10.1 release audit fixes: R1 (PHP 7.4 fatal), S1/S2/S3/S7.1, B2/B4/B8/B10 - #784

Merged
ilicfilip merged 13 commits into
developfrom
filip/release-1.10.1-audit-fixes
Sep 22, 2026
Merged

ilicfilip merged 13 commits into
developfrom
filip/release-1.10.1-audit-fixes

Conversation

@ilicfilip

Copy link
Copy Markdown
Collaborator

What

Fixes from the 1.10.0 release audit. Each change ships with a red-then-green regression test (except B10, a rendered-template attribute with no unit seam).

Release blocker

  • R1Branding::get_admin_submenu_position() declared a mixed return type, which fatals on PHP 7.4 (the plugin's declared minimum) on every wp-admin page. Present in the shipped v1.10.0 tag, absent from v1.9.1 — a regression. Also pins phpVersion: 70400 in phpstan.neon.dist so PHP 8-only syntax is caught statically (the lint job only checks syntax; the PHPUnit matrix starts at 8.2).

Security

  • S1/S2 — the prpl_recommendations CPT inherited default post capabilities with show_in_rest => true and no permission overrides, so any Contributor/Author could create/alter/trash/enumerate tasks via core REST (including slug-squatting to suppress a real recommendation), and any published task was readable anonymously by ID. Now requires edit_others_posts — the plugin's own UI gate — for every read/write; the provider taxonomy's term-write caps are mapped to match.
  • S3 — the pp_branding_id preview URL param was honoured for anonymous requests, letting an outsider steer the branding ID (which gates the auto-onboard remote call). Now honoured only for logged-in users.
  • S7.1 — an array provider[] / exclude_provider[] REST param raised a PHP 8 TypeError (500); now normalised for both string and array input.

Functional bugs

  • B2 — the email-sending provider minted a completion token on every request, overwriting the token stored when the test email was sent (invalidating the inbox link on the next Heartbeat/navigation). The init() token generation was dead code — the AJAX handler builds and sends its own. Removed.
  • B4 — the monthly badge's activity window used gmdate('t', strtotime($badge_name)); the badge name isn't a date, so it always yielded 31 and overflowed shorter months into the next (Feb → Mar 3). Now derived from the month number, matching the widget.
  • B8 — the "Set page" task could complete with "I have this page" selected but no page chosen (saved have_page=yes, id=0). Now rejects that combination.
  • B10 — the popover context check compared against 'popovers' while the caller passed 'popover', so the "Create this page" link always opened a new tab. Strings now match.

Explicitly out of scope

Reviewed and dispositioned, no change: S4/S5/S6 (require SaaS compromise / prior decisions), S7.2–S7.8 (LOW hygiene or not real findings), B1 (intended behaviour per #702), B3, B12 (by design), B13 (moot). B5/B6/B7/B9/B11/B14 remain as known lower-value items.

Testing

Full PHPUnit suite green (437 tests, 9 pre-existing risky, 0 failures), PHPStan level 10 pinned to PHP 7.4 clean, composer check-cs at default severity clean.

Note

R1 is already present in the released v1.10.0 tag, so this needs a patch release + re-tag to actually protect 7.4 users.

🤖 Generated with Claude Code

ilicfilip and others added 8 commits September 21, 2026 10:51
The `prpl_recommendations` CPT is registered with `show_in_rest => true` but
no `capability_type`/`capabilities`, so it inherited WordPress's default
`post` capabilities. Its REST controller added no permission overrides. This
opened two holes (1.10.0 release audit S1/S2):

S1 (HIGH) — any Contributor/Author (`edit_posts`) could, via core REST:
  - create a task carrying a known recommendation slug in `trash` status;
    `Suggested_Tasks_DB::add()` treats any existing post with that slug as
    "the task already exists" and returns early, so the genuine
    recommendation (e.g. "Perform all updates") is silently suppressed and
    never shown to the admin;
  - create `pending`/`publish` tasks with attacker-chosen titles that are
    then rendered in every admin's dashboard widget.
An Editor could also trash/delete admin-only tasks over REST.

S2 (MEDIUM) — any published task was readable anonymously by ID
(`GET /wp/v2/prpl_recommendations/<id>`), leaking pending-update state,
draft/unpublished titles, and the active SEO plugin. The existing collection
filter (`rest_api_tax_query`) only guards the list route, not single items;
and core's `check_read_permission()` allows any `publish` post to be read
before it consults `read_post`, so capability mapping alone cannot close it.

Fix: override the four `*_permissions_check` methods plus
`get_item_permissions_check` in `Recommendations_Controller` to require
`edit_others_posts` — the exact gate the plugin already uses for its admin UI
(`Base::init()`, `Dashboard_Widget::$capability`). Editors and admins keep
full read/write access; every role below is rejected. Also map the provider
taxonomy's term-write capabilities to the same gate.

Chose controller overrides over remapping the CPT's `capability_type`: it is
surgical, fixes the anonymous single-item read that caps cannot, and avoids
the `map_meta_cap` blast radius on a surface shipped since v1.6.0.

Adds 20 regression tests: contributor/author/subscriber/anonymous are denied
create/read/update/delete (verified red against the unfixed code), while
Editor and admin flows — including creating/deleting their own task and
assigning the provider term — still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`Branding::get_admin_submenu_position()` declared a `mixed` return type.
`mixed` only became a type in PHP 8.0; on PHP 7.4 it is resolved as a class
name, so returning `-1000` or `null` throws:

    TypeError: Return value of ...::get_admin_submenu_position()
    must be an instance of mixed, int returned

The method is called from `Admin\Page::add_page()` on the `admin_menu` hook,
so this fataled on every wp-admin page view for sites on PHP 7.4 — which the
plugin still advertises support for (`Requires PHP: 7.4`).

Introduced in 1c6684b ("Fix PHP 7.4 linting issue"), which replaced the
union type `int|null` (also 8.0+) with `mixed` — swapping one PHP 8-only
syntax for another. Shipped in v1.10.0; not present in v1.9.1.

Removes the native return type and keeps the `@return int|null` docblock,
which is the only form valid on 7.4.

Why CI did not catch it: the lint job does run PHP 7.4, but `php -l` only
checks syntax, and `(): mixed` is syntactically valid on 7.4 — the TypeError
only fires when the function is called. The PHPUnit matrix, which does
execute code, starts at PHP 8.2, and PHPStan ran against the latest PHP.
All three gates reported green.

Pinning `phpVersion: 70400` in phpstan.neon.dist closes that gap: PHPStan
now resolves `mixed` as an unknown class and fails, catching this and the
rest of the PHP 8-only syntax class statically, at no CI cost. Verified it
flags the original code and passes on the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Branding::get_branding_id()` read `$_GET['pp_branding_id']` for any request,
with no authentication. The param is a preview seam for branded-host
rendering (added in #633, "auto-register branded websites"), but because the
branding ID also gates the auto-onboard remote call in `Base::init()`, an
anonymous visitor appending `?pp_branding_id=N` could force a non-zero
branding ID and thereby trigger the server-side onboarding request and a
stored license key (1.10.0 release audit S3).

Gate the param on `is_user_logged_in()`. This keeps the preview flow working
for any authenticated user on both the front end and wp-admin (an admin stays
logged in across both), while an anonymous request now falls through to the
host-based default and can no longer steer branding.

Note: the report's broader S3 remedies (re-gating the Base::init() block,
adding an onboarding back-off transient) are intentionally not applied — in
practice sites are redirected to the capability-gated, nonce-checked
onboarding screen on activation, and the SaaS is fast and CDN-cached, so the
blocking-call/DoS concern does not apply. The unauthenticated param was the
only real defect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`rest_api_tax_query()` passed `$request['provider']` and
`$request['exclude_provider']` straight to `explode()`. When the param
arrives as an array (`?provider[]=a&provider[]=b`) that raised a PHP 8
TypeError and a 500 response (1.10.0 release audit S7.1).

Route both through a new `parse_provider_param()` helper that branches on the
input type — comma-splitting a string, casting an array — and sanitises each
slug with `sanitize_key()`.

Note: with the recommendations endpoint now gated to `edit_others_posts`
(see the REST-permissions commit), this path is no longer reachable
anonymously, so the fix hardens the request handling for authorised callers
rather than closing an unauthenticated 500.

Adds 3 regression tests (array `provider[]`, array `exclude_provider[]`, and
the string form), verified red against the pre-fix code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The email-sending provider's `init()` minted a fresh task-completion token on
every request (it runs on the `init` hook, front end included). Because the
token lives in a single per-task/per-user transient, this overwrote the token
that `ajax_test_email_sending()` stored when the test email was actually sent
— so the "mark as completed" link in the user's inbox was invalidated by the
next request (a Heartbeat poll or page navigation), and clicking it silently
did nothing (1.10.0 release audit B2).

The token generated in `init()` was never used: the AJAX handler builds and
sends its own email body with a freshly generated token at send time, and
`$this->email_content` (the property `init()` populated) has no readers. The
token block was authored redundantly alongside the handler's own generation
and never consumed.

Remove the token generation and `$this->email_content` build from `init()`,
and drop the now-unused property. `$this->email_subject` stays — it is used by
`wp_mail()` and localized to the popover view. This also removes a needless
`set_transient` + `sprintf` from every front-end request.

Sending a new test email still (correctly) mints a new token and supersedes
the previous link.

Adds a regression test asserting `init()` no longer overwrites a token stored
by the send flow (verified red against the pre-fix code).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The set-page interactive task (About / Contact / FAQ) could be completed with
"I have this page" chosen but no page actually selected. The handler only
checked that have_page was non-empty, so a submission with have_page = 'yes'
and id = 0 was saved and marked successful — recording that a page exists
while pointing at no page (1.10.0 release audit B8).

Reject have_page = 'yes' when no valid page id (>= 1) is supplied. The other
two options ("I don't have this page yet" and "My site doesn't need this
page") legitimately carry no page id and are unaffected.

Adds regression tests: yes-without-page is rejected (verified red against the
pre-fix code), while yes-with-a-real-page and the no-page options still
complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The set-page popover passes 'context' => 'popover' to the page-select view,
but the view compared it against 'popovers' (plural). The check never matched,
so the "Create this page" link always fell through to target="_blank" and
opened a new tab even when the user was already inside the popover flow
(1.10.0 release audit B10).

Match the strings: the view now checks 'popover', so the link opens _self
from within the popover as intended. No test — this is a rendered-template
attribute with no unit-test seam; verified via the full suite and lint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The monthly badge computed its activity window with
gmdate( 't', strtotime( $month ) ), where $month is the badge NAME (e.g.
"Felix February"), not a date. strtotime() returned false and gmdate( 't',
false ) always yielded 31, so the end date became "<year>-<month>-31" — which
DateTime overflows for shorter months. February's window ran to March 3 and
30-day months spilled one day into the next, so activity earned early in the
following month was counted toward the previous month's badge. This also
disagreed with the monthly-badges widget, which windows correctly.

Derive the window from the month number instead:
  $start_date = DateTime::createFromFormat( '!Y-n-j', "{$year}-{$month_num}-1" );
  $end_date   = ( clone $start_date )->modify( 'last day of this month' );

Verified for 28/30/31-day months and a leap-year February. The now-unused
$month (badge name) lookup is removed.

Adds regression tests: March 2 activity does not count toward the February
badge (verified red against the pre-fix code), while February activity —
including February 28 — still does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Test merged PR on Playground
Test this pull request on the Playground
or download the zip

@github-actions

github-actions Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

✅ Code Coverage Report

Metric Value
Total Coverage 33.90% 📉
Base Coverage 32.28%
Difference 📈 1.62%

⚠️ Coverage below recommended 40% threshold

🎉 Great job maintaining/improving code coverage!

📊 File-level Coverage Changes (12 files)

📈 Coverage Improved

Class Before After Change
Progress_Planner\Activities\Suggested_Task 50.00% 88.89% +38.89%
Progress_Planner\Suggested_Tasks\Providers\Set_Page_Task 0.00% 36.67% +36.67%
Progress_Planner\Rest\Recommendations_Controller 66.67% 92.86% +26.19%
Progress_Planner\Admin\Page_Settings 39.34% 60.66% +21.32%
Progress_Planner\Suggested_Tasks 9.60% 22.18% +12.58%
Progress_Planner\Suggested_Tasks\Providers\Email_Sending 0.00% 7.79% +7.79%
Progress_Planner\Suggested_Tasks\Tasks_Manager 62.83% 68.14% +5.31%
Progress_Planner\Page_Types 52.68% 55.36% +2.68%
Progress_Planner\Badges\Monthly 75.19% 76.52% +1.33%
Progress_Planner\UI\Branding 29.91% 30.77% +0.86%
Progress_Planner\Base 47.40% 48.08% +0.68%
Progress_Planner\Suggested_Tasks_DB 90.11% 90.58% +0.47%
ℹ️ About this report
  • All tests run in a single job with Xdebug coverage
  • Security tests excluded from coverage to prevent output issues
  • Coverage calculated from line coverage percentages

@tacoverdo tacoverdo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test results for this PR

I checked out the branch (2089b0f4) and ran the full checks plus a set of adversarial PHPUnit probes (roles × REST verbs, XML-RPC, Base::init() with HTTP mocked).

Checks: PHPUnit green (437 tests, 9 risky), PHPStan level 10 clean, PHPCS clean, every file passes php -l on PHP 7.4.

Confirmed fixed

Item Result
R1 – PHP 7.4 fatal Fixed. The phpVersion: 70400 pin works: re-adding : mixed makes PHPStan fail with "invalid return type".
S1 – REST writes by Contributor/Author Fixed. anon → 401; Subscriber, Contributor, Author → 403 on GET item, GET list, POST (trash + slug squat) and DELETE. An Editor can still create a user task (201) and the title is stripped of HTML.
S2 – anonymous read Fixed. Single item and collection both return 401.
S7.1 – ?provider[]=a TypeError Fixed. Array input returns 200 for an Editor, 401 for anon; no 500.
B2 – email completion token Fixed. The token is now only minted in ajax_test_email_sending().
B4, B8, B10 Not probed by hand. The diffs look correct and the PR's tests for B4 and B8 pass; B10 has no test.

Gaps that remain

1. Slug squatting still works through XML-RPC (S1 is only closed for REST).
The CPT still uses the default post capabilities, so create_posts is still edit_posts. In my probe a Contributor called wp.newPost with post_type=prpl_recommendations, post_name=core-blogdescription, post_status=draft. The post was created with that exact slug, and Blog_Description::get_tasks_to_inject() then published 0 real tasks (the control run publishes 1).
Suggested fix: give the CPT its own capabilities mapped to edit_others_posts in register_post_type(). As defence in depth, make Suggested_Tasks_DB::add() and Tasks::get_tasks_to_inject() only treat an existing post as "the task" when it carries the matching provider term.

2. An Editor can still trash admin-only tasks over REST.
In my probe an Editor sent DELETE /wp/v2/prpl_recommendations/<id> for an update-core task and got 200; the post status became trash, which counts as completed, so the task is not re-injected. The provider's capability_required() is only enforced in the AJAX path.
Suggested fix: in the get_item, update_item and delete_item permission checks, also require the capability of the post's provider. On create, only allow the user provider term for users without manage_options.

3. The pp_branding_id fix only blocks anonymous visitors (S3).
is_user_logged_in() lets a Subscriber through. In my probe, with no license key and ?pp_branding_id=1, two init() calls as a Subscriber made 4 remote HTTP requests (anon: 0). There is still no back-off after a failure. On hosts that define PROGRESS_PLANNER_BRANDING_ID, the two blocking POSTs also still run on every front-end request until a key exists.
Suggested fix: honour the param only for manage_options, and only auto-onboard in Base::init() when is_admin() && current_user_can( 'manage_options' ) && ! wp_doing_ajax() && ! wp_doing_cron(). Add a failure transient (for example 1 hour) that is checked before retrying.

4. Minor. GET /wp/v2/prpl_recommendations_provider still returns 200 for anonymous users, so provider slugs can be listed. prpl_url meta still has no auth_callback, but the new edit_others_posts gate makes that unimportant.

Out-of-scope items I'd reconsider

  • B3 – the wp_parse_args() argument order in Suggested_Tasks_DB::get() makes the __trashed fallback a no-op. get_post( $task_id ) never finds completed tasks, so add() runs lock-option writes and extra queries on every request, and Activities\Suggested_Task::get_points() defaults to 1 for every completed to-do. Swapping the arguments fixes it.
  • B5GET_TASKS_CACHE_GROUP is persisted by Redis/Memcached and only flushed on delete, so completed tasks can be celebrated again. wp_cache_add_non_persistent_groups() in the constructor is a one-line fix, and it matters most on managed hosts.
  • B1 – if this is intended per #702, fine, but please confirm that upgraded sites do not get the task auto-celebrated with points and no user action.

I'd like gaps 1–3 fixed before merge, since 1 and 3 are issues the PR description says are closed.

🤖 Generated with Claude Code

ilicfilip and others added 2 commits September 22, 2026 13:00
The earlier S1 fix added permission checks to the REST controller, but the
CPT still used the default `post` capabilities, so write paths that check
caps but bypass the controller — XML-RPC `wp.newPost`, the block editor —
still let a Contributor/Author create (and slug-squat) recommendations
(1.10.0 audit S1, review gap 1).

Map the CPT's primitive capabilities to `edit_others_posts` with
`map_meta_cap => true`. Only the primitive caps are listed; core derives the
meta caps (edit_post/read_post/delete_post) per-post — listing those too
triggers a `_doing_it_wrong` notice in WP 6.1+.

Verified this does NOT break internal writes: `Suggested_Tasks_DB` injects and
updates with raw `wp_insert_post()`/`wp_update_post()`, which do not run
capability checks, so task injection, the AJAX action, CLI commands and
migrations are unaffected. Contributors/Authors are now blocked from creating
via any cap-checked path; Editors and admins still can.

Adds regression tests: the create capability is `edit_others_posts` and gated
per role, and internal injection still works as a Subscriber.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The earlier S3 fix honoured `?pp_branding_id` for any logged-in user, so a
Subscriber could still steer the branding ID; and `Base::init()`'s auto-onboard
still made the blocking remote calls for any logged-in visitor on a branded
host (review gap 3).

- `Branding::get_branding_id()`: honour `?pp_branding_id` only for
  `current_user_can( 'manage_options' )` (was `is_user_logged_in()`).
- `Base::init()`: require `manage_options` (and skip cron) before the
  auto-onboard remote call.

Deliberately NOT gated on `is_admin()`: on a branded host (pp-hosts) the
administrator is redirected to the front-end homepage after Extendify set-up,
and this block is the only path that fetches the license key — gating it to
wp-admin would leave that flow un-onboarded. The branding check runs before
the capability check, so `current_user_can()` is only reached on a branded,
unlicensed site (never at bootstrap on a default install).

Updates the S3 test: a Subscriber's `pp_branding_id` is now ignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🔍 WordPress Plugin Check Report

⚠️ Status: Passed with warnings

📊 Report

🎯 Total Issues ❌ Errors ⚠️ Warnings
10 0 10

⚠️ Warnings (10)

📁 classes/suggested-tasks/providers/class-content-review.php (4 warnings)
📍 Line 🔖 Check 💬 Message
232 WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information.
377 WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information.
381 WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information.
388 WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information.
📁 classes/suggested-tasks/data-collector/class-unpublished-content.php (1 warning)
📍 Line 🔖 Check 💬 Message
103 WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in Using exclusionary parameters, like post__not_in, in calls to get_posts() should be done with caution, see https://docs.wpvip.com/databases/optimize-queries/using-post__not_in/ for more information.
📁 classes/suggested-tasks/data-collector/class-terms-without-posts.php (1 warning)
📍 Line 🔖 Check 💬 Message
120 PluginCheck.Security.DirectDB.UnescapedDBParameter Unescaped parameter $query used in $wpdb->get_results()\n$query assigned unsafely at line 118.
📁 classes/suggested-tasks/data-collector/class-yoast-orphaned-content.php (1 warning)
📍 Line 🔖 Check 💬 Message
111 PluginCheck.Security.DirectDB.UnescapedDBParameter Unescaped parameter $query used in $wpdb->get_row()\n$query assigned unsafely at line 98.
📁 classes/suggested-tasks/data-collector/class-terms-without-description.php (1 warning)
📍 Line 🔖 Check 💬 Message
108 PluginCheck.Security.DirectDB.UnescapedDBParameter Unescaped parameter $query used in $wpdb->get_results()\n$query assigned unsafely at line 106.
📁 classes/activities/class-query.php (2 warnings)
📍 Line 🔖 Check 💬 Message
71 PluginCheck.Security.DirectDB.UnescapedDBParameter Unescaped parameter $table_name used in $wpdb->query()\n$table_name assigned unsafely at line 58.
163 PluginCheck.Security.DirectDB.UnescapedDBParameter Unescaped parameter $where_args used in $wpdb->get_results()\n$where_args assigned unsafely at line 153.

🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

ilicfilip and others added 2 commits September 22, 2026 13:40
…roller

The surface `edit_others_posts` gate let an editor read, update or delete ANY
task over REST, including admin-only ones such as `update-core` (which needs
`update_core`). An editor could trash it and have it count as completed
(review gap 2). The AJAX handler already applies the task's provider
capability; the REST controller did not.

Add `current_user_can_manage_requested_task()`, mirroring the AJAX gate, to the
`get_item`, `update_item` and `delete_item` permission checks: the current user
must also satisfy the task's provider `capability_required()`. Tasks whose
provider cannot be resolved fall back to the surface gate (matching the AJAX
handler). `create` is not per-task gated — there is no existing post to resolve
a provider from, and the CPT capabilities + surface gate cover creation.

Also give the `user` provider `CAPABILITY = 'edit_others_posts'`. It previously
inherited the base `manage_options`, so editors could not complete their own
to-dos even though the widget shows them (a pre-existing bug) — and it would
have made the new per-task check reject editors from their own `user` tasks.

Verified: an editor can read/update/delete their own `user` task and complete
one via AJAX, but is blocked (403) from `update-core`; an admin can manage
`update-core`. The editor-blocked-from-update-core case is red against the
pre-fix controller.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ment

On multisite, `update_core` is reserved for super admins, so a plain site
administrator is correctly denied the `update-core` task and
`test_admin_can_manage_admin_only_task` failed on the `(+ ms)` CI jobs.
Grant super admin in that test when running on multisite.

Also remove the duplicated auto-onboard comment block in `Base::init()`,
keeping the shorter version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@tacoverdo tacoverdo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review

Thanks for the new commits. Gap 1 (XML-RPC slug squatting) and gap 2 (Editors trashing admin-only tasks) are closed for the cases I probed. Gap 3 is partly closed: Subscribers can no longer set pp_branding_id or trigger the onboarding calls.

I pushed 9e100199 with two small fixes:

  • test_admin_can_manage_admin_only_task failed on the multisite CI jobs because only super admins have update_core on multisite. The test now grants super admin when multisite is on. The code under test is unchanged.
  • I removed the duplicated auto-onboard comment in Base::init() and kept the shorter version.

Still open

1. An Editor can still suppress an admin-only task (the create half of gap 2).
The per-task provider check covers read, update and delete, but not create. An Editor can still:

  • create a task that uses an admin-only task's slug (for example an update-core task) with status trash, or
  • rename one of their own user to-dos to that slug.

Suggested_Tasks_DB::add() then treats the real task as already existing, so it's never shown to the admin. The risk is much lower than before, since only Editors and up can do this, but it's the same kind of hole S1 describes.
Possible fixes: allow only the user provider term on create, and block slug changes, for users without manage_options. As a second layer, add() and get_tasks_to_inject() could also check that the provider term matches, not just the slug.

2. There's still no back-off after a failed onboarding request (gap 3).
On a branded host with no license key, when the onboarding request fails, every admin request still makes the two blocking remote calls. That includes admin-ajax, heartbeat and REST, because wp_doing_ajax() isn't excluded. I understand the SaaS is fast, but a short failure transient (for example 1 hour) would stop an outage on our side from slowing down every admin request on those sites. I agree with skipping the is_admin() gate because of the pp-hosts redirect to the front end.

3. Minor.

  • The collection route (GET /wp/v2/prpl_recommendations) isn't filtered per task. An Editor can list admin-only tasks while being blocked from reading one by ID.
  • GET /wp/v2/prpl_recommendations_provider still lists provider slugs to anonymous users.

@ilicfilip, do you think any of these are urgent enough to fix in this PR before the 1.10.1 release, or should they go in a follow-up? Item 1 is the one I'd most like to see closed, since the PR description says S1 is fixed. Items 2 and 3 are fine in a follow-up if you'd rather ship the PHP 7.4 fix sooner.

🤖 Generated with Claude Code

The per-task provider check covered read/update/delete but not create, so an
Editor could still suppress an admin-only recommendation: creating a task that
carries the admin task's slug (e.g. an `update-core` slug) in `trash` status
made `Suggested_Tasks_DB::add()` treat the real task as already existing, so it
was never injected for the admin. Same class as S1, limited to Editors+ (the
CPT capabilities already block Contributors/Authors), but the PR claims S1 is
fixed. Reported in the follow-up review.

Two layers:

- `create_item_permissions_check`: users without `manage_options` may only
  create their own personal (`user`) to-dos — no client-supplied slug, and
  only the `user` provider term (or none). Admins are unrestricted (the plugin
  creates provider tasks as an admin, and internal injection uses raw
  wp_insert_post which bypasses this).

- `Suggested_Tasks_DB::add()`: only treat an existing post as "the task" when
  its provider matches the one being injected, so a post that merely squats the
  slug under a different provider no longer suppresses the real recommendation.
  Filtered in PHP on the resolved provider (a name + tax_query on a trashed post
  does not reliably AND-combine in WP_Query).

Verified live: an Editor's slug-squat / non-user-provider create is now 403,
their own `user` to-do still 201, admins unrestricted; a wrong-provider squat
no longer suppresses injection while a matching-provider task still dedupes.
Tests are red against the pre-fix code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ilicfilip
ilicfilip marked this pull request as ready for review September 22, 2026 13:45
@ilicfilip
ilicfilip merged commit 30eb5da into develop Sep 22, 2026
22 checks passed
@ilicfilip
ilicfilip deleted the filip/release-1.10.1-audit-fixes branch September 22, 2026 13:46
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.

2 participants