Skip to content

Commit a971fdb

Browse files
authored
Merge pull request #126 from iMattPro/security
Security improvement - allow only known push services
2 parents ab15164 + bb61ff5 commit a971fdb

File tree

7 files changed

+283
-23
lines changed

7 files changed

+283
-23
lines changed

event/listener.php

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,16 +82,33 @@ public function __construct(config $config, controller_helper $controller_helper
8282
public static function getSubscribedEvents()
8383
{
8484
return [
85-
'core.page_header_after' => [['load_template_data'], ['pwa_manifest']],
86-
'core.ucp_display_module_before' => 'load_language',
87-
'core.acp_main_notice' => 'compatibility_notice',
85+
'core.user_setup' => 'load_language_on_setup',
86+
'core.page_header_after' => [['load_template_data'], ['pwa_manifest']],
87+
'core.ucp_display_module_before' => 'load_language',
88+
'core.acp_main_notice' => 'compatibility_notice',
8889
'core.acp_board_config_edit_add' => 'acp_pwa_options',
89-
'core.acp_board_config_emoji_enabled'=> 'acp_pwa_allow_emoji',
90-
'core.validate_config_variable' => 'validate_pwa_options',
91-
'core.help_manager_add_block_after' => 'wpn_faq',
90+
'core.acp_board_config_emoji_enabled' => 'acp_pwa_allow_emoji',
91+
'core.validate_config_variable' => 'validate_pwa_options',
92+
'core.help_manager_add_block_after' => 'wpn_faq',
9293
];
9394
}
9495

96+
/**
97+
* Load common language file during user setup
98+
*
99+
* @param \phpbb\event\data $event The event object
100+
* @return void
101+
*/
102+
public function load_language_on_setup($event)
103+
{
104+
$lang_set_ext = $event['lang_set_ext'];
105+
$lang_set_ext[] = [
106+
'ext_name' => 'phpbb/webpushnotifications',
107+
'lang_set' => 'common',
108+
];
109+
$event['lang_set_ext'] = $lang_set_ext;
110+
}
111+
95112
/**
96113
* Load template data
97114
*/

language/en/common.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
/**
3+
*
4+
* phpBB Browser Push Notifications. An extension for the phpBB Forum Software package.
5+
*
6+
* @copyright (c) 2026, phpBB Limited <https://www.phpbb.com>
7+
* @license GNU General Public License, version 2 (GPL-2.0)
8+
*
9+
*/
10+
11+
/**
12+
* DO NOT CHANGE
13+
*/
14+
if (!defined('IN_PHPBB'))
15+
{
16+
exit;
17+
}
18+
19+
if (empty($lang) || !is_array($lang))
20+
{
21+
$lang = [];
22+
}
23+
24+
// DEVELOPERS PLEASE NOTE
25+
//
26+
// All language files should use UTF-8 as their encoding and the files must not contain a BOM.
27+
//
28+
// Placeholders can now contain order information, e.g. instead of
29+
// 'Page %s of %s' you can (and should) write 'Page %1$s of %2$s', this allows
30+
// translators to re-order the output of data while ensuring it remains correct
31+
//
32+
// You do not need this where single placeholders are used, e.g. 'Message %d' is fine
33+
// equally where a string contains only two placeholders which are used to wrap text
34+
// in a url you again do not need to specify an order e.g., 'Click %sHERE%s' is fine
35+
//
36+
// Some characters you may want to copy&paste:
37+
// ’ » “ ” …
38+
//
39+
40+
$lang = array_merge($lang, [
41+
'WEBPUSH_INVALID_ENDPOINT' => 'The push notification endpoint is not from a recognised push service.',
42+
]);

language/ru/common.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
/**
3+
*
4+
* phpBB Browser Push Notifications. An extension for the phpBB Forum Software package.
5+
*
6+
* @copyright (c) 2026, phpBB Limited <https://www.phpbb.com>
7+
* @license GNU General Public License, version 2 (GPL-2.0)
8+
*
9+
*/
10+
11+
/**
12+
* DO NOT CHANGE
13+
*/
14+
if (!defined('IN_PHPBB'))
15+
{
16+
exit;
17+
}
18+
19+
if (empty($lang) || !is_array($lang))
20+
{
21+
$lang = [];
22+
}
23+
24+
// DEVELOPERS PLEASE NOTE
25+
//
26+
// All language files should use UTF-8 as their encoding and the files must not contain a BOM.
27+
//
28+
// Placeholders can now contain order information, e.g. instead of
29+
// 'Page %s of %s' you can (and should) write 'Page %1$s of %2$s', this allows
30+
// translators to re-order the output of data while ensuring it remains correct
31+
//
32+
// You do not need this where single placeholders are used, e.g. 'Message %d' is fine
33+
// equally where a string contains only two placeholders which are used to wrap text
34+
// in a url you again do not need to specify an order e.g., 'Click %sHERE%s' is fine
35+
//
36+
// Some characters you may want to copy&paste:
37+
// ’ » “ ” …
38+
//
39+
40+
$lang = array_merge($lang, [
41+
'WEBPUSH_INVALID_ENDPOINT' => 'Конечная точка push-уведомления не принадлежит известному сервису push-уведомлений.',
42+
]);

styles/all/template/webpush.js

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -301,22 +301,35 @@ function PhpbbWebpush() {
301301
});
302302

