Skip to content

Harden interactive task handlers - #775

Draft
ilicfilip wants to merge 3 commits into
developfrom
filip/harden-interactive-task-handlers
Draft

Harden interactive task handlers#775
ilicfilip wants to merge 3 commits into
developfrom
filip/harden-interactive-task-handlers

Conversation

@ilicfilip

Copy link
Copy Markdown
Collaborator

Hardening pass across the interactive task handlers, following an external code review of the plugin. Some of the reviewed items were re-scoped after verifying them against the code; the detailed analysis lives in our internal tracker.

Changes

Consistent capability checks on AIOSEO interactive tasks
Five AIOSEO handlers were verifying the plugin was active and the nonce was valid, but not the user's capability. They now call the shared verify_aioseo_ajax_security() helper, which covers all three. Every other settings-changing interactive handler already required manage_options, so this brings the AIOSEO ones in line with the existing convention rather than introducing a new policy.

Capability guard on the email-sending task's script enqueue
Email_Sending::enqueue_scripts() overrode its parent without carrying over the parent's capability guard. It now checks $this->capability_required() — the provider's own declared capability rather than a hardcoded literal, so it stays correct if a subclass overrides CAPABILITY.

Term task handlers bound to the term the task suggested
Remove_Terms_Without_Posts and Update_Term_Description acted on whatever term_id/taxonomy the request supplied. They now require that a task from the same provider actually targets that term, reject non-public taxonomies, and (for deletion) re-check the post count at submit time.

The constraints already existed when generating the tasks — maybe_remove_irrelevant_tasks() skips non-public taxonomies and drops tasks once a term exceeds MIN_POSTS — they just weren't applied when acting on the request. The count re-check also closes a genuine race: a term can gain posts between the task being created and the user clicking through.

Binding rather than raising the required capability keeps the feature working for its intended audience. These tasks are meant for Editors, and the JS already submits the task's own target_term_id/target_taxonomy, so the legitimate flow is unchanged.

esc_url() for hrefs
Switched esc_attr() to esc_url() on three links. esc_url is the correct escaper for an href, since esc_attr does not validate the URL scheme.

Media previews built via DOM instead of HTML strings
The site icon and Yoast organization logo pickers assembled their preview <img> by string concatenation and assigned it to innerHTML. They now use createElement with property assignment, so attachment metadata is never parsed as markup. Same reasoning as the existing textContent usage in updateTaskTitle().

Testing

tests/phpunit/test-class-term-task-binding.php adds 6 tests for the term task binding, covering both the rejected and the permitted paths — the latter so the binding can't silently break the feature.

These were validated by temporarily removing the new guards and confirming the four negative tests fail while both positive tests still pass, i.e. they exercise the behaviour rather than passing vacuously.

The media preview change was verified with jsdom: attachment metadata containing quote characters round-trips as an attribute value and never becomes markup.

Verified: PHPCS clean · PHPStan clean · JS/CSS lint clean · PHPUnit 404 tests / 1216 assertions passing.

Note for local runs: PHPStan needs --memory-limit=2G; the 512M default OOMs on this codebase, unrelated to these changes.

🤖 Generated with Claude Code

ilicfilip and others added 3 commits August 21, 2026 16:20
Five AIOSEO interactive task handlers verified that the plugin was
active and that the nonce was valid, but did not check the user's
capability:

  - Archive_Author
  - Archive_Date
  - Crawl_Settings_Feed_Comments
  - Crawl_Settings_Feed_Authors
  - Media_Pages

They now call the existing verify_aioseo_ajax_security() helper, which
runs the active check, the capability check and the nonce check
together. Every other settings-changing interactive handler in the
plugin already required manage_options, so this brings the AIOSEO
handlers in line with the existing convention.

Email_Sending::enqueue_scripts() also overrode its parent without
carrying over the parent's capability guard. It now checks
$this->capability_required(), which resolves to the capability the
provider actually declares (manage_options here, inherited from Tasks)
rather than a hardcoded literal, so it stays correct if a subclass
overrides CAPABILITY.

Also switch esc_attr() to esc_url() on three hrefs. esc_url is the
correct escaper for a link target, since esc_attr does not validate the
URL scheme.

Verified: PHPCS clean, parallel-lint clean, PHPStan clean, PHPUnit
398 tests / 1203 assertions passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Remove_Terms_Without_Posts and Update_Term_Description acted on
whatever term_id/taxonomy the request supplied, without checking it
against the term the task actually suggested.

The relevant constraints already existed in the task generation path,
they just were not applied when acting on a request:
maybe_remove_irrelevant_tasks() skips non-public taxonomies and drops
tasks once a term has more than MIN_POSTS posts.

Both handlers now, after the existing capability and nonce checks:

  - reject non-public taxonomies,
  - require a task from the same provider that targets this exact
    term_id/taxonomy pair,
  - and, for deletion, re-check the post count at submit time, since
    the term may have gained posts after the task was created.

Binding rather than raising the required capability keeps the feature
working for its intended audience: these tasks are meant for Editors,
and the JS already submits the task's own target_term_id and
target_taxonomy, so the legitimate flow is unchanged.

Adds regression tests covering both the rejected and the permitted
paths. Verified that the negative tests fail when the new guards are
removed, and that the positive tests still pass, so they exercise the
behaviour rather than passing vacuously.

Verified: PHPCS clean, PHPStan clean, PHPUnit 404 tests / 1216
assertions passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The site icon and Yoast organization logo pickers assembled their
preview <img> by concatenating attachment metadata into an HTML string
and assigning it to innerHTML.

Both now build the node with createElement and property assignment, so
no HTML string is parsed and attachment metadata is always handled as
an attribute value rather than as markup. This follows the same
reasoning as the existing textContent usage in updateTaskTitle().

Verified with jsdom that metadata containing quote characters
round-trips as a value and does not become markup.

Note: the Yoast logo picker's alt fallback still reads "Site icon
preview", which looks like a copy-paste artifact. Left as-is to avoid
mixing a user-visible string change into this commit.

Verified: JS lint clean, PHPCS clean, PHPUnit 404 tests / 1216
assertions passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

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

@github-actions

Copy link
Copy Markdown
Contributor

✅ Code Coverage Report

Metric Value
Total Coverage 32.19% 📉
Base Coverage 31.62%
Difference 📈 0.57%

⚠️ Coverage below recommended 40% threshold

🎉 Great job maintaining/improving code coverage!

📊 File-level Coverage Changes (2 files)

📈 Coverage Improved

Class Before After Change
Progress_Planner\Suggested_Tasks\Providers\Remove_Terms_Without_Posts 4.93% 21.43% +16.50%
Progress_Planner\Suggested_Tasks\Providers\Update_Term_Description 5.30% 21.12% +15.82%
ℹ️ 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant