Bug 1635524 - Part 2: Pass oldSubscription for pushsubscriptionchange event r=asuth

Differential Revision: https://phabricator.services.mozilla.com/D234713
This commit is contained in:
Kagami Sascha Rosylight
2025-02-12 12:47:22 +00:00
parent f719282d2d
commit 4d3ee492be
20 changed files with 283 additions and 138 deletions

View File

@@ -12,6 +12,7 @@ interface mozIDOMWindowProxy;
interface nsIArray;
interface nsIInterceptedChannel;
interface nsIPrincipal;
interface nsIPushSubscription;
interface nsIRunnable;
interface nsIURI;
%{C++
@@ -320,7 +321,8 @@ interface nsIServiceWorkerManager : nsISupports
in ACString aScope,
[optional] in Array<uint8_t> aDataBytes);
void sendPushSubscriptionChangeEvent(in ACString aOriginAttributes,
in ACString scope);
in ACString scope,
[optional] in nsIPushSubscription aOldSubscription);
void addListener(in nsIServiceWorkerManagerListener aListener);

View File

@@ -13,6 +13,7 @@
%}
interface nsIPrincipal;
interface nsIPushSubscription;
/**
* Fires XPCOM observer notifications and service worker events for
@@ -43,7 +44,8 @@ interface nsIPushNotifier : nsISupports
* `pushsubscriptionchange` event to the service worker registered for the
* |scope|.
*/
void notifySubscriptionChange(in ACString scope, in nsIPrincipal principal);
void notifySubscriptionChange(in ACString scope, in nsIPrincipal principal,
[optional] in nsIPushSubscription oldSubscription);
/**
* Fires a `push-subscription-modified` observer notification. Chrome code

View File

@@ -0,0 +1,93 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
/** `ChromePushSubscription` instances are passed to all subscription callbacks. */
export class ChromePushSubscription {
#props;
constructor(props) {
this.#props = props;
}
QueryInterface = ChromeUtils.generateQI(["nsIPushSubscription"]);
/** The URL for sending messages to this subscription. */
get endpoint() {
return this.#props.endpoint;
}
/** The last time a message was sent to this subscription. */
get lastPush() {
return this.#props.lastPush;
}
/** The total number of messages sent to this subscription. */
get pushCount() {
return this.#props.pushCount;
}
/** The number of remaining background messages that can be sent to this
* subscription, or -1 of the subscription is exempt from the quota.
*/
get quota() {
return this.#props.quota;
}
/**
* Indicates whether this subscription was created with the system principal.
* System subscriptions are exempt from the background message quota and
* permission checks.
*/
get isSystemSubscription() {
return !!this.#props.systemRecord;
}
/** The private key used to decrypt incoming push messages, in JWK format */
get p256dhPrivateKey() {
return this.#props.p256dhPrivateKey;
}
/**
* Indicates whether this subscription is subject to the background message
* quota.
*/
quotaApplies() {
return this.quota >= 0;
}
/**
* Indicates whether this subscription exceeded the background message quota,
* or the user revoked the notification permission. The caller must request a
* new subscription to continue receiving push messages.
*/
isExpired() {
return this.quota === 0;
}
/**
* Returns a key for encrypting messages sent to this subscription. JS
* callers receive the key buffer as a return value, while C++ callers
* receive the key size and buffer as out parameters.
*/
getKey(name) {
switch (name) {
case "p256dh":
return this.#getRawKey(this.#props.p256dhKey);
case "auth":
return this.#getRawKey(this.#props.authenticationSecret);
case "appServer":
return this.#getRawKey(this.#props.appServerKey);
}
return [];
}
#getRawKey(key) {
if (!key) {
return [];
}
return new Uint8Array(key);
}
}

View File

