August 22, 2026
A 400 Is Not a Dead End — My First Bug Bounty (€250)
I went hunting for SSTI. I found nothing. That “nothing” paid €250.

By HariHax
5 min read
Hello hackers,
This is my first bounty write-up, so let me start with the embarrassing part: the bug I found is not the bug I was looking for.
The target is a multi-tenant SaaS platform for e-commerce feed management. Every customer company gets its own tenant, and no tenant should ever see another one's data. Let's call it app.redacted.com.
Let's go.
Part 1: The hunt that went nowhere
I was chasing SSTI.
The platform lets you build automation rules — "if item matches X, then set title to Y" — and that set to value field is user-controlled text that the server later runs against your whole product feed. Perfect SSTI surface, right?
So I threw everything at it:
{{7*7}} → {{7*7}}
<%= 7*7 %> → <%= 7*7 %>
${7*7} → ${7*7}
[title] → [title]{{7*7}} → {{7*7}}
<%= 7*7 %> → <%= 7*7 %>
${7*7} → ${7*7}
[title] → [title]Byte-identical. Every single one.
And no, the engine wasn't just ignoring me — I checked /preview and /counts and confirmed the server really was transforming items. It ran my input. It just didn't care about it.
Dead end. No SSTI. 😩
But before closing the tab, I went back to read the JavaScript bundle — just to understand how these rules were built. And there it was:
{
action: "create_image_...",
value: {
resource_id: <id>,
entity_path: "companies/" + companyId // 👀
}
}{
action: "create_image_...",
value: {
resource_id: <id>,
entity_path: "companies/" + companyId // 👀
}
}A server-side path. Built in the browser. From a variable I control.
Okay. Now I'm interested.
Part 2: Traversal works! …and it's completely useless
The parameter showed up in API calls as relative_entity_path. Name like that, you know what I'm typing next.
I made two companies I owned — A and B — and tried it on a limits endpoint:
- A, own path
projects/396367/master_rules→200 - B, same path, own company →
200 - B,
relative_entity_path=../115374/projects/...→200← ../ accepted! - B,
limitsunder A's company in the URL →403
Traversal accepted. No normalisation, nothing.
Time to report? NO.
Because I actually looked at what came back — and limits returns static plan config. number_of_resources: 100, max_sections: 10. The same bytes for every user on the platform, no matter what path you send.
Traversal that leaks public constants isn't a bug. It's a party trick.
So I wrote myself a note instead:
The path isn't validated. If any endpoint uses this parameter to read a real resource, that's cross-tenant access.
That note is the whole finding. I just needed to find the endpoint.
Part 3: The 400 that told me everything
The endpoint that returns real content is /body — full rule definitions, every condition and value.
I pointed it at my own rule and started guessing path formats:
{"error": "resource_wrong_entity_path"}{"error": "resource_wrong_entity_path"}400. Again. And again. And again.
Most people see a wall here. But read that error properly:
The server didn't say forbidden. It didn't say not found. It said the path was wrong — meaning it was resolving a path, it had an opinion about the correct shape, and I just hadn't guessed it yet.
A 403 means stop. A 400 that names its complaint means keep going.
The shape turned out to be projects/{project_id}/master_rules/{rule_group_id} — sitting right there in responses I already had. 🤦
Part 4: Proving the lock actually works before picking it
Quick but important step. Two companies, no shared user, no invite, no shared project:
- A (victim) —
115374, owns rule12225249 - B (attacker) —
115375, owns nothing in A
Ask for the victim's company directly:
GET /api/companies/115374/resources/rules/12225249/bodyGET /api/companies/115374/resources/rules/12225249/body→ 403 ✅
Own company, victim's resource ID, no traversal:
GET /api/companies/115375/resources/rules/12225249/body
?relative_entity_path=projects/396367/master_rules/28132172GET /api/companies/115375/resources/rules/12225249/body
?relative_entity_path=projects/396367/master_rules/28132172→ 400 resource_wrong_entity_path ✅
Both checks work. A plain ID swap gets refused. This is not a lazy endpoint.
Part 5: Booom 💥
Now add exactly one ../. Company in the URL stays mine, so authorisation passes:
GET /api/companies/115375/resources/rules/12225249/body?format=0
&relative_entity_path=../115374/projects/396367/master_rules/28132172
HTTP/2 200
{"sections":[{"actions":[
{"action":"set","field":"title","value":"VICTIM-CONFIDENTIAL-VALUE"}
]}]}GET /api/companies/115375/resources/rules/12225249/body?format=0
&relative_entity_path=../115374/projects/396367/master_rules/28132172
HTTP/2 200
{"sections":[{"actions":[
{"action":"set","field":"title","value":"VICTIM-CONFIDENTIAL-VALUE"}
]}]}Another company's private rule. Returned to a total stranger. 🎉
And it wasn't just one route:
/body→ the full rule definition/meta→ resource name and the victim's internal user ID
Then I tried it against a completely different resource type — image templates — and it behaved the same way. Better, in fact: there /versions also returned 200, handing over the victim's version history and creator name, even though that same sub-route is 403-gated on rules.
Two different resource types, one broken resolver. That's what told me the flaw lives in the shared path resolver rather than in any single endpoint — a route-level fix would have missed half of it.
Why it happened
One line:
The authorisation check and the object lookup were reading two different inputs.
- Auth reads
company_idfrom the URL → correctly blocks cross-tenant - The lookup reads
relative_entity_pathfrom the query string → resolves it without normalising
So the server asks "is this user allowed in company 115375?", says yes, then hands a string I control to a resolver that walks straight out of 115375 into 115374.
It does validate the path — that's what the 400 proves. It just validates before normalising. And because the impact lands outside the scope the check was defending, the vector carries Scope: Changed.
CVSS 6.4 · Medium · CWE-22 · A01:2025 Broken Access Control
The fix: normalise the path first, then check the resolved path still sits inside the caller's tenant root — and do it in the shared resolver, not per-route.
For defenders: log the authorised tenant ID and the resolved tenant ID as separate fields. When they disagree, alert. Catches this no matter what encoding trick got you there.
Three things I took from this
A dead end isn't a wasted day. The SSTI didn't exist — but proving that put me in the JS bundle, and the bundle had the parameter.
A 200 is not a finding. Traversal worked on limits in hour one. It was worthless. Always check what came back, not just the status code.
Read your errors. resource_wrong_entity_path wasn't the app blocking me. It was the app telling me how it worked.
How to spot this in your own hunting
The specific bug was traversal. The pattern is much bigger, and it's this:
_Find an endpoint where authorisation reads one input and the _object lookup reads a different one.
That's the whole bug class. Wherever those two inputs are separate, you can usually satisfy the first and abuse the second. What to look for:
- Two identifiers in one request — a tenant/org ID in the URL path plus any path, key, ref, or slug in the query string or body. That's the shape.
- Names that sound like paths —
entity_path,resource_ref,key,prefix,scope,parent. Anything the client assembles. - Read your JS bundles. Mine literally showed the path being built as
"companies/" + companyId. The client tells you what the server expects. - Errors that are specific. A generic
400is a wall. A400that names a field is a spec. - Then check what actually came back. Half the "traversal works!" moments resolve to static config nobody cares about.
And always test it as two real tenants that share nothing — no invite, no common user, no shared project. Otherwise triage will close it as intended sharing behaviour.
Accepted as Medium · €250 💶
If you found this useful, feel free to share it!
Thanks for reading — this was my first bounty and my first write-up. Tell me in the comments what you'd have done differently, I actually want to know.
See you in the next one 👋
HariHax
- Medium — https://medium.com/@HariHax
- LinkedIn — https://www.linkedin.com/in/hari0msingh/
- X — https://x.com/Hari0mSingh22
- YouTube — https://www.youtube.com/@Hari_Hax
- Site — https://hari0msingh.github.io/