Skip to content

Trac Watcher: Escape admin output, tighten the props capability, and add tests - #750

Closed
obenland wants to merge 13 commits into
WordPress:trunkfrom
obenland:trac-watcher-escape-output
Closed

Trac Watcher: Escape admin output, tighten the props capability, and add tests#750
obenland wants to merge 13 commits into
WordPress:trunkfrom
obenland:trac-watcher-escape-output

Conversation

@obenland

@obenland obenland commented Jul 30, 2026

Copy link
Copy Markdown
Member

An escaping and tidy-up pass over the Trac Watcher admin screens, plus the test coverage the plugin was missing.

Escape output in the list table and reports

The props and message columns, and most of the reports, interpolated stored prop names, display names and profile URLs into HTML unescaped — in several cases alongside a sibling esc_attr() on the same value in the same sprintf().

The message column needed a little care rather than a blanket wrap: format_trac_markup() has already run the message through esc_html() by the time the props loop runs, so the prop is now escaped before it is matched against the message and before it is inserted. For ordinary alphanumeric prop names esc_html() is a no-op, so matching and highlighting are unchanged; names containing markup never matched before either.

Prop names also now go through sanitize_text_field() on save. That only helps new rows, so the output escaping is doing the real work.

Require edit_others_posts to alter props records

The save and reparse handlers, and the edit affordances in the props column, checked publish_posts. On the make sites o2 Posting Access grants that to every logged-in account that isn't a member of the blog, so it wasn't gating much. Editing props rewrites attribution on someone else's commit, which feeds the release credits and profile badges.

Viewing the screen still only needs edit_posts.

Before this deploys: anyone doing props wrangling without Editor on the make site loses write access unless the capability is granted to them first. The previous check was broad enough that the working set was never expressed as a role, so it isn't derivable from the repo.

Fix several PHP warnings

  • create_tables() used $wpdb without importing it, so activation fatalled outright.
  • import_revisions() passed shell_exec()'s return straight to simplexml_load_string(), which is null whenever the command can't run.
  • The props cloud read $r->id and never set a slug, both of which wp_generate_tag_cloud() expects.
  • The typos report rendered $c->display_name without selecting the column — the WHERE clause already referenced it.

Add a PHPUnit suite

Follows the layout the o2 Posting Access suite uses and runs off the same make wp-env config. Covers both branches that interpolate a stored prop name, that an ordinary prop is still matched and highlighted after the escaping change, that names are stripped on all three write paths, and that the edit affordances track the capability the handler enforces.

The handler's rejection paths call die() rather than wp_die(), so a refused request would take the runner down with it; what the capability gates is asserted from the rendering side instead.

environments/make/.wp-env.test.json picks up the plugin and defines WP_CORE_LATEST_RELEASE, which the reports page reads unguarded when choosing its default version filter.

Testing

  • 13 tests, 23 assertions, green.
  • Mutation-checked rather than just run: reverting each change individually fails the suite — props column escaping (3 failures), the message-column escaping order (2), sanitize_text_field() (3), and the capability (1). Three tests carry explicit guards against passing vacuously.
  • Existing o2 Posting Access suite still passes (32 tests) against the shared config.
  • Manually exercised on wp-env across all seven report views and all four props screens: no PHP notices, an unresolved prop containing markup renders inert, and an Author-role account can view the screen but sees no edit affordances and gets -1 from a direct POST.
  • phpcs on the touched files drops from 160 to 118 pre-existing violations; nothing new introduced.

🤖 Generated with Claude Code

obenland and others added 5 commits July 30, 2026 12:28
…d reports.

Prop names, display names and profile URLs were interpolated into the props
and message columns, and into most of the reports, without escaping. Escape
them on output, and run the prop name through sanitize_text_field() on save.

The message column already runs through esc_html() in format_trac_markup(),
so the prop is now escaped before it's matched against the message and before
it's inserted, keeping the highlighting behaviour unchanged.

Also switches the touched sprintf() calls to numbered placeholders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The save and reparse handlers, and the edit affordances in the props column,
checked publish_posts. On the make sites o2 Posting Access hands that to every
logged-in account that isn't a member of the blog, so it wasn't gating much.

Editing props alters attribution on someone else's commit, which feeds the
release credits and profile badges, so edit_others_posts is the better fit.
Viewing the screen still only needs edit_posts.

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

