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
10 changes: 5 additions & 5 deletions adm/style/consentmanager_acp.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,13 @@ <h3>{{ lang('WARNING') }}</h3>
<dl>
<dt><span>{{ lang('ACP_CONSENTMANAGER_REGISTRATIONS_EXPLAIN') }}</span></dt>
<dd>
{% set services = S_CONSENTMANAGER_MEDIA ? CONSENTMANAGER_SERVICES|merge([{'category': 'media', 'label': lang('CONSENTMANAGER_IFRAMES'), 'id': 'phpbb.consentmanager'}]) : CONSENTMANAGER_SERVICES %}
{% set services = S_CONSENTMANAGER_MEDIA ? CONSENTMANAGER_SERVICES|merge([{'category': 'media', 'label': lang('ACP_CONSENTMANAGER_IFRAMES'), 'id': 'phpbb.consentmanager'}]) : CONSENTMANAGER_SERVICES %}
{% if services %}
{% for category in [
{ id: 'necessary', label: lang('CONSENTMANAGER_CATEGORY_NECESSARY') },
{ id: 'analytics', label: lang('CONSENTMANAGER_CATEGORY_ANALYTICS') },
{ id: 'marketing', label: lang('CONSENTMANAGER_CATEGORY_MARKETING') },
{ id: 'media', label: lang('CONSENTMANAGER_CATEGORY_MEDIA') }
{ id: 'necessary', label: lang('ACP_CONSENTMANAGER_CATEGORY_NECESSARY') },
{ id: 'analytics', label: lang('ACP_CONSENTMANAGER_CATEGORY_ANALYTICS') },
{ id: 'marketing', label: lang('ACP_CONSENTMANAGER_CATEGORY_MARKETING') },
{ id: 'media', label: lang('ACP_CONSENTMANAGER_CATEGORY_MEDIA') }
] %}
{% set category_has_services = false %}
{% for service in services %}
Expand Down
2 changes: 1 addition & 1 deletion adm/style/consentmanager_acp_banner.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ <h3>{{ lang('WARNING') }}</h3>
<table class="table1 zebra-table">
<thead>
<tr>
<th>{{ lang('LANGUAGE') }}</th>
<th>{{ lang('ACP_CONSENTMANAGER_LANGUAGE') }}</th>
{% for field in CONSENTMANAGER_BANNER_FIELDS %}
<th>{{ field.LABEL }}</th>
{% endfor %}
Expand Down
13 changes: 7 additions & 6 deletions language/en/acp_consentmanager.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
'ACP_CONSENTMANAGER_INTEGRATIONS_REQUIRED' => 'Required properties',
'ACP_CONSENTMANAGER_INTEGRATIONS_REQUIRED_ID' => 'may only use letters, numbers, dots, underscores, colons, and hyphens.',
'ACP_CONSENTMANAGER_INTEGRATIONS_REQUIRED_CAT' => 'must be one of these values:',
'ACP_CONSENTMANAGER_INTEGRATIONS_REQUIRED_SRC' => 'must be a valid http, https, or relative script URL.',
'ACP_CONSENTMANAGER_INTEGRATIONS_REQUIRED_SRC' => 'must be a valid HTTPS or relative script URL.',
'ACP_CONSENTMANAGER_INTEGRATIONS_OPTIONAL' => 'Optional properties',
'ACP_CONSENTMANAGER_INTEGRATIONS_EXAMPLE_LABEL' => 'Example Analytics',
'ACP_CONSENTMANAGER_INTEGRATIONS_EXAMPLE_DESC' => 'Loads a simple analytics library after consent.',
Expand All @@ -54,11 +54,12 @@
'ACP_CONSENTMANAGER_BANNER_FALLBACK_EXPLAIN' => 'Leave a field blank to remove the custom translation and use Consent Manager’s default text for that language.',
'ACP_CONSENTMANAGER_BANNER_TEXT_TOO_LONG' => 'Consent text values must be %d characters or fewer.',
'ACP_CONSENTMANAGER_BANNER_UPDATED' => 'Consent text updated.',
'CONSENTMANAGER_CATEGORY_NECESSARY' => 'Necessary',
'CONSENTMANAGER_CATEGORY_ANALYTICS' => 'Analytics',
'CONSENTMANAGER_CATEGORY_MARKETING' => 'Marketing',
'CONSENTMANAGER_CATEGORY_MEDIA' => 'Media',
'CONSENTMANAGER_IFRAMES' => 'BBCode iframe embeds',
'ACP_CONSENTMANAGER_LANGUAGE' => 'Language',
'ACP_CONSENTMANAGER_CATEGORY_NECESSARY' => 'Necessary',
'ACP_CONSENTMANAGER_CATEGORY_ANALYTICS' => 'Analytics',
'ACP_CONSENTMANAGER_CATEGORY_MARKETING' => 'Marketing',
'ACP_CONSENTMANAGER_CATEGORY_MEDIA' => 'Media',
'ACP_CONSENTMANAGER_IFRAMES' => 'BBCode iframe embeds',

