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 b769bb9..271235b 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::boolean(SettingName::MANUAL_COLLECT) ? 'false' : 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 @@ + { + 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.'); +});