- create_tables() used $wpdb without importing it, so activation fatalled.
- import_revisions() passed shell_exec()'s return straight to
  simplexml_load_string(), which is null when the command can't run at all.
- The props cloud read $r->id and never set a slug, both of which
  wp_generate_tag_cloud() expects.
- The typos report rendered $c->display_name without selecting the column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mounts the plugin alongside the other make ones, and defines
WP_CORE_LATEST_RELEASE, which its reports page reads unguarded when picking
the default version filter.

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

Follows the layout the o2 Posting Access suite uses, and runs off the same make
wp-env config, so it picks up a matrix entry in the unit tests workflow.

Covers both branches that interpolate a stored prop name into the props and
message columns, that an ordinary prop is still matched and highlighted after
the name is escaped ahead of the comparison, that the name is stripped on the
way in on all three write paths, and that the edit affordances track the
capability the handler enforces.

The handler's rejection paths call die() rather than wp_die(), so a refused
request would take the runner down with it. What the capability gates is
asserted from the rendering side instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 30, 2026 18:10

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props obenland.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

obenland and others added 8 commits July 30, 2026 13:23
- The list table read properties straight off get_user_by( 'login', $item->author ).
  The plugin already assumes a revision author may have no account: the committers
  report LEFT JOINs wp_users and falls back to r.author with ifnull(). The revision
  and author columns now show the SVN username alone in that case.
- The versions-contributed report read $u->user_nicename for any truthy user_id,
  which no longer resolves once an account is deleted. Falls back to the stored
  prop name, as the unresolved branch beside it already does.
- reports-page.php read WP_CORE_LATEST_RELEASE unguarded, while list-table.php and
  ui.php both defined()-guard it. An undefined constant is fatal in PHP 8, so the
  page died outright rather than warning. $version stays null, which add_query_arg(),
  the version select and the WHERE clause all handle already: the reports come back
  unfiltered, the same state the blank option in the dropdown produces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the phpcs:ignore annotations with escaping at the point of output: the
versions report branches rather than pre-building an anchor, and the two URL
columns of the raw report wrap make_clickable() in wp_kses_post() instead of
asserting the input was already safe.

The versions-contributed case sat a level deeper than its siblings, so every
line touched here tripped the indentation sniff. Reindenting it brought the
rest of the block into the branch linter's view, hence the remaining fixes:

- $versions['2.5'] = $version['2.4'] = 1 assigned to a string offset on the
  version filter, which is a fatal on the path that gives a 2.4 or 2.5 prop
  both versions. Both assignments now target $versions.
- Spacing, an empty else, and a phpcs:disable for the interpolated table names,
  which are identifiers from get_svns() and cannot be placeholders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adopts the pattern the plugin and theme directory environments use: a
database-tables.sql of the tables that exist outside WordPress on production,
mapped in as env-bin and imported after start.

Trac Watcher resolves a prop by GitHub username as a last resort, against a
table another part of dotorg owns. The test bootstrap was creating it, so the
suite passed while the development site errored on the same lookup. Both
databases now get it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds declare( strict_types=1 ) and parameter and return types throughout, and
annotates the fixture writes that go straight to the plugin's tables, so the
suite is clean under the WordPress standard rather than only the project one.

Drops the stub table creation, which the make environment now handles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The range branch matched against $args['revisions'], but display_reports_page()
has no $args -- the value it read off the request is in $revisions. Every report
loaded with a 1:HEAD or 60000-60100 range emitted an undefined variable warning
and then silently dropped the filter, so the report came back unfiltered. The
comma-separated branch below it was unaffected and still worked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The raw report takes its gravatar size from the query string. absint() was
applied to the value but the default sat behind the null coalesce, so anything
that isn't a number -- ?size=abc, ?size=0, ?size=-0 -- reached get_avatar() as
0 and rendered a zero-pixel image. Coalescing to 0 and picking the default up
with ?: instead sends unusable input to the same size an absent parameter gets,
and still honours a deliberate ?size=8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
setUp() sets the props screen and registers a per_page option on it, both of
which are global and outlive the test. Nothing in this suite depends on that
leaking, but a suite that runs alongside it would inherit the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The double space after the opening parenthesis predates this branch, but the
branch linter only sniffs lines a change touches, so rewriting the preg_match()
argument on that line brought it into scope and failed the build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@bazza bazza closed this in 698eabd Jul 30, 2026
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