Offline and caching

Does obfuscation break service worker caching?

A service worker's runtime caching is configured by narrowing defaults that are deliberately generous: which strategy answers a request, which bucket the response lands in, how long an entry stays usable, how many entries may accumulate, which status codes count as cacheable, and a rule about which responses belong to one person and must never be stored at all. Those are property names on an options object, and the caching helper reading them was installed rather than built. We protected a file that configures one, renamed the names a group at a time, and looked at what the next visitor was served.

What the sample actually does

The file configures a runtime cache for an application's own API the way the documentation tells you to. The strategy is network-first, so a fresh answer wins when the network is available. Entries live in their own named bucket rather than the shared default. They expire after five minutes. The bucket holds fifty entries. Only a plain 200 is cacheable. And a rule supplied by the application refuses to store anything that varies on the caller's identity or carries a session cookie, because such a response belongs to exactly one person.

Each of those is then given a scenario only it can decide. The private response is a 200, so the status list is not what stops it. The opaque cross-origin response carries no identity headers, so the application's own rule is not what stops it. The staleness scenario uses a response both of them happily accept. Without that separation the first guard to refuse hides every guard behind it, and an arm that is never reached reports no change.

The caching helper is copied in unprotected, because an installed package is not rebuilt when you protect your bundle. Its defaults are the permissive ones this family ships: cache-first, one shared bucket called runtime, entries that never expire, an unbounded bucket, opaque responses treated as cacheable, and a cacheability test that looks at the status line and nothing else.

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. Nothing below is caused by protection on its own.

One renamed option served one customer's account page to the next visitor

The sharpest result in this area comes from renaming the option that carries the application's own cacheability rule. The rule is still in the bundle. It is still a function. It is simply no longer the function the helper calls, because the helper looks for a name that no longer exists and falls back to its builtin, which asks only whether the status is in the cacheable list.

A signed-in customer loaded their account summary: a 200, marked to vary on the cookie, carrying a rotated session cookie. Under the application's rule that response is refused outright. Under the builtin it is a 200, so it was stored. The next request came from a visitor with no session at all, and it was answered from the cache with the first customer's email address and the last four digits of their card.

The line that reports which rule is in force did change - it moved from caller-supplied to the library's builtin - which is the honest reading and the one piece of evidence available. But the application's own summary of whether it is safe would not have moved, because nothing about the code that computes safety changed. It was simply never asked.

This is the fourth distinct area in this series where a caller-supplied implementation of a guard was replaced by a weaker builtin rather than switched off. The pattern is worth naming: when a library accepts a function through an option, losing that option name does not disable the feature. It downgrades it to whatever the library would have done for a caller who never configured anything, and the downgrade looks exactly like the safe state from the outside.

A revoked administrator who is still an administrator

Renaming the expiry option reverted it to the default, which is that entries never expire. A permissions response saying the user is an administrator was cached, the user was then demoted server-side, and an hour later the application was still being told the user is an administrator - served from a cache entry that under the configured policy would have been dead fifty-five minutes earlier.

The reported maximum age moved from three hundred seconds to zero, and zero here means forever rather than immediately, which is worth reading twice. That inversion is common in cache configuration and it is exactly the kind of default a reader skims past.

A cache lifetime is the upper bound on how long a revocation takes to bite. That sentence is the whole security argument for the option, and it is invisible in every functional test, because a functional test signs in as somebody whose permissions are not changing.

The security patch that never reached the browser

Renaming the strategy option reverted it from network-first to cache-first. A patched application bundle was published; the client that had the old one cached kept serving the old one, and the run's own summary line went from false to true on "running vulnerable bundle".

This is the arm most likely to be reported as a caching bug rather than a build problem, because that is exactly what it looks like. Users on a stale bundle, a hard refresh fixes it, and the fix appears to be a cache-busting change. The actual cause is that the strategy the application configured is not the strategy the helper is running.

