Client-side storage

Does obfuscation break IndexedDB options?

A browser object store is configured by naming things: which property is the key, whether keys are generated, which index refuses duplicates, whether a write must reach disk before it is acknowledged, which fields must never be written at all, whether the store is wiped at sign-out, and how much space it may take. Some of those are property names in code. Others are strings that name property names, which is a different thing and fails in a different direction. We protected a file that configures a store, renamed the names a group at a time, and read what was left in the profile.

What the sample actually does

The file configures a client-side profile store the way a careful team configures one. The account id is the in-line key, so saving the same profile twice updates one record instead of accumulating two. Key generation is off, because a generated key would defeat that. An index on the email address refuses duplicates. Writes are strict, so an acknowledged write has reached disk. A redactor drops the card number and the national insurance number before anything is stored, because this is a browser profile on somebody else's laptop. The store is cleared at sign-out. And a quota bounds how much of the origin's storage it may consume.

The store wrapper is copied in unprotected, for the usual reason: your bundle is rebuilt when you protect it, the installed package is not. Its defaults are the permissive ones this family ships - no in-line key, generated keys instead, no uniqueness constraint, relaxed durability, no redaction, a store that survives sign-out, and no quota.

Protection alone was applied first on all five presets. All five behaved identically to the unprotected file, so nothing below is caused by protection on its own.

This area was chosen partly because it contains both shapes at once. The key path is an option whose value is the string "accountId", and the record has a property actually called accountId. Those two can move independently, and when they do they fail in opposite directions.

The card number, and a redactor that reported itself installed

The sharpest result in this area came from renaming just two record field names - the card number and the national insurance number - and nothing else.

The redactor is a function the application supplies. It walks the record and replaces those two fields with a marker. It identifies them by comparing the key against a string literal, which is how every redactor of this kind is written. Renaming the properties moved the data; it did not move the string. So the redactor ran, compared "cardNumber" against a property now called something else, matched nothing, and copied the record through untouched.

The measured lines are worth reading together. card-readable-in-browser-profile went from false to true, the stored record came back containing "_0x1":"4242424242424242" in plain text, and on the same line the application's own telemetry still read redactor=caller-supplied(drops the sensitive fields).

That reading is accurate. The redactor is present, it is the application's own, and it is being called on every write. It simply has nothing to match any more. A question of the form "is redaction enabled?" returns a true answer to the wrong question - and unlike most findings in this series, the damage is data at rest. The card number is in a file on the customer's disk, readable by anything that can read the browser profile, and it stays there.

A guard that fails loudly is only loud if it is still reached

The key path arm shows the other direction, and then shows how the loud version gets switched off.

Renaming the key-path option alone reverted it to no in-line key. Key generation was pinned off, so the store had neither a key path nor a generator, and every write returned a data error. Nothing was saved at all. That is a total, immediate, unmissable outage - the kind an auditor implicitly relies on, and the reason people assume this class of problem would be noticed.

Renaming the key path and the key-generation flag together - which is what one pattern over an options object does - produced no outage. Key generation reverted to on, the store issued generated keys, and writes succeeded. The profile edit then collided with the email uniqueness index and was rejected, so the user's change was silently discarded and the stored record kept the old display name. With the whole options group renamed, the same edit produced two rows for one account and two accounts sharing one email address.

The half that would have screamed is disabled by the half that fails quietly. This is the same shape found in the auto-update area a pass earlier, in a completely different library, and it is the reason "if this broke we would know" is only sound when the thing that would tell you is not downstream of the thing that broke.

The rest of the arms

The uniqueness index reverted to no constraint, and a second signup claiming an email already in use was accepted: two-accounts-share-one-email went from false to true. For a store that backs an offline-first sign-in flow, that is two profiles that will fight over the same identity on the next sync.

Durability reverted from strict to relaxed, and a write the application had already acknowledged was lost when the tab was killed. The row count after the crash went from one to zero. The application reported success; the data was never there.

