fix(log): honor whitespace and array-shaped IP values in exclude rule - #1932
fix(log): honor whitespace and array-shaped IP values in exclude rule#1932faisalahammad wants to merge 2 commits into
Conversation
- Normalize the stored rule value to an array before splitting, so a stored comma-joined string (admin form) and a direct array (API, tests) both work. - Trim each entry and drop empty tokens so a user-typed list like "127.0.0.1, 8.8.8.8" or a stored value with trailing whitespace matches the real client IP. - Short-circuit cleanly when the record has no IP, so an empty-token rule cannot false-match against an empty record IP. Fixes xwp#1824
shadyvb
left a comment
There was a problem hiding this comment.
Looks good! With one nitpick about the unnecessary anonymous function usage with array_filter().
Use native array_filter without a callback to drop empty IP tokens. Perf/behavior identical. Addresses PR feedback. Refs xwp#1932
PatelUtkarsh
left a comment
There was a problem hiding this comment.
@faisalahammad Thanks you for your contribution!
The screenshot in #1824 shows a rule value that has no whitespace and no comma, thus the mechanism in this PR cannot apply to that report. I also reproduced a different cause on macOS that gives the same symptom. See the blocker comment on the test file.
Note: PR comments are created with help of LLM and manually reviewed before posting it.
| public function test_ip_only_exclude_rule_excludes_record() { | ||
| // End-to-end coverage for the bug in #1824. Shape mirrors the | ||
| // parallel-array rule format produced by both the wp-admin Exclude | ||
| // list and the stream/create-exclusion-rule ability. | ||
| $this->plugin->settings->options['exclude_rules'] = array( | ||
| 'exclude_row' => array( 0 => '' ), | ||
| 'author_or_role' => array( 0 => '' ), | ||
| 'connector' => array( 0 => '' ), | ||
| 'context' => array( 0 => '' ), | ||
| 'action' => array( 0 => '' ), | ||
| 'ip_address' => array( 0 => '127.0.0.1' ), | ||
| ); | ||
|
|
||
| $user = $this->factory->user->create_and_get(); | ||
| $user->add_role( 'administrator' ); | ||
|
|
||
| $this->assertTrue( | ||
| $this->plugin->log->is_record_excluded( | ||
| 'users', | ||
| 'profile', | ||
| 'updated', | ||
| $user, | ||
| '127.0.0.1' | ||
| ), | ||
| 'IP-only rule excludes a record from the matching IP' | ||
| ); | ||
|
|
||
| $this->assertFalse( | ||
| $this->plugin->log->is_record_excluded( | ||
| 'users', | ||
| 'profile', | ||
| 'updated', | ||
| $user, | ||
| '8.8.8.8' | ||
| ), | ||
| 'IP-only rule does not exclude a record from a different IP' | ||
| ); | ||
|
|
||
| // Whitespace in the stored IP value must still match. | ||
| $this->plugin->settings->options['exclude_rules']['ip_address'][0] = '127.0.0.1, 8.8.8.8'; | ||
| $this->assertTrue( | ||
| $this->plugin->log->is_record_excluded( | ||
| 'users', | ||
| 'profile', | ||
| 'updated', | ||
| $user, | ||
| '8.8.8.8' | ||
| ), | ||
| 'Comma-joined IP list with whitespace matches the second entry' | ||
| ); | ||
| } |
There was a problem hiding this comment.
Blocker: this test does not show the defect in #1824, and I do not think that whitespace is the cause of that report.
Evidence 1: the screenshot in the issue
The screenshot shows one rule row. Author or Role, Context, and Action all show the placeholder text. The IP Address cell has exactly one select2 tag: × 127.0.0.1.
One tag. No comma. Thus the stored value is a bare 127.0.0.1, and it has no whitespace to remove and no second address. The correction in this PR operates only on a value that has a comma or a space. Thus it cannot change the result for this reporter.
Evidence 2: the test passes without the correction
I changed classes/class-log.php back to the origin/develop version. Then I ran this test with no changes. The test failed only at line 313, which is the whitespace assertion at the end. The first two assertions, which use the same conditions as the report, both passed on develop without the correction in this PR.
Evidence 3: I reproduced a different cause on macOS
The reporter uses macOS. On macOS, the name localhost gives the IPv6 address first:
$ dscacheutil -q host -a name localhost
ipv6_address: ::1 <-- first
ip_address: 127.0.0.1
Thus, when the web server accepts IPv6 and the user opens http://localhost, the value of REMOTE_ADDR is ::1. I made a test with a small PHP server that shows REMOTE_ADDR:
Server on localhost (accepts IPv6):
http://localhost:8899/ => REMOTE_ADDR=::1
Server on 0.0.0.0 (IPv4 only):
http://localhost:8899/ => REMOTE_ADDR=127.0.0.1
http://127.0.0.1:8899/ => REMOTE_ADDR=127.0.0.1
The value depends on the address that the server accepts, not on the text that the user writes. Thus a user can open localhost, see a correct rule of 127.0.0.1, and get ::1 in the record.
A probe test with a rule of 127.0.0.1 gives these results:
REMOTE_ADDR=127.0.0.1 excluded=true
REMOTE_ADDR=::1 excluded=false
REMOTE_ADDR=::ffff:127.0.0.1 excluded=false
REMOTE_ADDR=172.18.0.1 excluded=false
This agrees with the report: the rule does nothing, and no error message occurs.
Note that this is not a defect in the comparison. ::1 and 127.0.0.1 are different addresses, thus a strict comparison is correct. The defect is that Stream gives the user no feedback. The user writes an address that looks correct, Stream records a different address, but the UI does not show this difference.
How to reproduce this without the reporter
- Start WordPress on a server that accepts IPv6. The default Apache on macOS does this.
- Open the site with
http://localhost, not withhttp://127.0.0.1. - Add an exclude rule. Set the IP address to
127.0.0.1. Set all the other fields to Any. Save. - Open
wp-admin/profile.php. Select Update Profile. - Open Stream, then Records. The record is present. The IP column shows
::1. - Change the rule to
::1. Do step 4 again. The record is absent.
Step 6 is the proof. If the rule operates with ::1 but not with 127.0.0.1, this is the cause.
A second, different defect that I found
This one is real, but it is probably not the defect of this reporter. classes/class-plugin.php:221 resolves the client IP address with FILTER_VALIDATE_IP. Some hosts copy the X-Forwarded-For value into REMOTE_ADDR with no change, thus REMOTE_ADDR holds more than one address:
'203.0.113.9' => '203.0.113.9'
'203.0.113.9, 70.41.3.18' => false
'203.0.113.9:51234' => false
A chain gives false, not the first address, because of Filter_Input::filter() at classes/class-filter-input.php:114-117. Then Log::log():136 writes an empty IP column for every record, and Log::is_record_excluded():182 puts false into the record. Thus no IP rule can ever agree. This gives the same symptom, but only on a site that has a proxy.
What to do
- Remove
Fixes #1824from this PR. Keep this PR for the whitespace defect, which is real but is a different defect. - Do the six steps above. If step 6 gives the result that I describe, make a new issue for the true cause.
The whitespace correction is good. My objection is only to the statement that it closes #1824.
|
|
||
| $ip_addresses = array_filter( array_map( 'trim', $ip_addresses ) ); | ||
|
|
||
| if ( ! empty( $record['ip_address'] ) && in_array( $record['ip_address'], $ip_addresses, true ) ) { |
There was a problem hiding this comment.
Warning: the ! empty( $record['ip_address'] ) condition has no effect on the result.
When the code gets to this line, two things are already true. The condition ! isset( $record[ $exclude_key ] ) at line 240 passed. And line 254 removed all the empty items from $ip_addresses. Thus an empty record IP address, or a record IP address of '0', cannot agree with an item in the list. The result is the same with the condition and without it.
But an empty record IP address does occur in operation. When REMOTE_ADDR holds a proxy chain, classes/class-plugin.php:221 gives false, and that value goes into the record. See my blocker comment on the test file.
Thus, if you added this condition for that reason, the condition is in the wrong place. It hides the problem here, but the correction belongs at the source in classes/class-plugin.php:221.
If the condition is only defensive, tell that in a comment. At this time, test_ip_address_rule_matches_with_whitespace (lines 213-221 of the test file) makes an assertion about this condition. Thus a subsequent reader will think that the condition is necessary.
| $ip_addresses = is_array( $exclude_value ) | ||
| ? $exclude_value | ||
| : explode( ',', (string) $exclude_value ); |
There was a problem hiding this comment.
Warning: I could not find operational code that gives an array to this parameter. Thus this branch is not necessary.
I examined each path that supplies $exclude_value:
abilities/class-ability-create-exclusion-rule.php:138makes a(string)cast. Line 147 does a check withFILTER_VALIDATE_IP. Thus the ability always stores a scalar value.Settings::render_field()(classes/class-settings.php:1100) reads a scalar value.src/js/admin-exclude.js:378-383joins the selected values into one string with commas.Log::is_record_excluded():197gives the stored value to the function with no change.
Only the test in this PR gives an array to this function. Thus the test is the only proof that the branch is necessary, but the test is also a part of this PR.
Do one of these two things. Show operational code that gives an array. Or remove this branch and the two array assertions in the test (lines 240-262). The (string) cast is sufficient for the PHP 8 TypeError that the description mentions.
| // Stored value shape varies: the admin form posts one | ||
| // comma-joined string per row, while a direct API or test | ||
| // caller can hand us an array of IPs. Normalize first, then | ||
| // trim and drop empties so a stored "1.1.1.1, 8.8.8.8" or | ||
| // ["1.1.1.1", " 8.8.8.8"] both match a real client IP. |
There was a problem hiding this comment.
Nit: the comment has five lines, but the code has four lines.
The comment also tells about a "direct API or test caller". I could not find such a caller. See my other comment about the array branch.
Two lines are sufficient: the admin form joins the values with commas, thus the code must remove the spaces before the strict comparison.
| ); | ||
| } | ||
|
|
||
| public function test_ip_address_rule_matches_with_whitespace() { |
There was a problem hiding this comment.
Nit: this test method makes assertions about four different behaviors, but the name tells about only one behavior. The four behaviors are: removal of spaces, an empty record IP, removal of empty items, and an array value.
If the test fails, the name of the test will point to the incorrect behavior. Divide this test into more than one test. Or give it a more general name, for example test_ip_address_rule_normalization.
Summary
Exclude rules with an IP address filter (and all other fields set to Any) silently no-op'd when the stored rule value had whitespace around commas or was shaped as an array.
Log::record_matches_rules()only split the value withexplode( ',', ... )and used a strictin_arrayagainst it, so"127.0.0.1, 8.8.8.8"never matched a real8.8.8.8and the record was still written.Fixes #1824
Changes
classes/class-log.phpBefore:
After:
Why: the admin Exclude form joins multi-IP values with a comma (
src/js/admin-exclude.js), so the stored value is a single string. Without trimming,"127.0.0.1, 8.8.8.8"becomes['127.0.0.1', ' 8.8.8.8']afterexplodeand the strictin_arraynever matches. A direct API or test caller can also hand us an array, andexplode( ',', array )raises a TypeError on PHP 8+.Repro before the fix
Testing
Test 1: IP-only exclude rule excludes a record
127.0.0.1. Save.wp-admin/profile.phpand click Update Profile.Result: no new "Profile updated" record is present (with the fix).
Test 2: multi-IP rule with whitespace
127.0.0.1, 8.8.8.8(with or without space after the comma).8.8.8.8.Result: the request is excluded.
Automated tests run:
composer test-one -- --filter='Test_Log::test_ip_only_exclude_rule_excludes_record'(single + multisite, green)composer test-one -- --filter='Test_Log::test_ip_address_rule_matches_with_whitespace'(green)composer lint-phpandcomposer lint-tests(green)