Skip to content

fix(security): stop register_meta leaking secret post meta over REST#583

Merged
galbus merged 3 commits into
mainfrom
claude/nifty-dirac-510e9f
Jul 19, 2026
Merged

fix(security): stop register_meta leaking secret post meta over REST#583
galbus merged 3 commits into
mainfrom
claude/nifty-dirac-510e9f

Conversation

@galbus

@galbus galbus commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Problem

Sync::register_meta() registered every BeyondWords post-meta key — via Utils::get_post_meta_keys('all') (current and deprecated) — with 'show_in_rest' => true.

WordPress returns show_in_rest meta in the view context with no capability check (WP_REST_Meta_Fields::get_value() performs no auth; auth_callback only gates writes). So an unauthenticated GET /wp/v2/posts/<id> (or the posts collection) returned, in the meta object of every compatible published post:

  • speechkit_access_key — a legacy per-post API credential from the v2.x SpeechKit era (on sites upgraded from SpeechKit)
  • beyondwords_error_message / speechkit_error_message — internal BeyondWords API error strings
  • beyondwords_preview_token — the audio preview token
  • _speechkit_link / _speechkit_text — legacy player URLs and article text
  • speechkit_status and other internal rows

This is a public information-disclosure bug present on every install.

Fix

Two layers, both in src/post/class-sync.php:

1. Register show_in_rest per key, not blanket-true.
Only the keys the block editor actually reads/writes over REST get show_in_rest => true: the 16 current keys, plus six deprecated keys the editor still reads for back-compat on SpeechKit-upgraded posts (REST_LEGACY_META_KEYS — error/pending notices, play/preview, the Generate-audio toggle and the legacy player link all depend on them). Every other deprecated key — including speechkit_access_key, _speechkit_text, cached API responses — is now show_in_rest => false and never reaches REST at all. All keys stay registered, so write-sanitisation and Custom-Fields-panel hiding (is_protected_meta()) are unchanged.

2. Strip the still-needed sensitive keys from public responses.
The keys the editor genuinely needs in the authenticated edit context but which must not leak publicly (REST_PRIVATE_META_KEYS: beyondwords_error_message, beyondwords_preview_token, speechkit_error_message, _speechkit_link) are removed from every non-edit response by a new rest_prepare_{post_type} filter (hide_private_meta_from_rest()). The edit context is itself permission-gated by the posts controller, so editors keep full access; anonymous visitors get nothing.

Why not the simpler "current → true, deprecated → false" split?

Two reasons it wouldn't work:

  • beyondwords_error_message and beyondwords_preview_token are current keys but still sensitive — a pure current/deprecated split leaves them exposed.
  • The block editor genuinely reads six deprecated keys in the edit context (e.g. error-notice/check.js, open-sidebar/index.js, play-audio, pending-notice), so blanket-hiding all deprecated keys would regress editing of legacy posts.

The strip layer resolves both: sensitive keys stay available to authenticated editors, hidden from the public.

Verification

  • npm run phpcs (WordPress-VIP-Go): PASS, no phpcs:ignore added.
  • PHPUnit SyncTest: PASSregister_meta, init, and a new register_meta_does_not_expose_private_meta_to_the_public (3 tests, 54 assertions) run in the real WP test harness.
  • Independently confirmed against a live WP REST server: all 39 meta keys land in the correct bucket, all secret/error/token keys are hidden from anonymous view, non-sensitive keys stay publicly readable, and an editor still sees the private keys via context=edit.
  • No JS changed → no Jest/Cypress impact (full Cypress suite intentionally not run).

Reviewer notes

  • Non-sensitive identifiers (beyondwords_content_id, beyondwords_project_id, and the four non-secret legacy IDs/flags) remain publicly readable — unchanged in sensitivity from before and useful for headless integrations. Only the flagged secret/internal keys are removed or stripped.
  • speechkit_access_key is now completely absent from REST (both view and edit) — editors never needed it there; the admin Inspect panel reads it server-side via has_meta().

register_meta() registered every BeyondWords post-meta key with
show_in_rest => true. WordPress returns show_in_rest meta in the public
`view` context with no capability check, so anonymous
GET /wp/v2/posts/:id leaked the legacy per-post `speechkit_access_key`,
BeyondWords API error messages, the audio preview token and legacy
player URLs/text on every compatible post.

Register only the keys the block editor reads/writes over REST (the
current keys plus the six deprecated keys it still reads for
SpeechKit-upgrade back-compat) with show_in_rest => true; register every
other deprecated key — including speechkit_access_key — with
show_in_rest => false so it never reaches the REST API. All keys stay
registered, so write sanitisation and Custom-Fields hiding are
unchanged.

