Notifications and delivery

Does obfuscation break push notification options?

Web push is configured by narrowing defaults that were chosen for durability rather than for secrecy: how long the push service should keep retrying, a topic that lets a later message replace an undelivered one, the encryption scheme, the identity of the sender, and a rule about what a notification body may contain given that it is rendered on a locked screen. Those are property names on an options object, read by a client that was installed rather than built. We protected a file that configures one and renamed the names a group at a time.

What the sample actually does

The file sends three kinds of push. A login code with a ten-minute lifetime and a topic, so a later code replaces an earlier undelivered one. A run of twelve order-status updates sharing one topic, so a phone that has been in a tunnel shows one notification rather than twelve. And a receipt, whose body is built from real record fields and whose contents are trimmed by a rule the application supplies, because a notification body is rendered on a locked screen to whoever is holding the phone.

Each guard gets a scenario only it decides. The redaction scenario uses a message that is neither collapsed nor expired. The collapsing scenario uses messages with nothing sensitive in them. The expiry scenario uses a single message with no competing topic.

The client is copied in unprotected. Its defaults are the ones the specification and this family of libraries choose: keep retrying for four weeks, no topic and therefore no collapsing, the legacy encryption scheme, no sender identity, and send the payload exactly as given.

Protection alone was applied first on five presets covering both output targets, the gate profile and the compressed profile. All five behaved identically to the unprotected file.

A full card number on a locked screen

The application supplies a rule that masks all but the last four digits of a card number and trims an email address to its first character and domain. Renaming the option that carries it replaced it with the client's builtin, which is to send the payload as given.

The delivered notification body went from a masked email and four digits to the customer's full address and the complete sixteen-digit card number. The run's summary line for "card on lock screen" went from false to true, and the reported filter moved from caller-supplied to the library's builtin.

The rule is still in the bundle. It is still correct. It is bound to the options object under a name the client no longer reads, so the client uses its own behaviour instead - which is not to filter at all. This is the fourth library in this pass in which a caller-supplied guard was downgraded rather than removed, and the failure is visible only to whoever is looking at the phone.

It is worth being clear about which system this damages. The push service delivered exactly what it was given, the phone rendered exactly what it received, and the application's own logs record a receipt notification being sent. Nothing anywhere in that chain is in a position to observe that the body contained a card number.

A ten-minute login code, delivered sixteen hours later

Renaming the lifetime option reverted it from ten minutes to the specification's own generous default of four weeks. A phone that was switched off overnight came back sixteen hours later and was handed a login code that expired before breakfast.

The code itself has almost certainly stopped working server-side, so the practical damage is a confusing notification rather than an authentication bypass. But it is a notification that says "your login code is" followed by six digits, arriving unprompted on a lock screen the following morning, which is indistinguishable from what a phishing attempt looks like and trains users to expect exactly that. And the same option governs every other short-lived push you send.

The collapse topic sits beside it. Renaming that option reverted it to no topic and therefore no collapsing: twelve order updates that should have shown as one showed as twelve. The reported topic moved from the order identifier to "none - no collapsing". Nothing failed, twelve correct notifications were delivered accurately, and the user turns notifications off for your application that afternoon. That is a retention problem rather than a security one, and it is the arm most likely to be reported as a product bug and never traced to a build step.

The two arms that failed loudly, and why that is the good case

Renaming the sender identity reverted it to none, and the push service refused the request outright with a 403. Renaming the encryption scheme reverted it to the legacy one, which the subscription in the sample does not support, and the request was refused with a 400.

Both are total: no push is delivered at all. The reason line names the cause precisely - the sender identity is missing, the content encoding is unsupported - and either one is discovered by the first person who tests a notification after the build.

That is the outcome you want from a broken configuration, and it is worth noticing how the loud and silent arms are distributed here. The options that decide whether the push is ACCEPTED fail closed and announce themselves. The options that decide what the push CONTAINS, and how long it lives, and whether it replaces another, fail open and say nothing. The security-relevant half of this area is the quiet half, which is the same distribution this series has measured in every configuration surface it has looked at.

The arms that rename the subscription's own fields, and the result fields the client returns, both threw a TypeError immediately - loud again. One option was pinned to a value identical to the library default as a control and measured no change, as predicted, for the tenth consecutive pass.

Why the defaults are shaped this way

It is tempting to read a four-week retry default, an absent topic and an unfiltered payload as carelessness. They are not. A push service that dropped messages quickly would be useless for the case it was designed for, which is a device that is off for a while. A client that collapsed messages by default would silently discard notifications the developer expected to be delivered. And a library cannot know which parts of your payload are sensitive.

Every one of those defaults is the correct choice for a caller who has configured nothing, and every one of them is the wrong choice for your application. That gap is precisely what the configuration exists to close, and it is precisely what is lost when the names that carry the configuration stop being the names the library reads.

This is the general shape of the whole problem, and it is why the damage is so consistently silent. Nothing is broken when an option is lost. The system reverts to a coherent, tested, documented behaviour - just not yours.

What to do about it

The mechanism is not specific to push and it is not a defect in the obfuscator. Member renaming rewrites property names inside the code it is given. An installed push client is not inside that code, so a renamed option name is one it has never heard of, and it applies its documented default.

Scope the renaming. RenameMembers takes a MemberRegexp, and the push client's options object belongs outside it, along with the subscription object the browser handed you - that one is not yours to rename at all, and in this measurement renaming it threw. If you would rather not maintain an exclusion list, build the options with quoted string keys and read them with bracket access using literal strings you wrote.

Then assert from the far side: ask the client what lifetime, topic, encoding and sender identity it is going to use, and fail the send path if they are not what you configured. Read them from the client rather than from your own object.

For the payload rule, which a configuration assertion cannot cover, the check is a content one and it is short: before handing a body to the client, assert that it does not match the patterns you exist to mask - a run of sixteen digits, a bare email address. Put that assertion on the path the notification actually takes, not on the filter function, because in the dangerous arm the filter function is perfectly healthy and simply never runs.

Frequently asked questions

Does protecting my JavaScript break push notifications on its own?

Not in this measurement. The sample was protected on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. Every result required member renaming pointed at property names.

What was the worst result?

Renaming the option that carries the payload rule. A receipt notification that should have shown a masked email and four card digits was delivered with the customer's full email address and the complete card number, rendered on a locked screen.

Was the masking rule deleted?

No. It is still in the bundle and still correct. The client looks for it under a name that no longer exists and uses its own behaviour instead, which is to send the payload exactly as given. The guard was downgraded rather than switched off.

How can a login code arrive sixteen hours later?

The lifetime option reverted to the specification's default of four weeks, so the push service kept the message and delivered it when the device came back. The code has usually expired server-side by then, so the damage is an unprompted code notification that looks exactly like a phishing attempt.

Why did twelve notifications appear instead of one?

The collapse topic reverted to none. A topic is what lets a later message replace an earlier undelivered one at the push service. Without it every queued update is delivered, correctly and individually, which users experience as spam.

Which arms fail loudly?

The ones that decide whether the push is accepted at all - the sender identity and the encryption scheme - both produce an outright rejection from the push service, with a reason that names the cause. The options that decide what the push contains fail silently, which is the wrong way round.

What check covers the payload case?

Assert on the body itself immediately before it is handed to the client: no run of sixteen digits, no bare email address. Do not assert on the filter function, because in the dangerous arm the filter is healthy and is simply never called.

Related reading