@@ -8,6 +8,7 @@
*/
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { ChromePushSubscription } from "./ChromePushSubscription.sys.mjs";
var isParent =
Services.appinfo.processType === Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT;
@@ -488,96 +489,6 @@ Object.assign(PushServiceContent.prototype, {
},
});
/** `ChromePushSubscription` instances are passed to all subscription callbacks. */
class ChromePushSubscription {
#props;
constructor(props) {
this.#props = props;
}
QueryInterface = ChromeUtils.generateQI(["nsIPushSubscription"]);
/** The URL for sending messages to this subscription. */
get endpoint() {
return this.#props.endpoint;
}
/** The last time a message was sent to this subscription. */
get lastPush() {
return this.#props.lastPush;
}
/** The total number of messages sent to this subscription. */
get pushCount() {
return this.#props.pushCount;
}
/** The number of remaining background messages that can be sent to this
* subscription, or -1 of the subscription is exempt from the quota.
*/
get quota() {
return this.#props.quota;
}
/**
* Indicates whether this subscription was created with the system principal.
* System subscriptions are exempt from the background message quota and
* permission checks.
*/
get isSystemSubscription() {
return !!this.#props.systemRecord;
}
/** The private key used to decrypt incoming push messages, in JWK format */
get p256dhPrivateKey() {
return this.#props.p256dhPrivateKey;
}
/**
* Indicates whether this subscription is subject to the background message
* quota.
*/
quotaApplies() {
return this.quota >= 0;
}
/**
* Indicates whether this subscription exceeded the background message quota,
* or the user revoked the notification permission. The caller must request a
* new subscription to continue receiving push messages.
*/
isExpired() {
return this.quota === 0;
}
/**
* Returns a key for encrypting messages sent to this subscription. JS
* callers receive the key buffer as a return value, while C++ callers
* receive the key size and buffer as out parameters.
*/
getKey(name) {
switch (name) {
case "p256dh":
return this.#getRawKey(this.#props.p256dhKey);
case "auth":
return this.#getRawKey(this.#props.authenticationSecret);
case "appServer":
return this.#getRawKey(this.#props.appServerKey);
}
return [];
}
#getRawKey(key) {
if (!key) {
return [];
}
return new Uint8Array(key);
}
}
// Export the correct implementation depending on whether we're running in
// the parent or content process.
export let Service = isParent ? PushServiceParent : PushServiceContent;

View File

