`POST /triple/save` handler allows cross-tenant overwrites due to missing project scope in `Causes#get`, `Risks#get` and `Effects#get` methods
#263 aperta il 4 mag 2026
Metriche repository
- Star
- (24 star)
- Metriche merge PR
- (Metriche PR in attesa)
Descrizione
What happens
In front/front_triple.rb:82-98, the POST /triple/save handler treats cid, rid, eid as trusted parameters and passes them straight to project-scoped objects:
causes.get(cid).text = ctext
causes.get(cid).emoji = params[:emoji]
risks.get(rid).text = rtext
effects.get(eid).text = etext
risks.get(rid).probability = params[:probability].to_i
effects.get(eid).impact = params[:impact].to_i
effects.get(eid).positive = !params[:positive].nil?
Rsk::Causes#get (objects/causes.rb:43-46), Rsk::Risks#get (objects/risks.rb) and Rsk::Effects#get (objects/effects.rb) ignore the @project they were constructed with — they just return Rsk::Cause.new(@pgsql, id) (and friends). The setters then run UPDATE part SET ... WHERE id = $1 and UPDATE cause/risk/effect SET ... WHERE id = $1 (objects/cause.rb:24-35, objects/risk.rb:24-37, objects/effect.rb:24-44) — none of these statements include a project = ... predicate.
Result: any logged-in user, working in their own project A, can submit a POST /triple/save with cid, rid, eid belonging to another user's project B and silently overwrite the text, emoji, probability, impact and positive fields of B's cause, risk and effect rows. This is a cross-tenant authorization bug (IDOR — Insecure Direct Object Reference).
Reproduction
A standalone reproduction against a temporary PostgreSQL with the live objects/causes.rb, objects/risks.rb, objects/effects.rb (and minimal schema slice from liquibase/2019/004-triple.xml + 009-cause-emoji.xml) prints:
== seed ==
project A id=1 (alice), B id=2 (bob)
Bob seeded: cid=1 rid=2 eid=3
== EXPLOIT (Alice mutates Bob via her project-A scoped objects) ==
before: cause B text="B-secret-cause" emoji="💾"
after:
cause B text="PWNED-BY-ALICE" emoji="💀"
risk B text="PWNED-RISK" probability=9
effect B text="PWNED-EFFECT" impact=9
IDOR CONFIRMED: cross-tenant write succeeded
The exploit is the literal sequence of calls the controller makes:
causes_a = Rsk::Causes.new(pool, pid_a) # Alice's project
causes_a.get(cid_b).text = 'PWNED-BY-ALICE' # cid_b belongs to Bob
causes_a.get(cid_b).emoji = '💀'
risks_a = Rsk::Risks.new(pool, pid_a)
risks_a.get(rid_b).probability = 9 # rid_b belongs to Bob
effects_a = Rsk::Effects.new(pool, pid_a)
effects_a.get(eid_b).impact = 9 # eid_b belongs to Bob
All writes succeed.
What should happen
Cross-tenant writes must be rejected.
Rsk::Causes#get, Rsk::Risks#get, Rsk::Effects#get should verify that the requested id belongs to the @project they were constructed with, and raise Rsk::Urror (or similar) when it does not — for example:
def get(id)
found = @pgsql.exec(
'SELECT id FROM part WHERE id = $1 AND project = $2 AND type = $3',
[id, @project, 'Cause']
)
raise Rsk::Urror, "Cause ##{id} not found" if found.empty?
Rsk::Cause.new(@pgsql, id)
end
Equivalently, the setters in Rsk::Cause, Rsk::Risk, Rsk::Effect could carry the project and add AND project = $N to every UPDATE. Either way, the invariant is: a Rsk::Causes/Risks/Effects instance bound to project P must never read or mutate rows that do not belong to P.
The same shape of bug very likely exists on every other endpoint that takes id from params and routes it through causes.get, risks.get, effects.get, plans.get, triples.get, etc. (e.g. the GET /triple lookup at front/front_triple.rb:71-80 already calls triples.fetch(id: id, limit: 1) — that path needs the same audit).