303303
const loadingIndicator = phpbb.loadingIndicator();
304-
fetch(subscribeUrl, {
305-
method: 'POST',
306-
headers: {
307-
'X-Requested-With': 'XMLHttpRequest',
308-
},
309-
body: getFormData(newSubscription),
310-
})
311-
.then(response => {
312-
loadingIndicator.fadeOut(phpbb.alertTime);
313-
return response.json();
314-
})
315-
.then(handleSubscribe)
316-
.catch(error => {
317-
loadingIndicator.fadeOut(phpbb.alertTime);
318-
phpbb.alert(ajaxErrorTitle, error);
304+
try {
305+
const response = await fetch(subscribeUrl, {
306+
method: 'POST',
307+
headers: {
308+
'X-Requested-With': 'XMLHttpRequest',
309+
},
310+
body: getFormData(newSubscription),
319311
});
312+
const data = await response.json();
313+
loadingIndicator.fadeOut(phpbb.alertTime);
314+
315+
if (data.success) {
316+
handleSubscribe(data);
317+
} else {
318+
// Server rejected the subscription; clean up the browser-side subscription
319+
// without awaiting, so a failure here can't bubble to the outer catch and
320+
// incorrectly trigger promptDenied or show the wrong error message.
321+
newSubscription.unsubscribe().catch(console.error);
322+
promptDenied.set();
323+
hidePopup(document.getElementById('wpn_popup_prompt'));
324+
phpbb.alert(ajaxErrorTitle, data.message || subscribeButton.getAttribute('data-l-unsupported'));
325+
}
326+
} catch (error) {
327+
loadingIndicator.fadeOut(phpbb.alertTime);
328+
// Clean up the browser-side subscription so it doesn't become orphaned
329+
// when the server request fails (network error, bad JSON, etc.).
330+
newSubscription.unsubscribe().catch(console.error);
331+
phpbb.alert(ajaxErrorTitle, error.message || subscribeButton.getAttribute('data-l-unsupported'));
332+
}
320333
} catch (error) {
321334
promptDenied.set(); // deny the prompt on error to prevent repeated prompting
322335
hidePopup(document.getElementById('wpn_popup_prompt'));

tests/controller/controller_webpush_test.php

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ public function test_sub_unsubscribe_success()
392392

393393
$symfony_request = $this->createMock(\phpbb\symfony_request::class);
394394
$symfony_request->method('get')->willReturn(json_encode([
395-
'endpoint' => 'test_endpoint',
395+
'endpoint' => 'https://fcm.googleapis.com/fcm/send/test_endpoint',
396396
'expiration_time' => 0,
397397
'keys' => ['p256dh' => 'test_p256dh', 'auth' => 'test_auth']
398398
]));
@@ -404,7 +404,7 @@ public function test_sub_unsubscribe_success()
404404

405405
$this->assertEquals([
406406
'user_id' => '2',
407-
'endpoint' => 'test_endpoint',
407+
'endpoint' => 'https://fcm.googleapis.com/fcm/send/test_endpoint',
408408
'p256dh' => 'test_p256dh',
409409
'auth' => 'test_auth',
410410
'expiration_time' => '0',
@@ -420,6 +420,77 @@ public function test_sub_unsubscribe_success()
420420
$this->assertEmpty($this->get_subscription_data());
421421
}
422422

423+
public function data_subscribe_invalid_endpoint(): array
424+
{
425+
return [
426+
'no_host' => ['not_a_url'],
427+
'disallowed_host' => ['https://evil.example.com/push/endpoint'],
428+
'disallowed_subdomain' => ['https://evil.fcm.googleapis.com.attacker.com/push'],
429+
'empty_string' => [''],
430+
];
431+
}
432+
433+
/**
434+
* @dataProvider data_subscribe_invalid_endpoint
435+
*/
436+
public function test_subscribe_invalid_endpoint(string $endpoint): void
437+
{
438+
$this->form_helper->method('check_form_tokens')->willReturn(true);
439+
$this->request->method('is_ajax')->willReturn(true);
440+
$this->user->data['user_id'] = 2;
441+
$this->user->data['is_bot'] = false;
442+
$this->user->data['user_type'] = USER_NORMAL;
443+
444+
$symfony_request = $this->createMock(\phpbb\symfony_request::class);
445+
$symfony_request->method('get')->willReturn(json_encode([
446+
'endpoint' => $endpoint,
447+
'expiration_time' => 0,
448+
'keys' => ['p256dh' => 'test_p256dh', 'auth' => 'test_auth']
449+
]));
450+
451+
$this->expectException(http_exception::class);
452+
$this->expectExceptionMessage('WEBPUSH_INVALID_ENDPOINT');
453+
454+
$this->controller->subscribe($symfony_request);
455+
}
456+
457+
public function data_is_valid_endpoint(): array
458+
{
459+
return [
460+
// Exact whitelist matches
461+
['https://android.googleapis.com/gcm/send/test', true],
462+
['https://fcm.googleapis.com/fcm/send/test', true],
463+
['https://updates.push.services.mozilla.com/push/v1/test', true],
464+
['https://updates-autopush.stage.mozaws.net/push/v1/test', true],
465+
['https://updates-autopush.dev.mozaws.net/push/v1/test', true],
466+
// Wildcard *.notify.windows.com
467+
['https://am-0.notify.windows.com/throttledthirdparty/test', true],
468+
['https://db5.notify.windows.com/push/test', true],
469+
// Wildcard *.push.apple.com
470+
['https://api.push.apple.com/3/device/test', true],
471+
['https://api.development.push.apple.com/3/device/test', true],
472+
// Invalid: disallowed host
473+
['https://evil.example.com/push/endpoint', false],
474+
// Invalid: subdomain spoofing
475+
['https://evil.fcm.googleapis.com.attacker.com/push', false],
476+
// Invalid: not a URL
477+
['not_a_url', false],
478+
// Invalid: bare wildcard domain without subdomain
479+
['https://notify.windows.com/push', false],
480+
['https://push.apple.com/push', false],
481+
// Invalid: empty string
482+
['', false],
483+
];
484+
}
485+
486+
/**
487+
* @dataProvider data_is_valid_endpoint
488+
*/
489+
public function test_is_valid_endpoint(string $endpoint, bool $expected): void
490+
{
491+
$this->assertEquals($expected, $this->controller->is_valid_endpoint($endpoint));
492+
}
493+
423494
public function test_toggle_popup_enable_to_disable()
424495
{
425496
$this->form_helper->method('check_form_tokens')->willReturn(true);

tests/event/listener_test.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ public function test_construct()
141141
public function test_getSubscribedEvents()
142142
{
143143
self::assertEquals([
144+
'core.user_setup',
144145
'core.page_header_after',
145146
'core.ucp_display_module_before',
146147
'core.acp_main_notice',
@@ -151,6 +152,25 @@ public function test_getSubscribedEvents()
151152
], array_keys(\phpbb\webpushnotifications\event\listener::getSubscribedEvents()));
152153
}
153154

155+
public function test_load_language_on_setup($lang_set_ext = [])
156+
{
157+
$this->set_listener();
158+
159+
$dispatcher = new \phpbb\event\dispatcher();
160+
$dispatcher->addListener('core.user_setup', [$this->listener, 'load_language_on_setup']);
161+
162+
$event_data = ['lang_set_ext'];
163+
$event_data_after = $dispatcher->trigger_event('core.user_setup', compact($event_data));
164+
extract($event_data_after);
165+
166+
self::assertEquals([
167+
[
168+
'ext_name' => 'phpbb/webpushnotifications',
169+
'lang_set' => 'common',
170+
]
171+
], $lang_set_ext);
172+
}
173+
154174
public function test_load_language()
155175
{
156176
$this->set_listener();

ucp/controller/webpush.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,21 @@ class webpush
3636
/** @var string UCP form token name */
3737
public const FORM_TOKEN_UCP = 'ucp_webpush';
3838

39+
/** @var array Allowed push service endpoint hosts https://github.com/pushpad/known-push-services */
40+
public const PUSH_SERVICE_WHITELIST = [
41+
'android.googleapis.com',
42+
'fcm.googleapis.com',
43+
'updates.push.services.mozilla.com',
44+
'updates-autopush.stage.mozaws.net',
45+
'updates-autopush.dev.mozaws.net',
46+
];
47+
48+
/** @var array Allowed push service endpoint host wildcard suffixes (e.g. *.notify.windows.com) https://github.com/pushpad/known-push-services */
49+
public const PUSH_SERVICE_WILDCARD_SUFFIXES = [
50+
'.notify.windows.com',
51+
'.push.apple.com',
52+
];
53+
3954
/** @var config */
4055
protected $config;
4156

@@ -279,6 +294,39 @@ public function worker(): Response
279294
return $response;
280295
}
281296

297+
/**
298+
* Check if a push subscription endpoint belongs to an allowed push service
299+
*
300+
* @param string $endpoint The push subscription endpoint URL
301+
* @return bool True if the endpoint host matches a known/allowed push service
302+
*/
303+
public function is_valid_endpoint(string $endpoint): bool
304+
{
305+
$host = parse_url($endpoint, PHP_URL_HOST);
306+
307+
if (empty($host))
308+
{
309+
return false;
310+
}
311+
312+
$host = strtolower($host);
313+
314+
if (in_array($host, self::PUSH_SERVICE_WHITELIST, true))
315+
{
316+
return true;
317+
}
318+
319+
foreach (self::PUSH_SERVICE_WILDCARD_SUFFIXES as $suffix)
320+
{
321+
if (substr($host, -strlen($suffix)) === $suffix)
322+
{
323+
return true;
324+
}
325+
}
326+
327+
return false;
328+
}
329+
282330
/**
283331
* Check (un)subscribe form for valid link hash
284332
*
@@ -312,6 +360,13 @@ public function subscribe(symfony_request $symfony_request): JsonResponse
312360

313361
$data = json_sanitizer::decode($symfony_request->get('data', ''));
314362

363+
$data['endpoint'] = $data['endpoint'] ?? '';
364+
365+
if (!$this->is_valid_endpoint($data['endpoint']))
366+
{
367+
throw new http_exception(Response::HTTP_BAD_REQUEST, 'WEBPUSH_INVALID_ENDPOINT');
368+
}
369+
315370
$sql = 'INSERT INTO ' . $this->push_subscriptions_table . ' ' . $this->db->sql_build_array('INSERT', [
316371
'user_id' => $this->user->id(),
317372
'endpoint' => $data['endpoint'],

0 commit comments

Comments
 (0)