From 3d3408f349f06830873fd2486e7ec048cbb6f072 Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Tue, 15 Sep 2026 16:56:05 +0200 Subject: [PATCH] Preserve IP lists through repeated WordPress sanitization --- src/Settings/Blocks/Fields/IpList.php | 8 ++---- tests/Browser/phpRegression.spec.ts | 16 +++++++++++ tests/Regression/ip-sanitization.php | 26 ++++++++++++++++++ tests/Support/isolated-options.php | 39 +++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 tests/Browser/phpRegression.spec.ts create mode 100644 tests/Regression/ip-sanitization.php create mode 100644 tests/Support/isolated-options.php diff --git a/src/Settings/Blocks/Fields/IpList.php b/src/Settings/Blocks/Fields/IpList.php index cc671d8..ceb34e0 100644 --- a/src/Settings/Blocks/Fields/IpList.php +++ b/src/Settings/Blocks/Fields/IpList.php @@ -15,12 +15,8 @@ class IpList extends Field public function getValueSanitizer(): callable { return function ($value) { - $ips = []; - - if (! is_array($value)) { - $ips = explode("\n", $value); - } - + $ips = is_array($value) ? $value : (is_string($value) ? explode("\n", $value) : []); + $ips = array_filter($ips, 'is_string'); $ips = array_map('trim', $ips); $ips = array_filter($ips, function ($ip) { return filter_var($ip, FILTER_VALIDATE_IP) !== false; diff --git a/tests/Browser/phpRegression.spec.ts b/tests/Browser/phpRegression.spec.ts new file mode 100644 index 0000000..4202937 --- /dev/null +++ b/tests/Browser/phpRegression.spec.ts @@ -0,0 +1,16 @@ +import { test, expect } from '@playwright/test'; +import { execFileSync } from 'node:child_process'; +import { readdirSync } from 'node:fs'; +import { basename, resolve } from 'node:path'; + +// Each script uses its own temporary options table, so these checks can run +// alongside browser tests without changing their WordPress settings. +for (const file of readdirSync(resolve('tests/Regression')).filter(name => name.endsWith('.php'))) { + test(`WordPress regression: ${file}`, () => { + const output = execFileSync(resolve('node_modules/.bin/wp-env'), [ + 'run', 'cli', 'wp', 'eval-file', + `wp-content/plugins/${basename(process.cwd())}/tests/Regression/${file}`, + ], { encoding: 'utf8', timeout: 45000 }); + expect(output).toContain('Regression checks passed'); + }); +} diff --git a/tests/Regression/ip-sanitization.php b/tests/Regression/ip-sanitization.php new file mode 100644 index 0000000..9689e15 --- /dev/null +++ b/tests/Regression/ip-sanitization.php @@ -0,0 +1,26 @@ +getValueSanitizer(); + register_setting('simpleanalytics-ignore-rules', $key, [ + 'type' => $field->getValueType(), + 'sanitize_callback' => $sanitize, + ]); + + delete_option($key); + update_option($key, "203.0.113.10\n2001:db8::1"); + $expected = ['203.0.113.10', '2001:db8::1']; + sa_assert(get_option($key) === $expected, 'First save must retain addresses after double sanitization.'); + sa_assert($sanitize($expected) === $expected, 'Sanitizing an array must preserve valid addresses.'); + sa_assert($sanitize($sanitize($expected)) === $expected, 'Sanitization must be idempotent.'); + sa_assert($sanitize([' 203.0.113.10 ', '203.0.113.10', 'invalid', [], null, new stdClass()]) === ['203.0.113.10'], 'Reject malformed items and remove duplicates.'); + foreach ([null, false, 123, new stdClass(), ''] as $invalid) { + sa_assert($sanitize($invalid) === [], 'Malformed or empty input must produce an empty list.'); + } + update_option($key, "203.0.113.20\r\ninvalid\r\n203.0.113.20"); + sa_assert(get_option($key) === ['203.0.113.20'], 'Existing options must still accept textarea updates.'); +}); diff --git a/tests/Support/isolated-options.php b/tests/Support/isolated-options.php new file mode 100644 index 0000000..f4f724e --- /dev/null +++ b/tests/Support/isolated-options.php @@ -0,0 +1,39 @@ +options; + $originalCache = $wp_object_cache; + $table = 'sa_regression_options'; + $guard = static function ($sql) use ($table) { + if (! preg_match('/^\s*(SELECT|SHOW|DESCRIBE|EXPLAIN)\b/i', $sql) && strpos($sql, $table) === false) { + throw new RuntimeException('Refusing a write outside the temporary test table.'); + } + return $sql; + }; + add_filter('query', $guard, 9999); + + try { + sa_assert($wpdb->query("CREATE TEMPORARY TABLE $table LIKE $originalTable") !== false, $wpdb->last_error); + sa_assert($wpdb->query("INSERT INTO $table SELECT * FROM $originalTable") !== false, $wpdb->last_error); + $wpdb->options = $table; + $wp_object_cache = new WP_Object_Cache(); + wp_cache_switch_to_blog(get_current_blog_id()); + $test(); + echo "Regression checks passed\n"; + } finally { + $wpdb->options = $originalTable; + $wp_object_cache = $originalCache; + remove_filter('query', $guard, 9999); + } + // The database connection automatically drops the temporary table on exit. +}