executor_invocation_authority_filetest.gno
7.90 Kb · 184 lines
1// PKGPATH: gno.land/r/test/execauthority
2package execauthority
3
4// Regression pin for who may invoke a proposal executor.
5//
6// dao.Executor.Execute is a public method on a public type and
7// dao.NewSimpleExecutor is a public constructor, so any realm can build an
8// executor around any func(realm) error it can obtain. Execute is a CROSSING
9// method declared in r/gov/dao, so invoking it mints a gno.land/r/gov/dao
10// frame for the callback — and that value is a capability: realms gate on it
11// (authz.NewContractAuthority("gno.land/r/gov/dao") + DoByPrevious, ownable,
12// anything reading Previous().Address()).
13//
14// Until SimpleExecutor.Execute gated on its invoker, the forged frame was
15// byte-identical to an approved one: same PkgPath, same Previous().PkgPath(),
16// same Previous().Address(). A callback could not tell a supermajority-backed
17// execution from one no member ever voted on, and the forgery minted no
18// proposal id, so there was nothing to render, audit or deny afterwards.
19//
20// The documented containment was never the one holding the line. types.gno
21// said invocation was "contained by the executor being unexported inside
22// ProposalRequest/Proposal with no accessor" — that protects the executor
23// OBJECT, but an attacker never needs someone else's executor: a reachable
24// CALLBACK is enough, and an ordinary exported entrypoint of the victim whose
25// signature is assignable to func(realm) error is such a callback.
26// r/gnops/valopers had exactly that shape.
27//
28// Sections 2, 3 and 6 are the inversion: the forgery is now refused, the two
29// frames are no longer comparable, and the proxy identity is not mintable.
30// Section 5 keeps SafeExecutor characterized — it remains dead code that
31// cannot be wired as written, since its allowlist holds the impl path while
32// an executor's caller is the proxy.
33
34import (
35 "chain"
36 "strings"
37 "testing"
38
39 "gno.land/r/gov/dao"
40 "gno.land/r/gov/dao/impl/v0"
41 "gno.land/r/gov/dao/memberstore/v0"
42)
43
44const (
45 member address = "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"
46 outside address = "g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn"
47)
48
49// observed records the frame the callback was handed, so the approved and the
50// forged invocation can be compared.
51type frame struct {
52 cur string
53 prev string
54 prevAddr address
55 ran bool
56}
57
58var seen frame
59
60func probe(cur realm) error {
61 seen = frame{
62 cur: cur.PkgPath(),
63 prev: cur.Previous().PkgPath(),
64 prevAddr: cur.Previous().Address(),
65 ran: true,
66 }
67 return nil
68}
69
70func init(cur realm) {
71 memberstore.Get(0, cur).DeleteAll()
72 memberstore.Get(0, cur).SetTier(memberstore.T1)
73 memberstore.Get(0, cur).SetMember(memberstore.T1, member, memberstore.NewMember(3))
74 dao.UpdateImpl(cross(cur), dao.NewUpdateRequest(
75 impl.NewGovDAO(), []string{"gno.land/r/gov/dao/impl/v0"}))
76}
77
78func main(cur realm) {
79 // 1. The approved path still works: a member proposes, votes it to
80 // supermajority, and executes. The gate must not break governance.
81 testing.SetOriginCaller(member)
82 testing.SetRealm(testing.NewUserRealm(member))
83
84 pid := dao.MustCreateProposal(cross(cur), dao.NewProposalRequest(
85 "approved", "carried by a supermajority vote", dao.NewSimpleExecutor(0, cur, probe, "")))
86 dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, pid))
87 println("approved path executes:", dao.ExecuteProposal(cross(cur), pid))
88 approved := seen
89 println("callback ran:", approved.ran)
90 println("approved frame cur :", approved.cur)
91 println("approved frame prev:", approved.prev)
92
93 // 2. The forged path: no proposal, no vote, and a caller who is not a
94 // member of any tier. Execute is called directly on a locally built
95 // executor, and is refused.
96 seen = frame{}
97 testing.SetOriginCaller(outside)
98 testing.SetRealm(testing.NewUserRealm(outside))
99
100 // The same caller cannot open a proposal -- PreCreateProposal rejects
101 // non-members. That is the authority level the DAO grants them, and
102 // Execute must not grant more.
103 _, err := dao.CreateProposal(cross(cur), dao.NewProposalRequest(
104 "rejected", "a non-member cannot propose", dao.NewSimpleExecutor(0, cur, probe, "")))
105 println("forger cannot even open a proposal:", err != nil)
106 println("callback ran:", seen.ran)
107
108 forged := dao.NewSimpleExecutor(0, cur, probe, "no proposal backs this")
109 println("forged Execute is refused:", forged.Execute(cross(cur)))
110 println("callback ran:", seen.ran)
111
112 // 3. And because it never ran, there is no forged frame to compare: the
113 // governance identity was not minted for an unauthorized invoker.
114 println("no frame was handed out:", seen.cur == "" && seen.prev == "")
115 println("approved prev address was the gov/dao package address:",
116 approved.prevAddr == chain.PackageAddress("gno.land/r/gov/dao"))
117
118 // 4. The forgery leaves no trace on the DAO either: no proposal id was
119 // minted, so there is nothing to render, audit, or deny after the fact.
120 _, missing := dao.GetProposal(pid + 1)
121 println("no proposal was created for the forged execution:", missing != nil)
122
123 // 5. SafeExecutor still cannot be wired as written: allowedDAOs holds the
124 // impl path, while an executor's caller is the proxy path, so its check
125 // rejects the APPROVED path. It remains dead code; the gate that works
126 // lives in SimpleExecutor.Execute and keys on the proxy, not on
127 // InAllowedDAOs.
128 println("InAllowedDAOs(proxy path):", dao.InAllowedDAOs("gno.land/r/gov/dao"))
129 println("allowedDAOs holds instead:", dao.AllowedDAOs()[0])
130
131 seen = frame{}
132 testing.SetOriginCaller(member)
133 testing.SetRealm(testing.NewUserRealm(member))
134 spid := dao.MustCreateProposal(cross(cur), dao.NewProposalRequest(
135 "safe", "same approved path, wrapped in SafeExecutor",
136 dao.NewSafeExecutor(dao.NewSimpleExecutor(0, cur, probe, ""))))
137 dao.MustVoteOnProposal(cross(cur), dao.NewVoteRequest(dao.YesVote, spid))
138 println("SafeExecutor rejects the approved path:", !dao.ExecuteOrRejectProposal(cross(cur), spid))
139 println("and its callback never ran:", !seen.ran)
140 println("denial reason names the allowlist:",
141 strings.Contains(dao.Render(cross(cur), spid.String()), "execution only allowed by validated govDAOs"))
142
143 // 6. The reachability precondition is now closed at the primitive. A
144 // realm-authored callback is stuck with its own current realm (the one
145 // value a forger never controlled), and it can no longer borrow the
146 // proxy as Previous() either -- so gates keying on Previous(), which
147 // were the forgeable ones, hold.
148 testing.SetOriginCaller(outside)
149 testing.SetRealm(testing.NewUserRealm(outside))
150 seen = frame{}
151 println("forged Execute refused again:",
152 dao.NewSimpleExecutor(0, cur, probe, "").Execute(cross(cur)) != nil)
153 println("no proxy identity was borrowed:", seen.prev != "gno.land/r/gov/dao")
154
155 // The member store was never reachable this way -- it gates on PkgPath(),
156 // which the forgery does not control -- but the attempt is now refused one
157 // layer earlier, at Execute, so the abort below never happens.
158 e := dao.NewSimpleExecutor(0, cur, func(cur realm) error {
159 memberstore.Get(0, cur).SetMember(memberstore.T1, outside, memberstore.NewMember(3))
160 return nil
161 }, "")
162 println("member-store escalation refused at Execute:", e.Execute(cross(cur)) != nil)
163}
164
165// Output:
166// approved path executes: true
167// callback ran: true
168// approved frame cur : gno.land/r/test/execauthority
169// approved frame prev: gno.land/r/gov/dao
170// forger cannot even open a proposal: true
171// callback ran: false
172// forged Execute is refused: execution denied: executors are only invocable by gno.land/r/gov/dao
173// callback ran: false
174// no frame was handed out: true
175// approved prev address was the gov/dao package address: true
176// no proposal was created for the forged execution: true
177// InAllowedDAOs(proxy path): false
178// allowedDAOs holds instead: gno.land/r/gov/dao/impl/v0
179// SafeExecutor rejects the approved path: true
180// and its callback never ran: true
181// denial reason names the allowlist: true
182// forged Execute refused again: true
183// no proxy identity was borrowed: true
184// member-store escalation refused at Execute: true