Clear-on-sign-out reverted to off, and the profile survived the sign-out on a shared machine, leaving one row for the next person to use the laptop. The quota reverted to unbounded, and a bulk write that should have been refused at thirty-three of forty records accepted all forty. An unbounded origin store is how a browser is provoked into evicting the entire database, including whatever queue you were relying on.

The record's field names as a whole group - account id, email, display name and the two sensitive fields together - failed closed rather than open, because the key path's literal string stopped matching and every write became a data error. That is the arm that hides the card leak: including the key field turns the group loud, and the quiet result only appears when the group is re-run without it. An arm that crashes is not an arm that is safe.

One arm measured no change: the transaction mode, pinned deliberately to a value identical to the library default as a control.

Why this area is worse than a server-side equivalent

Two things make client-side storage a harder case than the same mistake on a server. The first is that the data is already on hardware you do not control, so a redaction failure is not a leak waiting to happen - it is a leak that has happened, to every user who ran that build, and it persists until something clears it.

The second is that the browser is the one component you cannot patch retroactively. A server-side fix takes effect on the next request. A client-side one takes effect when the user next loads your bundle, and the records written by the previous build are still sitting in the profile with the card number in them. Fixing the code does not clean the store; you need a migration that finds and removes what the broken build wrote, and it has to identify those records under whatever names they were written with.

That is the practical reason to put a browser store's options and record shape outside the rename scope rather than relying on catching the problem later. The blast radius of this particular mistake does not shrink when you deploy the fix.

What to do about it

The mechanism is not specific to storage and it is not a defect in the obfuscator. Member renaming rewrites property names inside the code it is given. An installed store wrapper is not inside that code, and neither is the browser's own object store, so a renamed name is one they have never heard of.

Scope RenameMembers with a MemberRegexp that excludes both the store's options object and the shape of every record you put in it. Those two belong together: a key path is a string that names a record property, so excluding one without the other leaves them free to drift apart, which is precisely the failure above.

Write the redactor as an allowlist, not a denylist. A function that copies the fields it recognises fails closed when a name moves - the sensitive field is dropped because it was not recognised. A function that removes the fields it recognises fails open, and that is the arm that put a card number in the profile. This is the second measurement in this series to reach the same conclusion in a different library, and it is the single most portable piece of advice in the whole area.

Then verify behaviourally rather than by configuration. Write one record containing a canary value that must never be stored, read the record back out of the store, and assert the canary is absent. That check does not care what anything is called, it does not care which guard was supposed to catch it, and it is the only one in this article that survives every arm.

Frequently asked questions

Does protecting my JavaScript break IndexedDB 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 here required member renaming pointed at property names.

What was the worst result?

Renaming two record field names left a card number stored in the browser profile in plain text, while the application's own telemetry still reported its redactor as installed and running. The redactor was running - it identifies fields by string literal, and the strings no longer matched anything.

Why did the redactor keep reporting itself as present?

Because it was present. It is the application's own function, it was called on every write, and it did exactly what it was written to do. Renaming moved the data out from under a comparison against a string literal. "Is redaction enabled?" was a true answer to the wrong question.

Renaming the key path broke everything immediately. Isn't that safe?

Only on its own. Renaming the key path and the key-generation flag together - which is what one pattern over an options object does - produced no outage at all: generated keys took over and writes succeeded, silently duplicating records. The loud half was switched off by the quiet half.

Is a denylist redactor really worse than an allowlist?

Measurably, yes. A denylist removes the fields it recognises, so a moved name is not recognised and the value is kept. An allowlist copies the fields it recognises, so a moved name is not recognised and the value is dropped. Same edit, opposite outcome.

Why is client-side storage worse than the same mistake on a server?

Because the data is already on hardware you do not control and it persists. A server fix applies on the next request; a browser fix applies when the user next loads your bundle, and everything the broken build wrote is still in the profile. You need a migration to clean it, not just a deploy.

What is the smallest change that prevents all of this?

Scope RenameMembers to exclude both the store's options object and the shape of the records you put in it, write the redactor as an allowlist, and add one behavioural check: store a record containing a canary that must never persist, read it back, and assert the canary is gone.

Related reading