Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion simple-analytics.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');

Expand Down
3 changes: 2 additions & 1 deletion src/Scripts/AnalyticsScript.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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),
]);
Expand Down
5 changes: 5 additions & 0 deletions src/SettingName.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
5 changes: 5 additions & 0 deletions src/Settings/Blocks/Fields/Input.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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';
}

Expand Down
33 changes: 33 additions & 0 deletions src/Support/OnloadCallback.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace SimpleAnalytics\Support;

use SimpleAnalytics\SettingName;

class OnloadCallback
{
public static function sanitize($value): string
{
if (! current_user_can('unfiltered_html')) {
$current = get_option(SettingName::ONLOAD_CALLBACK, '');
return is_string($current) ? $current : '';
}

$value = sanitize_text_field($value);
// This private option is not registered as an editable setting. It
// records that a user allowed to save JavaScript saved this exact code.
update_option(SettingName::ONLOAD_CALLBACK_HASH, hash_hmac('sha256', $value, wp_salt('auth')));
return $value;
}

public static function get(): ?string
{
$value = get_option(SettingName::ONLOAD_CALLBACK, '');
$hash = get_option(SettingName::ONLOAD_CALLBACK_HASH, '');

// Older releases accepted callback text without the capability check.
// Keep it inert until an authorized administrator saves it again.
if (! is_string($value) || $value === '' || ! is_string($hash)) return null;
return hash_equals($hash, hash_hmac('sha256', $value, wp_salt('auth'))) ? $value : null;
}
}
23 changes: 23 additions & 0 deletions tests/Browser/pluginSettings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,29 @@ test('adds a script with manually collect page views enabled', async ({ page, br
await automaticGuest.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');
Expand Down
38 changes: 38 additions & 0 deletions tests/Regression/onload-permissions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

require __DIR__ . '/../Support/isolated-options.php';

sa_with_isolated_options(static function () {
$key = SimpleAnalytics\SettingName::ONLOAD_CALLBACK;
$hashKey = SimpleAnalytics\SettingName::ONLOAD_CALLBACK_HASH;
$script = new SimpleAnalytics\Scripts\AnalyticsScript();

delete_option($hashKey);
update_option($key, 'legacyCallback()');
sa_assert(! isset($script->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.');
});