From 81eb800d31d925b3439962b4d6982c371ccfa6a6 Mon Sep 17 00:00:00 2001 From: Joost de Valk Date: Tue, 15 Sep 2026 17:03:00 +0200 Subject: [PATCH] Execute authorized callbacks when the analytics script loads --- simple-analytics.php | 3 +- src/Scripts/AnalyticsScript.php | 3 +- src/SettingName.php | 5 ++++ src/Settings/Blocks/Fields/Input.php | 5 ++++ src/Support/OnloadCallback.php | 33 +++++++++++++++++++++ tests/Browser/phpRegression.spec.ts | 16 ++++++++++ tests/Browser/pluginSettings.spec.ts | 23 +++++++++++++++ tests/Regression/onload-permissions.php | 38 ++++++++++++++++++++++++ tests/Support/isolated-options.php | 39 +++++++++++++++++++++++++ 9 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 src/Support/OnloadCallback.php create mode 100644 tests/Browser/phpRegression.spec.ts create mode 100644 tests/Regression/onload-permissions.php create mode 100644 tests/Support/isolated-options.php diff --git a/simple-analytics.php b/simple-analytics.php index 3c253cd..1a12971 100644 --- a/simple-analytics.php +++ b/simple-analytics.php @@ -48,6 +48,7 @@ require __DIR__ . '/src/Actions/AddNoScriptTag.php'; require __DIR__ . '/src/Actions/AddPluginSettingsLink.php'; require __DIR__ . '/src/Support/Str.php'; +require __DIR__ . '/src/Support/OnloadCallback.php'; require __DIR__ . '/src/Settings/Block.php'; require __DIR__ . '/src/Settings/Blocks/CalloutBlock.php'; require __DIR__ . '/src/Settings/Concerns/HasDocs.php'; @@ -122,7 +123,7 @@ ->description('Collect analytics from visitors with disabled or no JavaScript.'); $tab->input(SettingName::ONLOAD_CALLBACK, 'Onload Callback') - ->description('JavaScript function to call when the script is loaded.') + ->description('JavaScript to run when the script loads. Requires permission to save unfiltered HTML. After upgrading, save an existing callback again to enable it.') ->placeholder('Example: sa_event("My event")') ->docs('https://docs.simpleanalytics.com/trigger-custom-page-views#use-custom-collection-anyway'); diff --git a/src/Scripts/AnalyticsScript.php b/src/Scripts/AnalyticsScript.php index 8b0d5b1..1064d28 100644 --- a/src/Scripts/AnalyticsScript.php +++ b/src/Scripts/AnalyticsScript.php @@ -7,6 +7,7 @@ use SimpleAnalytics\Scripts\Contracts\Script; use SimpleAnalytics\Setting; use SimpleAnalytics\SettingName; +use SimpleAnalytics\Support\OnloadCallback; class AnalyticsScript implements Script, HasAttributes, HideScriptId { @@ -28,7 +29,7 @@ public function attributes(): array 'data-collect-dnt' => Setting::boolean(SettingName::COLLECT_DNT) ? 'true' : null, 'data-ignore-pages' => Setting::get(SettingName::IGNORE_PAGES), 'data-auto-collect' => Setting::get(SettingName::MANUAL_COLLECT) ? 'true' : null, - 'data-onload' => Setting::get(SettingName::ONLOAD_CALLBACK), + 'onload' => OnloadCallback::get(), 'data-sa-global' => Setting::get(SettingName::SA_GLOBAL), 'data-hostname' => Setting::get(SettingName::HOSTNAME), ]); diff --git a/src/SettingName.php b/src/SettingName.php index fd35f98..b1cc789 100644 --- a/src/SettingName.php +++ b/src/SettingName.php @@ -45,6 +45,11 @@ class SettingName * @var string */ const ONLOAD_CALLBACK = 'simpleanalytics_onload_callback'; + /** + * Hash recorded only when an authorized user saves the callback. + * @var string + */ + const ONLOAD_CALLBACK_HASH = 'simpleanalytics_onload_callback_hash'; /** * @var string */ diff --git a/src/Settings/Blocks/Fields/Input.php b/src/Settings/Blocks/Fields/Input.php index 031c341..93705ca 100644 --- a/src/Settings/Blocks/Fields/Input.php +++ b/src/Settings/Blocks/Fields/Input.php @@ -3,6 +3,8 @@ namespace SimpleAnalytics\Settings\Blocks\Fields; use SimpleAnalytics\Setting; +use SimpleAnalytics\SettingName; +use SimpleAnalytics\Support\OnloadCallback; use SimpleAnalytics\Settings\Concerns\HasDocs; use SimpleAnalytics\Settings\Concerns\HasPlaceholder; use SimpleAnalytics\UI\LabelComponent; @@ -29,6 +31,9 @@ class Input extends Field public function getValueSanitizer(): callable { + if ($this->getKey() === SettingName::ONLOAD_CALLBACK) { + return [OnloadCallback::class, 'sanitize']; + } return 'sanitize_text_field'; } diff --git a/src/Support/OnloadCallback.php b/src/Support/OnloadCallback.php new file mode 100644 index 0000000..9118af0 --- /dev/null +++ b/src/Support/OnloadCallback.php @@ -0,0 +1,33 @@ + 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/Browser/pluginSettings.spec.ts b/tests/Browser/pluginSettings.spec.ts index c409661..8b83838 100644 --- a/tests/Browser/pluginSettings.spec.ts +++ b/tests/Browser/pluginSettings.spec.ts @@ -184,6 +184,29 @@ test('adds a script with manually collect page views enabled', async ({ page, br await guest.context().close(); }); +test('executes the saved onload callback when the analytics script loads', async ({ page, browser }) => { + await asAdmin(page); + await page.goto('/wp-admin/options-general.php?page=simpleanalytics&tab=advanced'); + const callback = `document.documentElement.setAttribute('data-sa-callback', 'loaded "quoted"');`; + await page.locator('[name="simpleanalytics_onload_callback"]').fill(callback); + await saveSettings(page); + + const context = await browser.newContext(); + await context.route('https://scripts.simpleanalyticscdn.com/latest.js', route => + route.fulfill({ contentType: 'application/javascript', body: '/* successful script load */' })); + const guest = await context.newPage(); + try { + await guest.goto('/'); + await expect(guest.locator('html')).toHaveAttribute('data-sa-callback', 'loaded "quoted"'); + await expect(guest.locator(DEFAULT_SCRIPT_SELECTOR)).toHaveAttribute('onload', callback); + await expect(guest.locator(DEFAULT_SCRIPT_SELECTOR)).not.toHaveAttribute('data-onload'); + } finally { + await page.locator('[name="simpleanalytics_onload_callback"]').fill(''); + await saveSettings(page); + await context.close(); + } +}); + test('adds a script with overwrite domain name', async ({ page, browser }) => { await asAdmin(page); await page.goto('/wp-admin/options-general.php?page=simpleanalytics&tab=advanced'); diff --git a/tests/Regression/onload-permissions.php b/tests/Regression/onload-permissions.php new file mode 100644 index 0000000..4e1fc63 --- /dev/null +++ b/tests/Regression/onload-permissions.php @@ -0,0 +1,38 @@ +attributes()['onload']), 'Unverified legacy text must remain inert.'); + + $admin = get_user_by('login', 'admin'); + sa_assert($admin !== false, 'The wp-env admin user must exist.'); + wp_set_current_user($admin->ID); + $field = new SimpleAnalytics\Settings\Blocks\Fields\Input($key, 'Onload Callback'); + register_setting('simpleanalytics-advanced', $key, ['sanitize_callback' => $field->getValueSanitizer()]); + update_option($key, 'authorizedCallback()'); + sa_assert(($script->attributes()['onload'] ?? null) === 'authorizedCallback()', 'Authorized saves must enable the callback.'); + + // Simulate a multisite administrator (or DISALLOW_UNFILTERED_HTML) through + // WordPress's real capability mapping, without changing any user records. + $deny = static function ($caps, $cap) { return $cap === 'unfiltered_html' ? ['do_not_allow'] : $caps; }; + add_filter('map_meta_cap', $deny, 10, 2); + $hash = get_option($hashKey); + update_option($key, 'unauthorizedCallback()'); + sa_assert(get_option($key) === 'authorizedCallback()', 'Users without unfiltered_html must not replace the callback.'); + sa_assert(get_option($hashKey) === $hash, 'Unauthorized saves must not change the recorded hash.'); + remove_filter('map_meta_cap', $deny, 10); + + remove_filter('sanitize_option_' . $key, $field->getValueSanitizer()); + update_option($key, 'changedOutsideSettings()'); + sa_assert(! isset($script->attributes()['onload']), 'Code that differs from the verified value must remain inert.'); + register_setting('simpleanalytics-advanced', $key, ['sanitize_callback' => $field->getValueSanitizer()]); + update_option($key, ''); + sa_assert(! isset($script->attributes()['onload']), 'Clearing the callback must remove the handler.'); +}); 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. +}