The sensitive keys the editor genuinely needs in the authenticated
`edit` context (error messages, preview token, legacy player link) are
additionally stripped from non-`edit` responses by a new
rest_prepare_{post_type} filter, so they stay available to editors but
not to anonymous visitors.

Verified with `npm run phpcs` (WordPress-VIP-Go, no phpcs:ignore) and
the SyncTest PHPUnit suite (register_meta, init, and a new
register_meta_does_not_expose_private_meta_to_the_public test).

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

Copy link
Copy Markdown

✅ WordPress Plugin Check Report

✅ Status: Passed

📊 Report

All checks passed! No errors or warnings found.


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

@galbus
galbus marked this pull request as ready for review July 19, 2026 07:15
@galbus
galbus requested a review from gouravkhunger July 19, 2026 07:15
@gouravkhunger

Copy link
Copy Markdown
Contributor

@galbus there's a merge conflict on this one.

…0e9f

Resolves a conflict in src/post/class-sync.php: main's
fix(sync) (2be1ca0) added DELETE_AUDIO_CRON_HOOK immediately after
GENERATE_AUDIO_CRON_HOOK, the same spot where this branch added
REST_LEGACY_META_KEYS and REST_PRIVATE_META_KEYS. Kept both sides,
with the new cron-hook constant next to its GENERATE_AUDIO_CRON_HOOK
sibling and the REST constants after it. init() merged cleanly and
registers both the delete-audio cron action and the rest_api_init
meta-visibility action.

Also reset the current user before deleting it in
register_meta_does_not_expose_private_meta_to_the_public, so the test
no longer leaves the global pointing at a deleted user row.

Verified after merge: phpcs clean on class-sync.php; SyncTest passes
(80 tests, 185 assertions) across repeated runs.

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

galbus commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

@gouravkhunger Conflict resolved — merged latest main in (b1befa6): main's DELETE_AUDIO_CRON_HOOK landed in the same spot in class-sync.php as this PR's new REST constants, so I kept both sides (cron hook next to its GENERATE_AUDIO_CRON_HOOK sibling, REST constants after); init() merged cleanly and registers both actions. Re-verified after the merge: phpcs clean and SyncTest green (80 tests, 185 assertions). GitHub now reports the PR as mergeable.

🤖 Addressed by Claude Code

galbus added a commit that referenced this pull request Jul 19, 2026
…a911

Conflict in src/editor/classic/class-metabox.php between main's stored-XSS
fix for the classic player (#583) and this branch's poll-before-embed.

Resolved by keeping both, split per integration path:

- REST API (has a Content ID): keeps this branch's loading state. The player
  is embedded by classic-metabox.js only once GET /content reports
  `processed`, so no inline `onload` is emitted at all. The Content ID and
  preview token ride in esc_attr()'d data-* attributes and are read back with
  getAttribute(), so neither is interpolated into JavaScript source — this
  removes the XSS vector rather than escaping around it.
- Client-side (Magic Embed, no Content ID): nothing to poll, so it still
  embeds inline and keeps main's JSON-encoded (HEX-flagged) + esc_attr()'d
  onload verbatim.

test-metabox.php: player_embed_neutralises_content_id_xss() now asserts the
crafted Content ID stays a data attribute with no onload to break out of, and
a new player_embed_json_encodes_the_client_side_config() keeps main's
JSON-encoding mechanism under test on the path that still uses it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gouravkhunger
gouravkhunger self-requested a review July 19, 2026 07:51
…0e9f

Resolves conflicts with main's fix (54a3364) that validates the Content
ID charset, which landed in the same two places as this branch's REST
exposure fix:

- src/post/class-sync.php: main added a per-key sanitize_callback
  override for beyondwords_content_id inside register_meta(), built off
  the old per-post_type $options array that this branch replaced with an
  inline per-key array (to compute show_in_rest per key). Combined both:
  the options array is still built per key with the show_in_rest split,
  and sanitize_callback now resolves to Meta::sanitize_content_id() for
  beyondwords_content_id and sanitize_text_field otherwise.

- tests/phpunit/post/test-sync.php: both sides appended a new test right
  after register_meta(). Kept both
  (register_meta_does_not_expose_private_meta_to_the_public and
  register_meta_sanitizes_content_id_on_write).

Verified after merge: phpcs clean on class-sync.php; SyncTest passes
(81 tests, 187 assertions), including both new register_meta tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@galbus
galbus merged commit 0d031ff into main Jul 19, 2026
5 checks passed
@galbus
galbus deleted the claude/nifty-dirac-510e9f branch July 19, 2026 08:09
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