Privileged Access

Does obfuscation break support impersonation controls?

Impersonation - the "view as this customer" button in your admin panel - is the most privileged thing most applications can do, and it is usually fenced by several independent-looking controls at once: a read-only flag, a scope allowlist, a forbidden-action denylist, a time limit, a case number. This area was built to test whether that redundancy buys what teams think it buys. It does not, and the way it fails is the cleanest instance of the pattern this series has measured.

What the sample actually does

The file configures support impersonation the way a careful team configures it. The session is read-only. It expires after fifteen minutes. It requires a case number to start. It grants two scopes, orders and billing. It names two forbidden actions, exporting all customers and deleting the account. It notifies the customer that it happened. And the application supplies its own audit sink that appends to a tamper-evident trail.

Six actions exercise it, each shaped so one guard decides it. Reading the customer's orders must succeed in every arm. Changing the customer's email address - the account-takeover move - is a write inside a granted scope, so only the read-only rule stops it. Reading another team's notes is a read, not forbidden, and simply out of scope. An action from a tab left open since morning is stopped only by the time limit.

And then the full customer export, which is deliberately covered three times over: it is a write, so the read-only rule reaches it; its scope is not granted, so the allowlist reaches it; and it is named in the denylist, so that reaches it too. Three controls, one action, on purpose.

Before any protected arm was read, each option was deleted from the sample's own configuration and the output compared. Every option moved the result except the one pinned equal to the library's default. The impersonation helper is copied in unprotected, as an installed package would be.

Protection alone was applied first, on five presets. All five behaved identically to the unprotected file.

Three guards, and the export still refused

Renaming the read-only option alone reverted the session to writable. The email change went from refused(read-only-session) to PERFORMED(performed) - a genuine account-takeover primitive, and a real finding on its own. But the export was still refused, now reading refused(scope-not-granted(admin)). The allowlist caught what the read-only flag no longer did.

Renaming the read-only option together with the denylist produced the same thing: the export still refused, still scope-not-granted(admin). Two of the three controls gone, and the outcome for the export is unchanged.

Renaming the read-only option together with the allowlist: the export was still refused, this time refused(action-forbidden(export-all-customers)). The denylist caught it.

Every one of those arms is, on the export specifically, a finding of the form "a value changed and the reported reason changed, but nothing was actually permitted". That is the finding a reviewer closes as cosmetic. It is the signature of this failure shape, and closing it is the mistake.

One pattern, all three, and the database walks

Renaming all three together - which is one ordinary pattern over one options object - produced export-every-customer=PERFORMED(performed) on both output targets.

The full customer database was exported by a support agent inside an impersonated session, and the audit trail recorded it as a fourth ordinary line: agent-42 -> cust-9001 : export-all-customers. Nothing errored. Nothing was flagged. The action was written down accurately, in a trail nobody reads until something else prompts them to.

The three controls were not redundant in any way that mattered. They were three keys on one object in one file, and redundancy of that kind is a property of where the controls live, not of how many there are. A second layer only survives what the first layer did not if the transformation cannot reach it - which in practice means server-side, in a different process, behind an interface that is not a property name.

This is now the third area in three passes where deliberately independent controls fell to a single pattern, and it is the sharpest because there were three of them and each one alone held the line. The lesson is uncomfortable for defence-in-depth arguments: counting layers tells you nothing unless you can say what would have to go wrong for each layer to fail, and here the answer is identical for all three.

The quiet ones

Renaming the time limit reverted it to the library's default of never expiring, and the action from a tab left open five hours earlier was performed. Impersonation sessions that do not end are how a temporary grant becomes a standing one, and the audit trail shows an agent doing their job.

Renaming the case-number requirement let a session start with no ticket at all: start-without-case-number=refused(no-ticket-number) became STARTED(started). That removes the only link between an impersonation and a reason for it, which is exactly what an after-the-fact review is built on.

Renaming the notify option is the quietest arm in the file. customer-notified=true became false, and nothing else changed at all - every action still allowed, still refused, still audited, exactly as before. The only difference is that the person whose account was entered is no longer told. In several jurisdictions that notification is the control, and it is the one whose loss produces no operational signal whatsoever.

Renaming the audit sink substituted the library's builtin, an in-memory buffer, and the trail went from one line to (EMPTY). Impersonation continued to work identically and stopped being recorded anywhere. For a control that exists almost entirely to be evidenced, that is the whole loss.

Renaming the audit entry's own field names produced the loud version: trail-entries=undefined -> undefined : undefined, with the readback reporting actor=MISSING onBehalfOf=MISSING action=MISSING. The trail is still written, still the right length, and says nothing. Anyone looking at it notices immediately - which makes it the least dangerous arm in the area.

What to do about it

Scope the rename. RenameMembers takes a MemberRegexp, and the option keys and entry fields of installed dependencies belong outside it.

Then move at least one of your layers somewhere the transformation cannot reach. If the read-only rule, the scope allowlist and the denylist are all keys on one client-side options object, they are one control wearing three hats. Enforcing scope on the server that serves the export is a different kind of layer, and it is the only kind that survives this.

Probe rather than review, and probe the thing you believe is impossible. Attempt the export from an impersonated session in a staging environment and require a refusal. Assert the audit trail gained a line with a readable actor. Start a session without a case number and require it to fail. Let a session idle past its limit and require the next action to be refused.

And when a review turns up "the reported reason changed but the action was still refused", do not close it. That is what two of the three arms in this area looked like individually, and the third one was the customer database.

Frequently asked questions

Does protecting my JavaScript break impersonation controls 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?

A full customer-database export performed from inside an impersonated support session. It was guarded three separate ways and each guard lost alone still refused it; one pattern over the options object reached all three.

Doesn't defence in depth protect against exactly this?

Only when the layers live somewhere different. Here the read-only flag, the scope allowlist and the forbidden-action denylist were three keys on one object in one file, so a single rename pattern reached all three. Counting layers is not the same as separating them.

What did the individual findings look like?

Cosmetic. Each guard lost alone changed the reported refusal reason - read-only-session became scope-not-granted, then action-forbidden - while the export stayed refused. That is the finding a reviewer closes, and it is the signature of this shape.

Which arm produces no operational signal at all?

Renaming the customer-notification option. Every action was still allowed or refused identically and the audit trail was unchanged; the only difference is that the customer whose account was entered is never told.

What happens to the audit trail?

Two different ways. Renaming the audit sink replaces your tamper-evident trail with the library's in-memory buffer, and the trail reads empty. Renaming the entry's own field names leaves the trail the right length with every field undefined, which is loud enough to notice.

What is the smallest change that prevents all of this?

Scope RenameMembers with a MemberRegexp that excludes installed dependencies' option and entry field names, then enforce at least one of the three controls server-side and probe the export from a real impersonated session.

Related reading