Skip to content

Harden multisite authorization and stop logging integration credentials - #1958

Open
PatelUtkarsh wants to merge 5 commits into
developfrom
fix/multisite-authorization-hardening
Open

Harden multisite authorization and stop logging integration credentials#1958
PatelUtkarsh wants to merge 5 commits into
developfrom
fix/multisite-authorization-hardening

Conversation

@PatelUtkarsh

@PatelUtkarsh PatelUtkarsh commented Aug 4, 2026

Copy link
Copy Markdown
Member

Security fixes from a review of v4.3.0.

  • Multisite record isolation — a site-level user could read other sites' records; a spoofed Referer granted network-admin context.
  • Network-wide settings — Abilities API writes hit the network option while only checking a site capability.
  • Credential exposureget-alerts returned alert destination secrets to lower-privileged viewers, and several connectors logged credentials into record metadata.
  • GHCR publishing — the branch guard matched unprotected branches.

Checklist

  • Project documentation has been updated to reflect the changes in this pull request, if applicable.
  • I have tested the changes in the local development environment (see contributing.md).
  • I have added phpunit tests.

Release Changelog

  • Fix: Prevent site-level users on multisite from reading other sites' activity records, and stop a spoofed HTTP Referer from granting network-admin context.
  • Fix: Require a network capability before Abilities API callers can change network-wide Stream settings or audit exclusion rules.
  • Fix: Redact Slack webhook URLs and IFTTT Maker keys from stream/get-alerts responses.
  • Fix: Stop storing credentials in activity record metadata, including the mail server password, Gravity Forms license and reCAPTCHA private keys, and WooCommerce, EDD and Jetpack secrets.
  • Fix: Restrict GitHub Container Registry publishing to the master branch.

Release Checklist

Not a release PR — targets develop.

…s writes

Addresses authorization findings from a security review of v4.3.0.

Multisite record isolation (Network):
Network::network_query_args() preserved any numeric blog_id coming from the
request, but a numeric type check is not an authorization check and the Stream
tables are shared across the network -- a site-level view_stream user could read
another site's activity with ?blog_id=N. Requested blog IDs are now honoured only
for users with manage_network_options; everyone else is pinned to the current
blog. The read abilities (get-record, get-records, purge-records) already carried
equivalent guards; this closes the legacy list-table and AJAX path.

ajax_network_admin() also derived network-admin authority purely from an
HTTP_REFERER prefix, which is caller-controlled. That let a site user lift the
per-blog query restriction and, via blog_id_logged(), record their actions
against blog_id 0 and corrupt site attribution. The Referer is now treated as a
UI hint only and must be accompanied by a real network capability. WP-CLI is
exempt since it has shell-level access and normally no logged-in user.

Network-wide settings writes (Ability):
update-settings and create-exclusion-rule inherited the default permission
callback (WP_STREAM_SETTINGS_CAPABILITY, i.e. manage_options) while
Settings::update_all_setting_values() routes to update_site_option() on
network-activated installs. A site administrator could therefore change
network-wide retention, role access, and audit exclusion rules. Both now use
Ability::can_write_settings(), which additionally requires
manage_network_options when the write will be network-scoped.

GHCR publishing (docker-images.yml):
The publish step was guarded by contains(github.ref_name, 'master'), which also
matches unprotected branch names such as feature-master-publish. Replaced with
an exact ref comparison.

This guards against an accidental publish only. The condition is read from the
pushed ref's own copy of the workflow, so someone with write access could still
edit it on their own branch; closing that requires a deployment branch policy
configured in repository settings, which is outside this change.
…tion

Adds regression coverage for the boundaries hardened in the previous commit.
WP_Stream\Network previously had almost no direct coverage (4.76% of methods),
which is how the can_write_settings() either/or bug below went unnoticed
through two full green test runs.

tests/phpunit/test-class-network.php (new):
- A site administrator and a subscriber both have a caller-supplied blog_id
  discarded in favour of the current blog.
- A super admin still gets cross-site filtering, so Network Admin is unaffected.
- site_id keeps its existing default.
- can_view_network_records() is false for a site administrator and true for a
  super admin, so a spoofed Referer alone cannot establish network context.
- blog_id_logged() keeps site attribution for a site user.

test-class-ability-update-settings.php:
- A site administrator cannot write network-wide settings.
- A super admin is denied when the Stream settings capability is revoked. This
  pins the AND semantics of can_write_settings(): the network capability is an
  additional requirement, not a substitute. Note WP_User::has_cap() returns
  early for super admins before the user_has_cap filter runs, so the test uses
  map_meta_cap/do_not_allow -- the one restriction that early return honours.

Each test was verified to fail against the unfixed code and pass against the
fix. Both suites: 401 tests, 0 failures; skipped/incomplete counts unchanged
from the develop baseline.
…APIs

