Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9ae946e
Update dependency @playwright/test to ^1.62.0 (#1943)
renovate[bot] Jul 25, 2026
9228755
Update dependency globals to ^17.8.0 (#1947)
renovate[bot] Jul 26, 2026
d709f42
Update dependency npm-run-all2 to ^9.0.3 (#1949)
renovate[bot] Jul 29, 2026
0ca1295
Update Node.js to ^24.18.1 (#1951)
renovate[bot] Jul 29, 2026
67798e1
Update dependency @playwright/test to ^1.62.1 (#1952)
renovate[bot] Jul 31, 2026
d38c8c4
Update dependency globals to ^17.9.0 (#1953)
renovate[bot] Aug 2, 2026
949e494
Update Node.js to ^24.19.0 (#1954)
renovate[bot] Aug 3, 2026
3e9337b
chore(deps-dev): bump squizlabs/php_codesniffer from 3.10.2 to 3.13.6
dependabot[bot] Aug 8, 2026
2a65fa6
Merge pull request #1960 from xwp/dependabot/composer/squizlabs/php_c…
PatelUtkarsh Aug 9, 2026
d82305a
Update dependency globals to ^17.10.0 (#1964)
renovate[bot] Aug 12, 2026
7aa8e3c
Update dependency globals to ^17.11.0 (#1965)
renovate[bot] Aug 12, 2026
c97a0ec
Harden multisite authorization and stop logging integration credentia…
PatelUtkarsh Aug 26, 2026
1faa0ca
Update Node.js to ^24.20.0 (#1970)
renovate[bot] Aug 26, 2026
50237d0
Fix fatal in role_can_view() when cap check fires before init (#1963)
andreilupu Aug 31, 2026
8f1878e
fix(users): guard false user in retrieve_password (#1931)
faisalahammad Aug 31, 2026
da9a969
Fix false "SITE IS DISCONNECTED" error on empty query results (#1966)
anamwp Aug 31, 2026
4693012
docs: add 4.4.0 changelog entry
PatelUtkarsh Aug 31, 2026
51eeadc
docs: add 4.4.0 changelog entry to readme.txt
PatelUtkarsh Aug 31, 2026
4d68733
chore: bump version to 4.4.0
PatelUtkarsh Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/docker-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,5 @@ jobs:
run: docker compose --file docker-compose.build.yml build

- name: Publish images
if: contains( github.ref_name, 'master' )
if: github.ref == 'refs/heads/master'
run: docker buildx bake --file docker-compose.build.yml --push --set '*.platform=linux/amd64,linux/arm64'
13 changes: 13 additions & 0 deletions abilities/class-ability-create-exclusion-rule.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ public function get_name() {
return 'stream/create-exclusion-rule';
}

/**
* Exclusion rules suppress future audit records. On a network-activated
* install the rule is written to the network option and therefore applies
* across every site, so require a network capability in that case.
*
* @param array $input Input that will be passed to execute().
* @return bool
*/
public function permission_callback( $input = array() ) {
unset( $input );
return $this->can_write_settings();
}

/**
* {@inheritDoc}
*/
Expand Down
14 changes: 7 additions & 7 deletions abilities/class-ability-get-alerts.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,13 @@ public function execute( $input = null ) {
continue;
}

// Alert::$alert_meta defaults to array(); an empty PHP array() also
// JSON-encodes as a list ([]), which violates the declared object
// output schema. Normalize empty/non-array values to a real object
// so wp_json_encode() emits {} when there is no meta.
$alert_meta = is_array( $alert->alert_meta ) && ! empty( $alert->alert_meta )
? $alert->alert_meta
: new \stdClass();
// This ability only requires view_stream, which is a lower bar than
// the settings capability that gates alert configuration in the
// admin UI. Strip destination credentials (Slack webhook, IFTTT
// maker key) before they cross that boundary. Also normalizes
// empty/non-array meta to stdClass so wp_json_encode() emits {} and
// satisfies the declared object output schema.
$alert_meta = $this->redact_alert_meta( $alert->alert_meta );

$out[] = array(
'id' => (int) $alert->ID,
Expand Down
12 changes: 12 additions & 0 deletions abilities/class-ability-update-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ public function get_name() {
return 'stream/update-settings';
}

/**
* Writes land on the network option when Stream is network activated, so
* require a network capability in that case.
*
* @param array $input Input that will be passed to execute().
* @return bool
*/
public function permission_callback( $input = array() ) {
unset( $input );
return $this->can_write_settings();
}

/**
* {@inheritDoc}
*/
Expand Down
29 changes: 29 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
# Stream Changelog

## 4.4.0 - August 31, 2026

### Security

- Keep multisite record reads inside the current site. `Network::network_query_args()` kept any numeric `blog_id` from the request. Because the Stream tables are shared across the network, a site-level `view_stream` user could read the activity of another site with `?blog_id=N`. Stream now accepts a requested blog ID only from users with `manage_network_options`. All other users get the current blog. WP-CLI keeps its exemption ([#1958](https://github.com/xwp/stream/pull/1958)).
- Stop the use of `HTTP_REFERER` as proof of network-admin authority in `ajax_network_admin()`. The Referer comes from the caller. A site user could use it to remove the per-blog query restriction, and, through `blog_id_logged()`, record their actions against `blog_id` 0. This made the site attribution incorrect. Stream now uses the Referer only as a hint about the user interface. A true network capability is also necessary ([#1958](https://github.com/xwp/stream/pull/1958)).
- Make a network capability necessary for the `update-settings` and `create-exclusion-rule` abilities when the write goes to `update_site_option()` on a network-activated multisite. Both abilities used the default `manage_options` permission callback before ([#1958](https://github.com/xwp/stream/pull/1958)).
- Remove secret values from option-change records. The WooCommerce, EDD, Jetpack, Gravity Forms, and Settings connectors wrote integration credentials into `stream_meta` as cleartext. This included payment gateway API keys, webhook signing keys, the `mailserver_pass` mailbox password, and the Gravity Forms license and reCAPTCHA private keys. Any user who can read the activity log or export it could read these values. Stream now replaces a secret value with `[redacted]`. A credential that was never set continues to log as an empty value, so you can tell the difference between "not set" and "set but withheld" ([#1958](https://github.com/xwp/stream/pull/1958)).
- Withhold alert destination credentials from the `stream/get-alerts` ability. The ability needs only `view_stream`, but you configure alert destinations behind the Stream settings capability. The ability returned `alert_meta` without change, which moved Slack webhook URLs and IFTTT Maker keys across a privilege boundary. Stream now replaces each secret with a `{key}_configured` boolean, so a caller can still see if a destination is configured. Non-secret configuration, such as the channel, the user name, and the event name, does not change ([#1958](https://github.com/xwp/stream/pull/1958)).
- Log only the changed field for Jetpack options, and withhold Publicize connection payloads. Jetpack attached the complete `jetpack_options` value to a record about one field, so a single miss could expose all tokens ([#1958](https://github.com/xwp/stream/pull/1958)).
- Correct the GHCR publish condition in `docker-images.yml`. The `contains( github.ref_name, 'master' )` guard also matched an unprotected branch name such as `feature-master-publish`. The workflow now compares the ref exactly ([#1958](https://github.com/xwp/stream/pull/1958)).

### Bug Fixes

- Correct a false "SITE IS DISCONNECTED" error from `wp stream query`. The CLI treated every empty result as a disconnected site, because `db->query()` returns an empty array both for "no matching records" and for a failure. The CLI now reads `$wpdb->last_error`, which WordPress sets only after a true database error. A site with no records no longer reports the error. This affected new installations and multisite subsites with no activity ([#1829](https://github.com/xwp/stream/issues/1829), [#1966](https://github.com/xwp/stream/pull/1966)).
- Correct a fatal error in `role_can_view()` when the capability check runs before `init` and the `general_role_access` setting is not yet available ([#1963](https://github.com/xwp/stream/pull/1963)).
- Correct a PHP warning for a password reset request that gives an unknown user. The `retrieve_password` handler now stops when `get_user_by()` returns `false`. Before, it read properties from `false` ([#1838](https://github.com/xwp/stream/issues/1838), [#1931](https://github.com/xwp/stream/pull/1931)).

### Enhancements

- Add the `wp_stream_secret_alert_meta_keys` filter. Use it to declare the destination secrets of a custom alert type that you register with `wp_stream_alert_types`, so Stream removes them from ability output ([#1958](https://github.com/xwp/stream/pull/1958)).
- Add `Connector::is_secret_key()` and `Connector::redact_secret_values()`, with the `SECRET_KEY_PATTERNS`, `SECRET_KEY_SUFFIXES`, `PUBLIC_KEY_PATTERNS`, and `REDACTED_PLACEHOLDER` constants. Custom connectors can use these helpers for the same redaction. Matching uses substrings and suffixes, because connectors log option values that belong to plugins Stream does not control. `PUBLIC_KEY_PATTERNS` keeps the published half of a key pair, such as `public_key`, `publishable_key`, and `site_key`, unless the name also holds a secret marker ([#1958](https://github.com/xwp/stream/pull/1958)).
- Add `Network::can_view_network_records()` as the single authority check for access to network-scoped records ([#1958](https://github.com/xwp/stream/pull/1958)).

### Development

- Add PHPUnit coverage for the hardened paths: `Network`, the `Connector` redaction helpers, `CLI::connection()`, `Admin`, the Gravity Forms, Jetpack, Settings, and Users connectors, and the `get-alerts` and `update-settings` abilities. `Network` had almost no direct coverage before ([#1958](https://github.com/xwp/stream/pull/1958), [#1966](https://github.com/xwp/stream/pull/1966)).
- Update Node.js to `^24.20.0` ([#1951](https://github.com/xwp/stream/pull/1951), [#1954](https://github.com/xwp/stream/pull/1954), [#1970](https://github.com/xwp/stream/pull/1970)), `globals` to `^17.11.0` ([#1947](https://github.com/xwp/stream/pull/1947), [#1953](https://github.com/xwp/stream/pull/1953), [#1964](https://github.com/xwp/stream/pull/1964), [#1965](https://github.com/xwp/stream/pull/1965)), `@playwright/test` to `^1.62.1` ([#1943](https://github.com/xwp/stream/pull/1943), [#1952](https://github.com/xwp/stream/pull/1952)), `npm-run-all2` to `^9.0.3` ([#1949](https://github.com/xwp/stream/pull/1949)), and `squizlabs/php_codesniffer` to 3.13.6 ([#1960](https://github.com/xwp/stream/pull/1960)).

## 4.3.0 - July 18, 2026

### Enhancements
Expand Down
99 changes: 99 additions & 0 deletions classes/class-ability.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,105 @@ public function permission_callback( $input = array() ) {
return current_user_can( WP_STREAM_SETTINGS_CAPABILITY );
}

/**
* Permission check for abilities that persist Stream settings.
*
* On network-activated multisite, Settings::update_all_setting_values()
* writes the authoritative network option (wp_stream_network), so the write
* affects every site on the network. WP_STREAM_SETTINGS_CAPABILITY defaults
* to 'manage_options', which every single-site administrator holds -- on its
* own it is not sufficient authority for a network-wide change. Require a
* network capability whenever the write is going to be network-scoped.
*
* The network capability is required in addition to the settings
* capability, not instead of it: on multisite a super admin passes every
* capability check by definition, so returning the network check alone
* would silently ignore a deployment that narrowed
* WP_STREAM_SETTINGS_CAPABILITY.
*
* @return bool
*/
protected function can_write_settings() {
$can_write_settings = current_user_can( WP_STREAM_SETTINGS_CAPABILITY );

// Mirrors the branch in Settings::update_all_setting_values(): when the
// write lands on the network option it additionally requires network
// authority. This is an extra requirement, never a substitute -- the
// settings capability is a site-overridable constant, so a deployment
// that restricts or revokes it must keep being honoured for super
// admins too.
if ( is_multisite() && $this->plugin->is_network_activated() ) {
return $can_write_settings && current_user_can( 'manage_network_options' );
}

return $can_write_settings;
}

/**
* Alert meta keys holding reusable credentials rather than configuration.
*
* Alert destinations are configured by users with the Stream settings
* capability, but alerts are readable through get-alerts by anyone with
* `view_stream`. These values are bearer credentials -- a Slack incoming
* webhook URL is sufficient on its own to post into the target channel, and
* an IFTTT Maker key is sufficient to fire that account's applets -- so they
* must not cross that privilege boundary.
*
* @const array
*/
const SECRET_ALERT_META_KEYS = array(
'webhook',
'maker_key',
);

/**
* Replace credential values in an alert_meta array with a boolean marker.
*
* Callers still need to know whether a destination is configured, so each
* secret key is replaced by `{key}_configured` rather than dropped. The
* value itself never leaves the site.
*
* Returns stdClass for empty input so wp_json_encode() emits `{}` and
* satisfies the `alert_meta: object` output schema (an empty PHP array
* encodes as `[]`).
*
* @param mixed $alert_meta Raw alert meta, usually an array.
* @return array|\stdClass
*/
protected function redact_alert_meta( $alert_meta ) {
if ( ! is_array( $alert_meta ) || empty( $alert_meta ) ) {
return new \stdClass();
}

/**
* Filters the alert_meta keys treated as credentials and withheld from
* ability output.
*
* Third-party alert types registered via `wp_stream_alert_types` may
* store their own destination secrets under names Stream cannot know
* about; add them here so they are redacted too.
*
* @param array $keys Meta keys to redact.
* @param array $alert_meta The alert meta being redacted.
*/
$secret_keys = (array) apply_filters(
'wp_stream_secret_alert_meta_keys',
self::SECRET_ALERT_META_KEYS,
$alert_meta
);

foreach ( $secret_keys as $key ) {
if ( ! array_key_exists( $key, $alert_meta ) ) {
continue;
}

$alert_meta[ $key . '_configured' ] = ! empty( $alert_meta[ $key ] );
unset( $alert_meta[ $key ] );
}

return empty( $alert_meta ) ? new \stdClass() : $alert_meta;
}

/**
* Annotation flags for the ability (readonly, destructive, idempotent).
*
Expand Down
12 changes: 8 additions & 4 deletions classes/class-admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -1748,16 +1748,20 @@ public function register_list_table() {
/**
* Check if a particular role has access
*
* The user_has_cap/role_has_cap filters that call this are registered in the
* constructor, but the Settings object is not constructed until init priority 9.
* A capability check fired before then (e.g. by a security plugin evaluating
* firewall rules on plugins_loaded) must be denied rather than fatal on the
* null options chain.
*
* @param string $role User role.
*
* @return bool
*/
private function role_can_view( $role ) {
if ( in_array( $role, $this->plugin->settings->options['general_role_access'], true ) ) {
return true;
}
$allowed_roles = $this->plugin->settings->options['general_role_access'] ?? array();

return false;
return in_array( $role, (array) $allowed_roles, true );
}

/**
Expand Down
9 changes: 7 additions & 2 deletions classes/class-cli.php
Original file line number Diff line number Diff line change
Expand Up @@ -223,14 +223,19 @@ private function csv_format( $records ) {
* @return void
*/
private function connection() {
$query = wp_stream_get_instance()->db->query(
global $wpdb;

wp_stream_get_instance()->db->query(
array(
'records_per_page' => 1,
'fields' => 'created',
)
);

if ( ! $query ) {
// An empty result set is valid (e.g. a fresh site with no logged
// activity yet); only a genuine database error means the site is
// disconnected.
if ( ! empty( $wpdb->last_error ) ) {
\WP_CLI::error( esc_html__( 'SITE IS DISCONNECTED', 'stream' ) );
}
}
Expand Down
Loading
Loading