The bucket-name arm belongs beside it. Renaming that option moved every entry from the application's own named bucket into the shared default one. Nothing breaks, and that is the problem: a bucket that was versioned so it could be invalidated on deploy is now the same bucket everything else uses, so the cleanup routine that deletes old versions has nothing to match and the eviction logic that used to bound one API's storage now competes with every other cached thing on the origin.

The arm where a lost guard was covered by a guard that survived

Renaming the list of cacheable status codes reverted it to a list that includes zero, and zero is the status of an opaque cross-origin response - a response whose body and headers your code cannot read, so it cannot tell a successful script from a captive-portal login page.

And yet the opaque response was still refused. The reported policy changed, and nothing else did, because the application's own cacheability rule requires a status of exactly 200 and it was still in force in that arm. One guard covered for the loss of another.

That is a comfortable result and it is worth being precise about why it is not reassuring. The cover only holds while the covering guard is present. In the arm that renamed the application's rule, and in the arm that renamed the whole options block, the opaque response and the private response both went into the cache. Overlapping guards make a system resilient to losing one name at a time. They do nothing for the case where a rename pattern matches a family of names, which is the normal case, because option keys on one object tend to look alike.

The remaining arms behaved as the shape predicts. The entry cap reverted to unbounded and sixty entries accumulated where fifty was the ceiling - a storage-pressure problem rather than a correctness one, and the one arm here that a dashboard would show you. The arms that rename the fields of the response object read undefined for the body and served it. And one option was pinned to a value identical to the library default as a control; it measured no change, as predicted, for the tenth consecutive pass.

What to do about it

Member renaming rewrites property names inside the code it is given. A caching helper installed from a registry is not inside that code, so a renamed option name is one it has never heard of, and it applies its documented default. The defaults in this area are unusually permissive because a caching library has to work for the person who configured nothing.

Scope the renaming. RenameMembers takes a MemberRegexp, and the options object handed to the caching helper belongs outside it. If you would rather not maintain an exclusion list, build that object with quoted string keys and read it with bracket access using literal strings you wrote.

Then make the cache assert its own policy. Ask the helper which strategy, bucket name and maximum age it is running under, and fail the service worker's install step if they are not what you configured. That catches every arm in this article except the two that decide what may be stored.

For those two the check has to be behavioural, and it is the single most valuable test in this area: request an authenticated endpoint, then request the same URL with no credentials at all, and assert that the second request was not answered from the cache. Ten lines, and it is the difference between finding this in a test run and finding it in a support ticket that begins with a customer seeing somebody else's name.

Frequently asked questions

Does protecting my JavaScript break service worker caching 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 application's own cacheability rule. A signed-in customer's account response was stored in a shared cache, and the next visitor - who had no session at all - was served it, including their email address and the last four digits of their card.

Was the rule deleted from the bundle?

No. It is still there and still correct. The helper looks for it under a name that no longer exists, does not find it, and falls back to its own builtin, which only checks the status code. The feature was downgraded rather than switched off, which is why it still looks configured.

How would a revoked permission survive obfuscation?

The expiry option reverted to the library default, which is that entries never expire. A cached response saying the user is an administrator was still being served an hour after the user was demoted. A cache lifetime is the upper bound on how long a revocation takes to take effect.

Why did the opaque-response arm show no damage?

Because a second guard that was still in force required a status of exactly 200. Overlapping guards protect you from losing one name at a time. They do not help when a rename pattern matches a family of similar-looking option keys, which is the normal case.

Does this affect precached assets too?

The measurement covered runtime caching. The same mechanism applies anywhere an installed helper reads configuration by property name from an object your protected code builds, which includes precache manifests and expiration plugins.

What is the one test that catches the dangerous arms?

Fetch an authenticated endpoint, then fetch the same URL with no credentials, and assert the second response did not come from the cache. Start-up assertions catch the strategy, bucket and expiry arms; only a behavioural check catches the ones that decide what may be stored.

Related reading