1.10.1 release audit fixes: R1 (PHP 7.4 fatal), S1/S2/S3/S7.1, B2/B4/B8/B10 - #784
Conversation
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>
|
Test merged PR on Playground |
✅ Code Coverage Report
🎉 Great job maintaining/improving code coverage! 📊 File-level Coverage Changes (12 files)📈 Coverage Improved
ℹ️ About this report
|
tacoverdo
left a comment
There was a problem hiding this comment.
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 inSuggested_Tasks_DB::get()makes the__trashedfallback a no-op.get_post( $task_id )never finds completed tasks, soadd()runs lock-option writes and extra queries on every request, andActivities\Suggested_Task::get_points()defaults to 1 for every completed to-do. Swapping the arguments fixes it. - B5 –
GET_TASKS_CACHE_GROUPis 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
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>
🔍 WordPress Plugin Check Report
📊 Report
|
| 📍 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
…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
left a comment
There was a problem hiding this comment.
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_taskfailed on the multisite CI jobs because only super admins haveupdate_coreon 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-coretask) with statustrash, or - rename one of their own
userto-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_providerstill 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>
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
Branding::get_admin_submenu_position()declared amixedreturn type, which fatals on PHP 7.4 (the plugin's declared minimum) on every wp-admin page. Present in the shippedv1.10.0tag, absent fromv1.9.1— a regression. Also pinsphpVersion: 70400inphpstan.neon.distso PHP 8-only syntax is caught statically (the lint job only checks syntax; the PHPUnit matrix starts at 8.2).Security
prpl_recommendationsCPT inherited defaultpostcapabilities withshow_in_rest => trueand 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 requiresedit_others_posts— the plugin's own UI gate — for every read/write; the provider taxonomy's term-write caps are mapped to match.pp_branding_idpreview 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.provider[]/exclude_provider[]REST param raised a PHP 8 TypeError (500); now normalised for both string and array input.Functional bugs
init()token generation was dead code — the AJAX handler builds and sends its own. Removed.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.have_page=yes, id=0). Now rejects that combination.'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-csat default severity clean.Note
R1 is already present in the released
v1.10.0tag, so this needs a patch release + re-tag to actually protect 7.4 users.🤖 Generated with Claude Code