// Consent logs
'ACP_CONSENTMANAGER_EXPORT_EXPLAIN' => 'Download a CSV file of stored consent log records or permanently delete matching records from the database. All fields are optional; leave them blank to work with the full log.',
Expand Down
59 changes: 59 additions & 0 deletions migrations/m4_guest_throttling.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php
/**
*
* Consent Manager extension for the phpBB Forum Software package.
*
* @copyright (c) 2026
* @license GNU General Public License, version 2 (GPL-2.0)
*
*/

namespace phpbb\consentmanager\migrations;

class m4_guest_throttling extends \phpbb\db\migration\migration
{
public function effectively_installed()
{
return $this->db_tools->sql_column_exists($this->table_prefix . 'consentmanager_logs', 'throttle_id');
}

public static function depends_on()
{
return [
'\phpbb\consentmanager\migrations\m1_initial',
'\phpbb\consentmanager\migrations\m3_banner_translations'
];
}

public function update_schema()
{
return [
'add_columns' => [
$this->table_prefix . 'consentmanager_logs' => [
'throttle_id' => ['VCHAR:64', ''],
],
],
'add_index' => [
$this->table_prefix . 'consentmanager_logs' => [
'throttle_id' => ['throttle_id'],
],
],
];
}

public function revert_schema()
{
return [
'drop_keys' => [
$this->table_prefix . 'consentmanager_logs' => [
'throttle_id',
],
],
'drop_columns' => [
$this->table_prefix . 'consentmanager_logs' => [
'throttle_id',
],
],
];
}
}
38 changes: 38 additions & 0 deletions migrations/m5_hmac_secret.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php
/**
*
* Consent Manager extension for the phpBB Forum Software package.
*
* @copyright (c) 2026
* @license GNU General Public License, version 2 (GPL-2.0)
*
*/

namespace phpbb\consentmanager\migrations;

class m5_hmac_secret extends \phpbb\db\migration\migration
{
public function effectively_installed()
{
return isset($this->config['consentmanager_hmac_secret']);
}

public static function depends_on()
{
return ['\phpbb\consentmanager\migrations\m4_guest_throttling'];
}

public function update_data()
{
return [
['config.add', ['consentmanager_hmac_secret', (string) $this->config['rand_seed']]],
];
}

public function revert_data()
{
return [
['config.remove', ['consentmanager_hmac_secret']],
];
}
}
5 changes: 1 addition & 4 deletions service/acp_manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -281,16 +281,13 @@ public function delete_logs(array $filters = [])
* Mirrors the HMAC used in log_manager::log_consent() so that admins can
* filter exports by user ID without exposing raw identifiers.
*
* Note: it only matches rows hashed with the current config[rand_seed]. Records
* logged before a rand_seed rotation will not be found.
*
* @param int $user_id Numeric phpBB user ID (must be > 0)
*
* @return string 64-character hex hash
*/
public function hash_user_id($user_id)
{
return hash_hmac('sha256', 'u:' . (int) $user_id, $this->config['rand_seed']);
return hash_hmac('sha256', 'u:' . (int) $user_id, $this->config['consentmanager_hmac_secret']);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion service/consent_manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -1023,7 +1023,7 @@ protected function is_valid_script_source($src)
return false;
}

return !isset($parts['scheme']) || in_array(strtolower($parts['scheme']), ['http', 'https'], true);
return !isset($parts['scheme']) || strtolower($parts['scheme']) === 'https';
}

