The Invite That Made an Owner: Reading What the Form Posts
The challenge
Vantage found an Owner in its workspace that nobody remembers creating. The account belongs to a real colleague who was invited in an ordinary batch of four, and the team lead who sent that batch is certain they never touched a role. They are telling the truth: the invite screen offers no role control at all, and it says in plain text that everyone invited from it joins as a Member. But what a page shows and what a page posts are two different documents, and the server believed the second one. This is the screen exactly as it was submitted, captured by the support tool. Read the markup and name the address that arrived as Owner.
What you'll learn
- Compare what a page renders against what its form would post
- Read two positional arrays as co-indexed data
- Explain why a hidden input is neither secret nor read-only
- Recognise client-supplied authorisation as the underlying flaw
- Describe why this escalation leaves a clean-looking audit trail
Skills tested
Prerequisites
- What an HTML form and a hidden input are
- Reading a comma-separated list
How it works
Every form is two documents. There is the one the user reads, and there is the set of name and value pairs the browser will post. They are supposed to agree. When they do not, the server almost always believes the second one, because the second one is the only one it ever sees.
Preview here is internally consistent: four invitees, the word Member beside each, and a sentence promising that this screen grants nothing else. There is no role control to misuse, which is exactly why the team lead's account of events is truthful and still wrong.
View source carries five fields the page never drew. Two of them are lists. invitees is four addresses in order and roles is member,member,owner,member. The comment above them states the contract: roles[] is positional and must stay in sync with invitees[]. Line them up and the third entry of each pairs [email protected] with owner.
The flaw is not that a field was hidden. display:none and type="hidden" are layout instructions, not access control. The value still goes over the wire, and anyone with developer tools, an intercepting proxy or a single curl command can change it. The flaw is that the server accepted a role from the client at all, so the promise printed on the page was enforced in exactly one place: the page.
Two things make this pattern worse than it looks. Parallel arrays joined by index are brittle, and one reordering anywhere in the stack silently reassigns every role. And the aftermath is invisible: the invite, the acceptance and the new Owner all read as legitimate in the audit log, because from the server's point of view an authorised user asked for precisely this.
Common mistakes
- Answering [email protected]. The
ownerentry is third, not fourth. Count the positions rather than assuming it is the last one. - Answering with the role instead of the address. The question asks who, and the answer label says so.
- Trusting the rendered rows. All four say Member, which is why the visible page is not evidence.
- Calling the hidden attribute the vulnerability. Hiding a field changes nothing about whether the browser posts it.
- Assuming a privileged account did this. A client-supplied role needs no privilege, only a request.
How to defend against it
Decide roles on the server, from who is asking rather than from what they sent.
- Drop
rolesfrom the request entirely. If the screen can only create Members, the handler should assign Member and ignore any role in the body. - Where a role genuinely can be chosen, re-check on the server that the caller is allowed to grant it, and that the role is in the permitted set for that plan and organisation.
- Bind the request to the organisation from the session, not from an
orgfield the client controls. - Replace the parallel arrays with a list of objects, one address and one role per entry, so nothing depends on position.
- Alert on any Owner grant that did not come from the role management screen. This escalation is silent precisely because every individual record looks normal.