Private-App Developer Self-Approves Its Own Permissions to Seize Merchant Data
The Risk
A business that hires an outside developer to build a small add-on for its online store could have that developer quietly hand themselves the keys to everything. The developer asks for extra permissions the store owner never agrees to, then ticks the approval box themselves from a separate free account they control. After that they can read every customer's personal details, see staff and owner logins, and change products and records. The only way the owner can undo it is to delete the add-on completely.
The Vulnerability
The platform lets merchants extend their store with private apps written by in-house developers or outside contractors. When a private app wants more access than it was first installed with, it submits a permission-expansion request, and the merchant is supposed to approve or decline it. That merchant approval is the only control on what a private app can reach after the initial install.
The approval is performed by a single API call (a permission-approval mutation on the per-store API endpoint). The call carries only the application id and the response (approve or decline). It never checks that the account making the approval is the merchant that actually installed the app. Any authenticated store session that holds the application id can approve any private app's pending permissions, including a separate, unrelated store the attacker spins up for free.
The mismatch is easy to see: the attacker's own store cannot even read the target app (the read call returns "failed to get application by id"), yet the same store's session can successfully approve that app's pending permissions. Read and token operations correctly reject the cross-store id; only the approval path forgot the check.
The Attack
The realistic attacker is a rogue or compromised in-house developer or contractor, a normal arrangement for a merchant that outsources add-on development. The developer holds the application id by construction (they built the app) and also controls their own free store on the platform.
Setup (normal, intended onboarding)
Two preconditions exist, both part of ordinary onboarding and neither removable by an outsider:
- The merchant invites the developer into its in-house developer organisation. An outsider cannot self-join.
- The merchant installs the app once for a limited, benign permission. An outsider cannot self-install onto a merchant.
At this point the app is installed with only the narrow read permission the merchant agreed to.
The escalation
From the developer side the rest is fully attacker-driven, with no further merchant action, no social engineering, and no guessing:
- The developer requests additional permissions in the developer portal (including write permissions). These move to a pending state, awaiting merchant approval. The merchant does not approve them.
- The developer opens their own separate store, and from that store's browser session issues the approval call against the target application id. A single approve call grants the entire pending permission set at once.
A minimal version of the self-approval, run from the attacker-controlled store's own origin:
// run from the attacker's OWN store session
// APP_ID is the target app's id, held by its developer
const r = await fetch('/api/graphql', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: 'mutation($a:ID!){ approvePrivateAppScopes(applicationID:$a, response: APPROVED) }',
variables: { a: APP_ID }
})
});
// -> {"data":{"approvePrivateAppScopes":true}} The escalation sticks and the merchant cannot reverse it through the normal interface. After the fact, both Approve and Decline on the merchant's pending request error with "Something went wrong" because the request is already resolved. The new permissions show as granted even though the merchant never approved them. The only recourse is deleting the application.
Turning the permissions into data access
The developer then generates the app's access token (gated by developer-org membership, which the developer has) so the token carries the freshly self-granted permissions, and points it at the victim merchant's store host:
fetch('/api/users', { headers: { Authorization: 'Bearer ' + TOKEN } })
.then(r => r.json())
.then(d => console.table(d.data));
// HTTP 200: the owner/admin account, using a permission the merchant never approved The same token reads customer records and performs writes (create, modify, delete catalog and customer data) for any write permission self-granted in the previous step.
The Impact
The merchant's approval is the only control on what a private app can access after install, and this bypass removes it entirely. With it:
- The developer self-grants any permission: customer personal data, staff and admin user accounts, sales, inventory, plus write permissions.
- A single approval call flips the whole pending permission set to granted; there is no per-permission gate.
- With the resulting token the developer read the victim store's admin user account (HTTP 200) using a permission the merchant never approved, and can read all other store data and tamper with catalog and customer records.
- The token is genuine victim-store authorisation: it returns unauthorized against unrelated stores, confirming the bypass yields real access to the victim merchant, not the attacker's own data.
- The merchant cannot reverse the escalation through the approval interface; the only recourse is deleting the application.
This is a full privilege escalation against a merchant the developer has no authorisation to administer, exposing customer and staff personal data and allowing tampering with live merchant data. The rating is High rather than Critical because turning the permissions into a data read still requires the app token, and token generation is gated by developer-org membership, so the data-theft path is bound to the app's own developer rather than any outsider holding a leaked id.
Remediation
- In the permission-approval call (and the matching install-acceptance call), resolve the application id within the authenticated store context and reject the request when the app's installing store does not match the caller's store.
- Permission approval must be authorised only for the store that installed the app, never for an arbitrary store session that merely knows the application id.
- Apply the same ownership check that the read and token-generation paths already enforce; the gap is isolated to the approval path.
- Give merchants a way to review and revoke individual granted permissions without deleting the entire application.