/**
Expand Down
32 changes: 27 additions & 5 deletions service/log_manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,18 @@ public function __construct(config $config, driver_interface $db, user $user, $c
public function log_consent(array $categories, $version)
{
$anonymized_id = $this->get_anonymized_subject();
$throttle_id = $this->get_throttle_subject($anonymized_id);
$accepted_categories = json_encode(array_values($categories));
$now = time();

if ($this->should_suppress_submission($anonymized_id, (int) $version, $accepted_categories, $now))
if ($this->should_suppress_submission($throttle_id, (int) $version, $accepted_categories, $now))
{
return false;
}

$record = [
'anonymized_id' => $anonymized_id,
'throttle_id' => $throttle_id,
'consent_version' => (int) $version,
'accepted_categories' => $accepted_categories,
'consent_time' => $now,
Expand All @@ -88,18 +90,18 @@ public function log_consent(array $categories, $version)
/**
* Suppress rapid duplicates and excessive submissions from one subject.
*
* @param string $anonymized_id Anonymized user or guest-session identifier
* @param string $throttle_id Anonymized user or guest-IP throttle identifier
* @param int $version Consent version
* @param string $accepted_categories JSON-encoded normalized categories
* @param int $now Current Unix timestamp
*
* @return bool
*/
protected function should_suppress_submission($anonymized_id, $version, $accepted_categories, $now)
protected function should_suppress_submission($throttle_id, $version, $accepted_categories, $now)
{
$sql = 'SELECT consent_version, accepted_categories, consent_time
FROM ' . $this->consent_logs_table . "
WHERE anonymized_id = '" . $this->db->sql_escape($anonymized_id) . "'
WHERE throttle_id = '" . $this->db->sql_escape($throttle_id) . "'
AND consent_time >= " . ((int) $now - self::RATE_LIMIT_WINDOW) . '
ORDER BY consent_log_id DESC';
$result = $this->db->sql_query_limit($sql, self::RATE_LIMIT_MAX);
Expand Down Expand Up @@ -136,6 +138,26 @@ protected function get_anonymized_subject()
{
$subject = (int) $this->user->data['user_id'] !== ANONYMOUS ? 'u:' . (int) $this->user->data['user_id'] : 's:' . $this->user->session_id;

return hash_hmac('sha256', $subject, $this->config['rand_seed']);
return hash_hmac('sha256', $subject, $this->config['consentmanager_hmac_secret']);
}

/**
* Build an anonymized identifier used to throttle the current subject.
*
* Guest throttling uses the requester IP so discarding the session cookie
* cannot reset the duplicate or rate-limit windows.
*
* @param string $anonymized_id Current user or guest-session identifier
*
* @return string
*/
protected function get_throttle_subject($anonymized_id)
{
if ((int) $this->user->data['user_id'] !== ANONYMOUS)
{
return $anonymized_id;
}

return hash_hmac('sha256', 'ip:' . $this->user->ip, $this->config['consentmanager_hmac_secret']);
}
}
17 changes: 13 additions & 4 deletions service/media_manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

use s9e\TextFormatter\Configurator;
use s9e\TextFormatter\Configurator\Helpers\TemplateLoader;
use s9e\TextFormatter\Configurator\Items\UnsafeTemplate;

class media_manager
{
Expand Down Expand Up @@ -50,7 +51,8 @@ public function configure_iframe_embeds(Configurator $configurator)

foreach ($configurator->tags as $tag)
{
$template_source = (string) $tag->template;
$original_template = $tag->template;
$template_source = (string) $original_template;

if ($template_source === '' || stripos($template_source, 'iframe') === false)
{
Expand All @@ -63,9 +65,16 @@ public function configure_iframe_embeds(Configurator $configurator)
continue;
}

$tag->template = $template;
$configurator->templateNormalizer->normalizeTag($tag);
$configurator->templateChecker->checkTag($tag);
try
{
$tag->template = ($original_template instanceof UnsafeTemplate) ? new UnsafeTemplate($template) : $template;
$configurator->templateNormalizer->normalizeTag($tag);
$configurator->templateChecker->checkTag($tag);
}
catch (\Exception $e)
{
$tag->template = $original_template;
}
}
}

