Skip to content

Ship 1.6.0: Abilities permission fix, private data storage, HTML API content filters - #51

Merged
wpgaurav merged 4 commits into
mainfrom
fix/1.6.0-audit
Sep 6, 2026
Merged

Ship 1.6.0: Abilities permission fix, private data storage, HTML API content filters#51
wpgaurav merged 4 commits into
mainfrom
fix/1.6.0-audit

Conversation

@wpgaurav

@wpgaurav wpgaurav commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Closes every finding from the v1.5.0 audit, and is already running on gauravtiwari.org.

Security

Abilities API privilege escalation. One shared permission callback downgraded to edit_post whenever the input carried a post_id, and it guarded all nine abilities. No input schema set additionalProperties, and core only rejects unknown properties when that flag is false. Any user who could edit a single post could toggle modules off, create an external redirect from any path, create tasks, trigger loopback scans, and spend AI credits through /wp-abilities/v1/abilities/functionalities/*/run.

Now: a permission callback per ability, additionalProperties => false on every input schema, and check-content-integrity additionally verifies the post type. Verified against a real contributor account on production — all eight site-wide abilities return ability_invalid_permissions, and that user can still check only their own post.

Web-readable data files. wp-content/functionalities/redirects.json was served publicly at a fixed, guessable URL. Data now lives in a directory named with 20 random characters, created and migrated by Storage\Data_Directory, with .htaccess, web.config, and index.php written at both levels and an async Site Health probe that asks the server whether the folder is actually reachable.

Snippets mangled for visitors. Output ran kses against the viewer's capability, so anonymous readers got && where the author wrote &&, and comparison operators were eaten as tags — while the logged-in administrator saw the snippet work. Filtering now happens once at save time against the author's capability, with an unfiltered flag recorded per snippet.

Also: username throttling, an IP allowlist and an unlock action in Login Security; Prism bundled locally instead of pulled from a CDN; and a service worker that no longer caches wp-admin, the login page, REST responses, cross-origin requests, or anything marked no-store, with a capped runtime cache and per-URL precaching so one stale entry cannot abort install.

Correctness and performance

  • A JSON exception preset served from a URL was fetched on every page load with a 10s timeout. It is now cached, and the cache clears whenever a post or page is edited, settings change, or the theme switches, keeping the last good list if a fetch fails.
  • Link Management, Block Cleanup, and Schema moved from DOMDocument to WP_HTML_Tag_Processor. Attributes are edited in place, so Vue, Alpine, and mustache syntax survive untouched. The skip guard added in 1.4.3 and 1.4.4 is deleted, and content that used to be skipped entirely is now processed.
  • Redirect hits and 404 aggregates are buffered and flushed in batches instead of rewriting the whole JSON file under an exclusive lock on every request.
  • Redirects run at parse_request, before the main query, guarded against WordPress's own entry points.
  • The bulk nofollow tool pages by ID cursor and can now finish past 100 posts; it previously returned the same first batch forever.
  • The Content Integrity list column reads a result cached at save time instead of rendering and DOM-parsing every row on every visit.
  • The SVG icon library leaves the autoload set. This needed a real upgrade routine, because update_option() returns early on an unchanged value and never touches the autoload column.
  • Content Integrity and Assumption Detection gained the filters their documentation promised; the docs no longer advertise 19 hooks that were never fired.

Release hygiene

  • .distignore patterns anchored to the root. An unanchored vendor was also matching assets/vendor/, which would have shipped a zip with Prism missing. build.sh now uses the same exclude list as the release workflow and asserts required runtime files.
  • Translation template generated from source: 1,136 strings, previously a single placeholder.
  • CI sparse-checks out wordpress-develop so the HTML API tests run rather than skip, diffs the POT file, and asserts the new runtime files ship.
  • CLAUDE.md rewritten to the current architecture.

Testing

52 tests, 203 assertions. PHPCS clean, lint clean across PHP 7.4 to 8.5, distribution zip clean at 85 files.

Verified end to end on WordPress 7.0.2 locally and on gauravtiwari.org (WordPress 7.1, PHP 8.5.6, LiteSpeed, Cloudflare, 212 users): the permission model against a real contributor, the storage migration, snippet output for a logged-out visitor, all three content filters on real posts, hit buffering, preset invalidation, and every admin screen.

Two things live deployment caught

  1. The migration was gated on module state. Both file-based modules are disabled on gauravtiwari.org, yet a redirects.json from an earlier version was still on disk and still public. Upgrader::has_legacy_data() now migrates whenever legacy files exist, regardless of whether the modules are on. Without this the exposed file would have stayed exposed.
  2. .htaccess is inert on that host. Six forms were tested inside the plugin's own data directory — Require all denied, bare Order/Deny, FilesMatch, RedirectMatch 404, and a mod_rewrite forbid — and every one still served the file, because AllowOverride is off for wp-content. The unguessable directory name is therefore the protection that holds, which is why the Site Health check reports recommended rather than critical when a folder is servable.

Known follow-up

To make data files unservable even when the URL is known, each payload would need to sit inside a .php file beginning with <?php exit; ?>. That means a filename change, a read/write guard in Atomic_JSON_Store, and another migration, so it belongs in 1.6.1 with its own test pass rather than being rushed onto a live site.

Upgrade notes

Existing option names, hooks, admin URLs, shortcodes, and stored JSON stay compatible. Data written by earlier versions migrates on the first admin page load. Two intentional behaviour changes: disabling Heartbeat now applies to the front end only unless the new admin option is also enabled, so autosave and post locking keep working; and the revision limit is configurable rather than hard-coded to 10.

🤖 Generated with Claude Code

wpgaurav and others added 4 commits September 6, 2026 09:28
…filters

Closes every finding from the v1.5.0 audit.

Security
- Abilities API: a permission callback per ability instead of one shared
  callback that downgraded to edit_post whenever the input carried a post_id.
  Every input schema now sets additionalProperties => false, and
  check-content-integrity also verifies the post type. A contributor with one
  draft could previously toggle modules, create redirects and tasks, trigger
  scans, and spend AI credits.
- Redirects, the 404 log, and task projects move to a private directory with a
  random name under wp-content/functionalities/, with Apache, IIS, and listing
  guards written alongside and automatic migration of existing files. A new
  async Site Health check confirms over HTTP whether the folder is served.
- Header and footer snippets are filtered once at save time against the
  author's unfiltered_html, not at output time against the viewer's. Logged-out
  visitors were receiving mangled code: && became &amp;&amp; and comparison
  operators were eaten as tags, while the administrator saw it work.
- Login Security gains username throttling, an IP allowlist, an unlock action
  on the lockout log, and a warning when every recent lockout shares one
  address.
- Prism.js is bundled locally instead of loaded from a third-party CDN.
- The service worker no longer caches wp-admin, the login page, REST responses,
  cross-origin requests, or anything marked no-store; the runtime cache is
  capped and precaching is per URL so one stale entry cannot abort install.

Correctness and performance
- A JSON exception preset served from a URL is cached rather than fetched on
  every page load, and the cache clears whenever a post or page is edited.
- Link Management, Block Cleanup, and Schema use WP_HTML_Tag_Processor.
  Attributes are edited in place, so Vue, Alpine, and mustache syntax survive
  and the skip guard added in 1.4.3 and 1.4.4 is gone.
- Redirect hits and 404 aggregates are buffered and flushed in batches instead
  of rewriting the whole JSON file under a lock per request.
- Redirects run at parse_request, guarded against WordPress's own entry points.
- The bulk nofollow tool pages by ID cursor and can now finish past 100 posts.
- The Content Integrity list column reads a result cached at save time.
- The SVG icon library leaves the autoload set, which needed a real upgrade
  routine because update_option() returns early on an unchanged value.
- Content Integrity and Assumption Detection gained the filters their docs
  promised; the docs no longer list 19 hooks that were never fired.

Release hygiene
- .distignore patterns anchored to the root; an unanchored vendor rule also
  matched assets/vendor and would have dropped Prism from the zip. build.sh
  now shares the release workflow's exclude list and asserts required files.
- Translation template generated from source: 1,136 strings, was 1 placeholder.
- CI sparse-checks out wordpress-develop so the HTML API tests run, diffs the
  POT file, and asserts the new runtime files ship.

Verified on WordPress 7.0.2: 52 tests, 203 assertions, PHPCS clean, lint clean
on PHP 8.5, and an end-to-end pass on a live install covering the permission
model, the storage migration, snippet output, all three content filters, hit
buffering, and the admin screens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deploying 1.6.0 to a production site showed the upgrade routine skipping the
storage migration there. Both the Redirect Manager and Task Manager modules are
switched off on that site, but a redirects.json written by an earlier version was
still on disk and still served at its old public URL. Gating the migration on
module state meant the exposed file would have stayed exposed.

Upgrader::has_legacy_data() now checks for the files themselves, so a site
carrying data from a module it no longer uses is migrated too.

Also records what live deployment established: on that host no .htaccess form
works at all, because AllowOverride is off for wp-content. Six variants were
tested inside the plugin's own data directory and every one still served the
file. The unguessable directory name is therefore the protection that holds, and
the Site Health check wording already reflects that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two release-hygiene bugs that only surfaced once CI built from a clean
checkout rather than from the working tree.

.gitignore carried an unanchored `vendor/`, the same bug already fixed in
.distignore. It matched assets/vendor/ as well as Composer's root vendor/,
so the bundled Prism copy added for the local-syntax-highlighting change
was never committed. build.sh checks its allowlist against the checkout and
failed on the missing file, meaning a release built from GitHub would have
shipped the Misc module pointing at a 404. Both patterns are now anchored
to the repo root and the four Prism files are tracked.

The POT freshness check compared the committed template against a command
CI spelled out inline, and the two had drifted on Report-Msgid-Bugs-To:
the committed file carried the GitHub issues URL, the CI command produced
the wordpress.org default. Rather than duplicating the header in a second
place, the whole command now lives in bin/make-pot.sh, which regenerates
the template by default and diffs it under --check. CI calls that script,
so a developer and the workflow run byte-identical commands and the two
cannot drift again.

bin/ is excluded from the zip and the script is syntax-checked in CI
alongside build.sh.

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

Tested up to is now 7.1, which is what 1.6.0 was actually verified against
on a live install rather than an aspirational bump.

Three claims had gone stale and are now accurate:

- Both readmes promised DOMDocument-based HTML processing. 1.6.0 replaced it
  with the WordPress HTML API in all three content filters, which is the
  reason framework markup survives, so the bullet now says so.
- The redirect storage answer predated the private data folder added in
  1.6.0 and described only the file-based part.
- The redirect migration answer told people to add CSV rows by hand. Bulk
  CSV import with column aliases, dry runs, and loop/chain validation has
  shipped since 1.4.8.

Adds a "New in 1.6.0" section and three FAQ entries covering supported
WordPress versions, what the plugin sends outbound (nothing, with the two
opt-in exceptions named), and where files are written. Drops a Markdown
`###` heading that WordPress.org renders literally.

Links now point at functionalities.dev throughout: the docs home, getting
started, module and dashboard guides, hooks, API reference, FAQ, and
downloads, plus a per-module link on each entry that has a page. Fonts,
Components, PWA, Meta and Editor Links have no docs page yet and are
deliberately left unlinked. All 19 URLs verified 200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wpgaurav
wpgaurav merged commit 6b6ada7 into main Sep 6, 2026
9 checks passed
@wpgaurav
wpgaurav deleted the fix/1.6.0-audit branch September 6, 2026 04:43
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