Alert destination credentials (get-alerts):
stream/get-alerts requires only view_stream, but alert destinations are
configured behind the Stream settings capability, so returning alert_meta
verbatim dropped credentials across a privilege boundary. A Slack incoming
webhook URL and an IFTTT Maker key are both bearer credentials -- possession
alone is enough to post into the channel or fire the account's applets.

Ability::redact_alert_meta() now replaces those values with a
`{key}_configured` boolean before output, so callers can still tell whether a
destination is set up without receiving the secret. Non-secret configuration
(channel, username, event_name) is untouched. The helper also absorbs the
existing empty-meta-to-stdClass normalization the output schema requires.

Payment gateway credentials (WooCommerce connector):
callback_updated_option() serialized entire third-party gateway settings
arrays into record metadata. Gateways routinely co-locate operational
settings with live API secrets and webhook signing keys, so an enabled
credential-bearing gateway persisted those secrets into stream_meta where any
Stream viewer or record-detail API consumer could read them.

Adds Connector::is_secret_key() / redact_secret_values(), applied to both
old and new values before serialization. Matching is substring/suffix based
rather than an allowlist because connectors log option arrays belonging to
plugins we do not control, and an allowlist cannot anticipate their field
names. Over-redacting costs a little audit detail; under-redacting persists a
live credential. The helper lives on the Connector base class because the
remaining unredacted-secret findings need the same logic.

Note the pattern list was corrected while writing the tests: 'password' did
not match mailserver_pass and no pattern matched rg_gforms_key, both of which
are real targets. A bare 'key' suffix was also rejected as it matched
harmless words such as monkey.

Each redaction test was verified to fail against the unredacted code. Both
suites: 408 tests, 0 failures.
Applies the Connector redaction helper added in the previous commit to the
remaining settings that were persisted verbatim.

Settings connector (mailserver_pass):
callback_updated_option() passed values through sanitize_value(), which only
flattens complex types to strings and has no notion of sensitivity, so a
reusable mailbox password was stored as both old_value and value. Redaction is
now applied per field at the two call sites where the setting name is known --
sanitize_value() itself receives no key and cannot make the decision.

Gravity Forms connector (rg_gforms_key, rg_gforms_captcha_private_key):
check() logged old/new values for every tracked option including the reCAPTCHA
private key, and check_rg_gforms_key() explicitly logged both halves of a
license-key change. Both now redact before logging. In the license case the
update/delete status is derived before redaction, so the message still reports
which happened -- covered by a test.

The change also refines the pattern list from the previous commit, driven by
test failures rather than assumption:

- rg_gforms_captcha_public_key was being over-redacted by the '_key' suffix
  rule. Public halves of key pairs are meant to be published and redacting
  them removes audit detail for no security benefit, so PUBLIC_KEY_PATTERNS
  now exempts public_key / publishable_key / site_key ahead of the secret
  match. A test asserts the public key survives while the private one does
  not.
- The earlier assertion that 'publishable_key' should be treated as secret was
  wrong and has been corrected.

Each redaction test was verified to fail without the fix while the non-secret
control tests continued to pass, confirming they discriminate rather than
redacting everything. Both suites: 414 tests, 0 failures.
1. Secret markers now beat the public-name exemption. PUBLIC_KEY_PATTERNS was
   checked first and returned early, so a name such as secret_site_key or
   webhook_public_key escaped redaction despite carrying an explicit secret
   marker -- inverting the over-redact-rather-than-under-redact preference the
   previous commit claimed. The exemption exists only to stop the broad "_key"
   suffix rule from catching published key halves, so it is now consulted
   after the secret substrings and before the suffix rule.

2. REDACTED_PLACEHOLDER is '[redacted]' rather than ''. The empty string made
   a withheld credential indistinguishable from a cleared field, contradicting
   the stated rationale of the empty-value test. Unset credentials still log as
   '' so "never set" and "set but withheld" remain distinguishable; the test
   now asserts both directions.

3. Extends redaction to EDD and Jetpack, which logged raw old/new option
   values. EDD is the notable one: it logs arbitrary settings fields including
   payment gateway API keys. BuddyPress was examined and left alone -- it only
   logs component activation booleans and page IDs, so there is no credential
   to redact and a call would be dead code.

4. Adds the wp_stream_secret_alert_meta_keys filter so third-party alert types
   registered via wp_stream_alert_types can have their own destination secrets
   redacted from get-alerts output.

Tests assert against Connector::REDACTED_PLACEHOLDER rather than a literal so
they track the constant. The precedence fix and the filter both have tests
verified to fail against the previous behaviour. Both suites: 416 tests, 0
failures.
@PatelUtkarsh
PatelUtkarsh marked this pull request as ready for review August 4, 2026 09:27
@PatelUtkarsh
PatelUtkarsh force-pushed the fix/multisite-authorization-hardening branch from 0b0152c to a81c3f5 Compare August 4, 2026 09:38
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