fix: validate Content ID charset to block API path/query injection#575
Merged
Conversation
An unvalidated Content ID saved via the editor metabox or the REST/block-editor meta write was interpolated raw into BeyondWords API URL paths (sprintf '%s/projects/%d/content/%s'). The org X-Api-Key header is attached to any URL under the API base, so a crafted value such as 'x/../../projects/999/content/abc?force=1' let an author-level user steer authenticated, org-scoped GET/PUT/DELETE requests to arbitrary endpoints. Fix in three layers: - Meta::sanitize_content_id(): sanitize_text_field + a strict ^[a-zA-Z0-9-]*$ gate (the charset the inspect REST route uses), blanking anything else. - Route ContentId::save() and the beyondwords_content_id register_meta sanitize_callback through it (both write paths). - Defensively rawurlencode() the Content ID at the three Client URL builders (get_content, update_audio, delete_audio). Verified: phpcs clean; full PHPUnit suite green (662 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
✅ WordPress Plugin Check Report
📊 ReportAll checks passed! No errors or warnings found. 🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check |
galbus
commented
Jul 19, 2026
galbus
marked this pull request as ready for review
July 19, 2026 07:02
Resolves a conflict in Client::delete_audio_by_ids(): main shortened the DELETE timeout while this branch added rawurlencode() to the Content ID. Kept both — the URL is encoded and the request passes DELETE_TIMEOUT. Also adapts main's new player_embed_neutralises_content_id_xss test. Content IDs are now charset-validated on save, so update_post_meta() blanked its hostile payload and no player rendered. It now writes the payload straight to the DB, which preserves the classic-editor escaping coverage and additionally fixes the exact-encoding assertion that was already failing on main (sanitize_text_field had been mangling the payload before it reached the renderer). Verified: phpcs clean; full PHPUnit suite green (676 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gouravkhunger
approved these changes
Jul 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes an authenticated API path/query injection via an unvalidated Content ID. A WordPress user with
edit_post(author, or contributor on their own draft) could store a craftedbeyondwords_content_idand steer authenticated, organization-API-key-scoped requests to arbitrary BeyondWords API endpoints.The vulnerability
beyondwords_content_idwas persisted with onlysanitize_text_field(), which permits/,.,?,#and&. That value was later read back verbatim and interpolated unencoded into API URL paths:in
Client::get_content(),update_audio()anddelete_audio(). BecauseClient::filter_http_request_args()attaches the orgX-Api-Keyheader to any outbound URL that starts with the API base — and path-suffix manipulation preserves that prefix — a value such as:resolves (after cURL dot-segment normalization) to an attacker-chosen endpoint under the API host, with the org key attached, letting an author-level user read/update/delete content in other projects of the organization. Both write paths were affected: the classic-editor metabox (
ContentId::save()) and the block-editor/REST meta write (Sync::register_meta(),sanitize_callback => sanitize_text_field). The plugin's own inspect REST route already documented the intended charset as[a-zA-Z0-9\-]+.What changed
Defense at three layers (per the advisory):
Meta::sanitize_content_id():sanitize_text_field()+ a strict^[a-zA-Z0-9-]*$gate (the same charset the inspect REST route enforces), blanking anything else.ContentId::save()(classic editor) and thebeyondwords_content_idsanitize_callbackinSync::register_meta()(block editor / REST). Only that one meta key gets the strict callback; others keepsanitize_text_field.rawurlencode( (string) $content_id )at the threeClientURL builders. Transparent for legitimate IDs (numeric / UUID), so no behavior change for valid callers. (batch_deletealready sends IDs in a JSON body, not a URL path — unchanged.)Follows WordPress-VIP standards — input is unslashed + sanitized, and no
phpcs:ignorewas added.Reviewer notes
register_metasanitize_callbackapplies globally, so the strict gate takes effect on everybeyondwords_content_idwrite (includingupdate_post_meta), not just REST. API-issued IDs (UUIDs/numeric) pass through unchanged.'beyondwords_content_id','test_value') were changed to charset-valid values.Testing
phpcs(fullWordPress-VIP-Goscope): clean (exit 0).OK — 662 tests, 2079 assertions, 0 failures. Ran the whole suite (not just changed files) because theregister_metacallback registers globally.sanitize_content_idunit provider;ContentId::saveinjection cases; aSynctest provingupdate_post_metablanks a crafted ID end-to-end; aClienttest asserting the URL is%2F/%3F-encoded.🤖 Generated with Claude Code