@@ -33,34 +33,6 @@
namespace mozilla::dom {
namespace {
nsresult GetPermissionState(nsIPrincipal* aPrincipal, PermissionState& aState) {
nsCOMPtr<nsIPermissionManager> permManager =
mozilla::components::PermissionManager::Service();
if (!permManager) {
return NS_ERROR_FAILURE;
}
uint32_t permission = nsIPermissionManager::UNKNOWN_ACTION;
nsresult rv = permManager->TestExactPermissionFromPrincipal(
aPrincipal, "desktop-notification"_ns, &permission);
if (NS_WARN_IF(NS_FAILED(rv))) {
return rv;
}
if (permission == nsIPermissionManager::ALLOW_ACTION ||
Preferences::GetBool("dom.push.testing.ignorePermission", false)) {
aState = PermissionState::Granted;
} else if (permission == nsIPermissionManager::DENY_ACTION) {
aState = PermissionState::Denied;
} else {
aState = PermissionState::Prompt;
}
return NS_OK;
}
nsresult GetSubscriptionParams(nsIPushSubscription* aSubscription,
nsAString& aEndpoint,
nsTArray<uint8_t>& aRawP256dhKey,
@@ -91,6 +63,34 @@ nsresult GetSubscriptionParams(nsIPushSubscription* aSubscription,
return NS_OK;
}
namespace {
nsresult GetPermissionState(nsIPrincipal* aPrincipal, PermissionState& aState) {
nsCOMPtr<nsIPermissionManager> permManager =
mozilla::components::PermissionManager::Service();
if (!permManager) {
return NS_ERROR_FAILURE;
}
uint32_t permission = nsIPermissionManager::UNKNOWN_ACTION;
nsresult rv = permManager->TestExactPermissionFromPrincipal(
aPrincipal, "desktop-notification"_ns, &permission);
if (NS_WARN_IF(NS_FAILED(rv))) {
return rv;
}
if (permission == nsIPermissionManager::ALLOW_ACTION ||
Preferences::GetBool("dom.push.testing.ignorePermission", false)) {
aState = PermissionState::Granted;
} else if (permission == nsIPermissionManager::DENY_ACTION) {
aState = PermissionState::Denied;
} else {
aState = PermissionState::Prompt;
}
return NS_OK;
}
class GetSubscriptionResultRunnable final : public WorkerThreadRunnable {
public:
GetSubscriptionResultRunnable(WorkerPrivate* aWorkerPrivate,

View File

@@ -37,6 +37,7 @@
class nsIGlobalObject;
class nsIPrincipal;
class nsIPushSubscription;
namespace mozilla {
class ErrorResult;
@@ -49,6 +50,12 @@ class PushManagerImpl;
struct PushSubscriptionOptionsInit;
class WorkerPrivate;
nsresult GetSubscriptionParams(nsIPushSubscription* aSubscription,
nsAString& aEndpoint,
nsTArray<uint8_t>& aRawP256dhKey,
nsTArray<uint8_t>& aAuthSecret,
nsTArray<uint8_t>& aAppServerKey);
class PushManager final : public nsISupports, public nsWrapperCache {
public:
NS_DECL_CYCLE_COLLECTING_ISUPPORTS

View File

@@ -9,6 +9,7 @@
#include "nsContentUtils.h"
#include "nsCOMPtr.h"
#include "nsICategoryManager.h"
#include "nsIPushService.h"
#include "nsIXULRuntime.h"
#include "nsNetUtil.h"
#include "nsXPCOM.h"
@@ -66,9 +67,11 @@ PushNotifier::NotifyPush(const nsACString& aScope, nsIPrincipal* aPrincipal,
NS_IMETHODIMP
PushNotifier::NotifySubscriptionChange(const nsACString& aScope,
nsIPrincipal* aPrincipal) {
nsIPrincipal* aPrincipal,
nsIPushSubscription* aOldSubscription) {
NS_ENSURE_ARG(aPrincipal);
PushSubscriptionChangeDispatcher dispatcher(aScope, aPrincipal);
PushSubscriptionChangeDispatcher dispatcher(aScope, aPrincipal,
aOldSubscription);
return Dispatch(dispatcher);
}
@@ -311,8 +314,9 @@ bool PushMessageDispatcher::SendToChild(ContentParent* aContentActor) {
}
PushSubscriptionChangeDispatcher::PushSubscriptionChangeDispatcher(
const nsACString& aScope, nsIPrincipal* aPrincipal)
: PushDispatcher(aScope, aPrincipal) {}
const nsACString& aScope, nsIPrincipal* aPrincipal,
nsIPushSubscription* aOldSubscription)
: PushDispatcher(aScope, aPrincipal), mOldSubscription(aOldSubscription) {}
PushSubscriptionChangeDispatcher::~PushSubscriptionChangeDispatcher() = default;
@@ -334,7 +338,8 @@ nsresult PushSubscriptionChangeDispatcher::NotifyWorkers() {
if (NS_WARN_IF(NS_FAILED(rv))) {
return rv;
}
return swm->SendPushSubscriptionChangeEvent(originSuffix, mScope);
return swm->SendPushSubscriptionChangeEvent(originSuffix, mScope,
mOldSubscription);
}
bool PushSubscriptionChangeDispatcher::SendToParent(

View File

@@ -149,13 +149,17 @@ class PushMessageDispatcher final : public PushDispatcher {
class PushSubscriptionChangeDispatcher final : public PushDispatcher {
public:
PushSubscriptionChangeDispatcher(const nsACString& aScope,
nsIPrincipal* aPrincipal);
nsIPrincipal* aPrincipal,
nsIPushSubscription* aOldSubscription);
~PushSubscriptionChangeDispatcher();
nsresult NotifyObservers() override;
nsresult NotifyWorkers() override;
bool SendToParent(ContentChild* aParentActor) override;
bool SendToChild(ContentParent* aContentActor) override;
private:
nsCOMPtr<nsIPushSubscription> mOldSubscription;
};
class PushSubscriptionModifiedDispatcher : public PushDispatcher {

View File

@@ -4,6 +4,7 @@
import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { ChromePushSubscription } from "./ChromePushSubscription.sys.mjs";
const lazy = {};
@@ -661,7 +662,11 @@ export var PushService = {
if (!record) {
return;
}
lazy.gPushNotifier.notifySubscriptionChange(record.scope, record.principal);
lazy.gPushNotifier.notifySubscriptionChange(
record.scope,
record.principal,
new ChromePushSubscription(record.toSubscription())
);
},
/**

View File

@@ -11,6 +11,7 @@ EXTRA_COMPONENTS += [
]
EXTRA_JS_MODULES += [
"ChromePushSubscription.sys.mjs",
"Push.sys.mjs",
"PushBroadcastService.sys.mjs",
"PushComponents.sys.mjs",

View File

@@ -7,6 +7,9 @@
const { MockRegistrar } = ChromeUtils.importESModule(
"resource://testing-common/MockRegistrar.sys.mjs"
);
const { ChromePushSubscription } = ChromeUtils.importESModule(
"resource://gre/modules/ChromePushSubscription.sys.mjs"
);
let pushService = Cc["@mozilla.org/push/Service;1"].getService(
Ci.nsIPushService
@@ -54,7 +57,21 @@ add_test(function test_service_instantiation() {
equal(handlerService.observed[0].data, scope);
// and a subscription change.
pushNotifier.notifySubscriptionChange(scope, principal);
pushNotifier.notifySubscriptionChange(
scope,
principal,
new ChromePushSubscription({
endpoint: "xpcshell",
lastPush: 0,
pushCount: 0,
p256dhKey: [],
p256dhPrivateKey: [],
authenticationSecret: [],
appServerKey: [],
quota: 0,
systemRecord: true,
})
);
equal(handlerService.observed.length, 2);
equal(handlerService.observed[1].topic, pushService.subscriptionChangeTopic);
equal(handlerService.observed[1].subject, principal);

View File

@@ -21,6 +21,7 @@
#include "nsServiceManagerUtils.h"
#include "nsDebug.h"
#include "nsIPermissionManager.h"
#include "nsIPushService.h"
#include "nsXULAppAPI.h"
#include "jsapi.h"
@@ -1113,7 +1114,8 @@ nsresult ServiceWorkerManager::SendPushEvent(
NS_IMETHODIMP
ServiceWorkerManager::SendPushSubscriptionChangeEvent(
const nsACString& aOriginAttributes, const nsACString& aScope) {
const nsACString& aOriginAttributes, const nsACString& aScope,
nsIPushSubscription* aOldSubscription) {
OriginAttributes attrs;
if (!attrs.PopulateFromSuffix(aOriginAttributes)) {
return NS_ERROR_INVALID_ARG;
@@ -1123,7 +1125,8 @@ ServiceWorkerManager::SendPushSubscriptionChangeEvent(
if (!info) {
return NS_ERROR_FAILURE;
}
return info->WorkerPrivate()->SendPushSubscriptionChangeEvent();
return info->WorkerPrivate()->SendPushSubscriptionChangeEvent(
aOldSubscription);
}
nsresult ServiceWorkerManager::SendNotificationEvent(

View File

@@ -12,6 +12,8 @@
#include "js/Exception.h" // JS::ExceptionStack, JS::StealPendingExceptionStack
#include "jsapi.h"
#include "mozilla/dom/PushSubscriptionChangeEvent.h"
#include "mozilla/dom/PushSubscriptionChangeEventBinding.h"
#include "nsCOMPtr.h"
#include "nsContentUtils.h"
#include "nsDebug.h"
@@ -830,12 +832,28 @@ class PushSubscriptionChangeEventOp final : public ExtendableEventOp {
RefPtr<EventTarget> target = aWorkerPrivate->GlobalScope();
ExtendableEventInit init;
ServiceWorkerPushSubscriptionChangeEventOpArgs& args =
mArgs.get_ServiceWorkerPushSubscriptionChangeEventOpArgs();
PushSubscriptionChangeEventInit init;
init.mBubbles = false;
init.mCancelable = false;
RefPtr<ExtendableEvent> event = ExtendableEvent::Constructor(
target, u"pushsubscriptionchange"_ns, init);
if (args.oldSubscription()) {
PushSubscriptionData oldSubscriptionData =
args.oldSubscription().extract();
RefPtr<PushSubscription> oldSubscription = new PushSubscription(
target->GetParentObject(), oldSubscriptionData.endpoint(), u""_ns,
Nullable<EpochTimeStamp>(),
std::move(oldSubscriptionData.rawP256dhKey()),
std::move(oldSubscriptionData.authSecret()),
std::move(oldSubscriptionData.appServerKey()));
init.mOldSubscription = oldSubscription.forget();
}
RefPtr<PushSubscriptionChangeEvent> event =
PushSubscriptionChangeEvent::Constructor(
target, u"pushsubscriptionchange"_ns, init);
event->SetTrusted(true);
nsresult rv = DispatchExtendableEventOnWorkerScope(

View File

@@ -42,7 +42,16 @@ struct ServiceWorkerPushEventOpArgs {
OptionalPushData data;
};
struct ServiceWorkerPushSubscriptionChangeEventOpArgs {};
struct PushSubscriptionData {
nsString endpoint;
uint8_t[] rawP256dhKey;
uint8_t[] authSecret;
uint8_t[] appServerKey;
};
struct ServiceWorkerPushSubscriptionChangeEventOpArgs {
PushSubscriptionData? oldSubscription;
};
struct ServiceWorkerNotificationEventOpArgs {
nsString eventName;

View File

@@ -38,6 +38,7 @@
#include "mozilla/dom/FetchEventOpChild.h"
#include "mozilla/dom/InternalHeaders.h"
#include "mozilla/dom/InternalRequest.h"
#include "mozilla/dom/PushManager.h"
#include "mozilla/dom/ReferrerInfo.h"
#include "mozilla/dom/RemoteType.h"
#include "mozilla/dom/RemoteWorkerControllerChild.h"
@@ -970,12 +971,22 @@ nsresult ServiceWorkerPrivate::SendPushEventInternal(
});
}
nsresult ServiceWorkerPrivate::SendPushSubscriptionChangeEvent() {
nsresult ServiceWorkerPrivate::SendPushSubscriptionChangeEvent(
const RefPtr<nsIPushSubscription>& aOldSubscription) {
AssertIsOnMainThread();
ServiceWorkerPushSubscriptionChangeEventOpArgs args{};
if (aOldSubscription) {
PushSubscriptionData oldSubscription{};
MOZ_TRY(GetSubscriptionParams(aOldSubscription, oldSubscription.endpoint(),
oldSubscription.rawP256dhKey(),
oldSubscription.authSecret(),
oldSubscription.appServerKey()));
args.oldSubscription().emplace(oldSubscription);
}
return ExecServiceWorkerOp(
ServiceWorkerPushSubscriptionChangeEventOpArgs(),
ServiceWorkerLifetimeExtension(FullLifetimeExtension{}),
std::move(args), ServiceWorkerLifetimeExtension(FullLifetimeExtension{}),
[](ServiceWorkerOpResult&& aResult) {
MOZ_ASSERT(aResult.type() == ServiceWorkerOpResult::Tnsresult);
});

View File

@@ -30,6 +30,7 @@
#define NOTIFICATION_CLOSE_EVENT_NAME u"notificationclose"
class nsIInterceptedChannel;
class nsIPushSubscription;
class nsIWorkerDebugger;
namespace mozilla {
@@ -107,7 +108,8 @@ class ServiceWorkerPrivate final : public RemoteWorkerObserver {
const Maybe<nsTArray<uint8_t>>& aData,
RefPtr<ServiceWorkerRegistrationInfo> aRegistration);
nsresult SendPushSubscriptionChangeEvent();
nsresult SendPushSubscriptionChangeEvent(
const RefPtr<nsIPushSubscription>& aOldSubscription);
nsresult SendNotificationEvent(const nsAString& aEventName,
const nsAString& aID, const nsAString& aTitle,

View File

@@ -0,0 +1 @@
prefs: [dom.push.serverURL:wss://web-platform.test:8889/mozilla_push_dummy,dom.push.connection.enabled:true]

View File

@@ -0,0 +1,4 @@
[pushsubscriptionchange.https.any.window-module.html]
[Fire pushsubscriptionchange event when permission is revoked]
expected:
if os == "android": FAIL

View File

@@ -0,0 +1,15 @@
async function postAll(data) {
const clients = await self.clients.matchAll({ includeUncontrolled: true });
for (const client of clients) {
client.postMessage(data);
}
}
onpushsubscriptionchange = ev => {
postAll({
type: ev.type,
constructor: ev.constructor.name,
oldSubscription: ev.oldSubscription?.toJSON(),
newSubscription: ev.newSubscription?.toJSON(),
});
}

View File

@@ -0,0 +1,35 @@
// META: global=window-module
// META: script=/resources/testdriver.js
// META: script=/resources/testdriver-vendor.js
// META: script=/notifications/resources/helpers.js
let registration;
promise_setup(async () => {
await trySettingPermission("granted");
registration = await getActiveServiceWorker("push-sw.js");
});
promise_test(async (t) => {
const promise = new Promise(r => {
navigator.serviceWorker.addEventListener("message", r, { once: true })
});
const subscription = await registration.pushManager.subscribe();
t.add_cleanup(() => subscription.unsubscribe());
// https://w3c.github.io/push-api/#security-and-privacy-considerations
// When a permission is revoked, the user agent MAY fire the "pushsubscriptionchange"
// event for subscriptions created with that permission
//
// But Firefox fires pushsubscriptionchange on permission regrant instead of revocation.
// https://github.com/w3c/push-api/issues/236
await trySettingPermission("prompt");
await trySettingPermission("granted");
const pushSubscriptionChangeEvent = await promise;
assert_equals(pushSubscriptionChangeEvent.data.type, "pushsubscriptionchange");
assert_equals(pushSubscriptionChangeEvent.data.constructor, "PushSubscriptionChangeEvent");
assert_object_equals(pushSubscriptionChangeEvent.data.oldSubscription, subscription.toJSON());
}, "Fire pushsubscriptionchange event when permission is revoked");