Expand Down
6 changes: 2 additions & 4 deletions styles/all/template/js/consentmanager.js
Original file line number Diff line number Diff line change
Expand Up @@ -575,11 +575,9 @@
return false;
}

const link = document.createElement('a');
link.href = src;
const protocol = (link.protocol || '').toLowerCase();
const scheme = src.match(/^([a-z][a-z0-9+.-]*):/i);

return protocol === '' || protocol === 'http:' || protocol === 'https:';
return !scheme || scheme[1].toLowerCase() === 'https';
}

function isSafeEmbedSource(src)
Expand Down
11 changes: 11 additions & 0 deletions tests/javascript/consentmanager.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -386,13 +386,24 @@ test('registerScript blocks unsafe sources and executes safe inline scripts', ()
src: 'javascript:alert(1)'
})).toBe(false);

expect(window.consentManager.registerScript('insecure', {
category: 'analytics',
src: 'http://cdn.example.com/script.js'
})).toBe(false);

expect(window.consentManager.registerScript('safe-inline', {
category: 'analytics',
inline: 'window.safeInlineLoaded = true;'
})).toBe(true);

expect(window.safeInlineLoaded).toBe(true);
expect(document.head.querySelectorAll('script[src]').length).toBe(0);

expect(window.consentManager.registerScript('safe-relative', {
category: 'analytics',
src: '/assets/analytics.js'
})).toBe(true);
expect(document.head.querySelector('script[src="/assets/analytics.js"]')).not.toBeNull();
});

test('processes deferred consent scripts and copies only safe attributes', () => {
Expand Down
20 changes: 17 additions & 3 deletions tests/service/acp_manager_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,23 @@ public function log_admin_action_data()
public function test_hash_user_id_returns_hmac_of_user_prefix()
{
$manager = $this->create_manager(1, 'session');
$expected = hash_hmac('sha256', 'u:42', 'random-seed');
$expected = hash_hmac('sha256', 'u:42', 'consent-secret');

self::assertSame($expected, $manager->hash_user_id(42));
}

public function test_hash_user_id_is_unchanged_after_rand_seed_rotation()
{
$manager_before = $this->create_manager(1, 'session', null, null, null, null, null, [
'rand_seed' => 'old-random-seed',
]);
$manager_after = $this->create_manager(1, 'session', null, null, null, null, null, [
'rand_seed' => 'new-random-seed',
]);

self::assertSame($manager_before->hash_user_id(42), $manager_after->hash_user_id(42));
}

public function test_hash_user_id_is_consistent()
{
$manager = $this->create_manager(1, 'session');
Expand Down Expand Up @@ -491,7 +503,7 @@ public function test_stream_logs_csv_filters_by_user_id()

self::assertCount(1, $rows);

$expected_hash = hash_hmac('sha256', 'u:42', 'random-seed');
$expected_hash = hash_hmac('sha256', 'u:42', 'consent-secret');
self::assertStringContainsString($expected_hash, reset($rows));
}

Expand Down Expand Up @@ -602,7 +614,7 @@ public function test_delete_logs_filters_by_user_id()

self::assertCount(1, $rows);

$remaining_hash = hash_hmac('sha256', 'u:99', 'random-seed');
$remaining_hash = hash_hmac('sha256', 'u:99', 'consent-secret');
self::assertStringContainsString($remaining_hash, reset($rows));
}

Expand Down Expand Up @@ -644,6 +656,7 @@ protected function create_manager($user_id, $session_id, $log = null, $config_te

$config = new \phpbb\config\config(array_merge(array(
'rand_seed' => 'random-seed',
'consentmanager_hmac_secret' => 'consent-secret',
'consentmanager_analytics_enabled' => 1,
'consentmanager_marketing_enabled' => 1,
'consentmanager_media_enabled' => 1,
Expand Down Expand Up @@ -804,6 +817,7 @@ protected function create_log_manager($user_id, $session_id)
{
$config = new \phpbb\config\config(array(
'rand_seed' => 'random-seed',
'consentmanager_hmac_secret' => 'consent-secret',
));

$user = new \phpbb\user($this->language, '\phpbb\datetime');
Expand Down
Loading