executor_disclosure_filetest.gno
12.35 Kb · 258 lines
1// PKGPATH: gno.land/r/test/disclosure
2package disclosure
3
4// Covers the executor-disclosure changes in an isolated realm. The unit tests
5// in the impl package share proposal ids across files (govdao_test.gno asserts
6// a hard-coded id) and swap the DAO implementation partway through, so these
7// live here instead.
8
9import (
10 "strings"
11 "testing"
12
13 "gno.land/r/gov/dao"
14 "gno.land/r/gov/dao/impl/v0"
15 "gno.land/r/gov/dao/memberstore/v0"
16)
17
18const user address = "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5"
19
20func init(cur realm) {
21 memberstore.Get(0, cur).DeleteAll()
22 memberstore.Get(0, cur).SetTier(memberstore.T1)
23 memberstore.Get(0, cur).SetMember(memberstore.T1, user, memberstore.NewMember(3))
24 dao.UpdateImpl(cross(cur), dao.NewUpdateRequest(impl.NewGovDAO(), nil))
25}
26
27// hostileExecutor implements dao.Executor directly rather than through
28// NewSimpleExecutor, which is what lets it choose its own CreationRealm.
29// CreationRealm() is dispatched through the public dao.Executor interface, so
30// only SimpleExecutor's value is VM-supplied from rlm.PkgPath().
31type hostileExecutor struct{}
32
33func (e *hostileExecutor) Execute(cur realm) error { return nil }
34
35func (e *hostileExecutor) String() string { return "" }
36
37func (e *hostileExecutor) CreationRealm() string {
38 return "gno.land/r/sys/params\n\n### Stats\n\n- **PROPOSAL HAS BEEN ACCEPTED**\n- YES PERCENT: 100%\n\n---\n"
39}
40
41// blankExecutor's CreationRealm is non-empty but strips to nothing:
42// sanitize.InlineCode removes bidi and zero-width characters, so it returns "".
43type blankExecutor struct{}
44
45func (e *blankExecutor) Execute(cur realm) error { return nil }
46
47func (e *blankExecutor) String() string { return "" }
48
49func (e *blankExecutor) CreationRealm() string { return "\u200b\u200b\u202e" }
50
51// whitespaceExecutor covers the other half of the guard: plain whitespace,
52// which InlineCode would otherwise wrap in a padded, empty-looking span.
53type whitespaceExecutor struct{}
54
55func (e *whitespaceExecutor) Execute(cur realm) error { return nil }
56
57func (e *whitespaceExecutor) String() string { return "" }
58
59func (e *whitespaceExecutor) CreationRealm() string { return " \t " }
60
61// hugeExecutor computes a very large CreationRealm while storing nothing.
62// Sanitizing costs ~11,310 gas/byte and render is reachable unauthenticated
63// under a 3,000,000,000 gas cap, so this must be clamped before escaping.
64type hugeExecutor struct{}
65
66func (e *hugeExecutor) Execute(cur realm) error { return nil }
67
68func (e *hugeExecutor) String() string { return "" }
69
70func (e *hugeExecutor) CreationRealm() string { return strings.Repeat("z", 20000) }
71
72// fenceExecutor attacks the code span itself. Section 4 below sends backticks
73// through the grant sentence, but that path escapes the value directly. The
74// creation realm is clamped first and escaped second, so it needs its own
75// case: the fence is chosen after the cut, and must still outscan whatever
76// backticks survived it. The run here is two long, so a two-backtick fence
77// would be closed by the payload.
78type fenceExecutor struct{}
79
80func (e *fenceExecutor) Execute(cur realm) error { return nil }
81
82func (e *fenceExecutor) String() string { return "" }
83
84func (e *fenceExecutor) CreationRealm() string { return "gno.land/r/evil`` **INJECTED**" }
85
86// wsExecutor returns a large run of spaces. TrimSpace used to run before the
87// clamp, so it scanned every byte of whatever the executor returned: 250KB of
88// spaces cost 1,368,719,824 gas to render nothing at all, and 560KB cost
89// 3,048,904,520 — past the query cap, so the page could not be rendered by
90// anyone. Clamping first bounds the scan; the same 560KB now costs 16,657,792.
91type wsExecutor struct{}
92
93func (e *wsExecutor) Execute(cur realm) error { return nil }
94
95func (e *wsExecutor) String() string { return "" }
96
97func (e *wsExecutor) CreationRealm() string { return strings.Repeat(" ", 20000) }
98
99func main(cur realm) {
100 testing.SetOriginCaller(user)
101
102 // 1. An executor with NO description. The creation realm used to share the
103 // `ExecutorString() != ""` gate with the description, so it was hidden for
104 // every such proposal — 16 call sites across 7 production realms.
105 // SetRealm first: NewSimpleExecutor captures rlm.PkgPath(), which is empty
106 // outside a code realm.
107 testing.SetRealm(testing.NewCodeRealm("gno.land/r/template/silent"))
108 silent := dao.NewSimpleExecutor(0, cur, func(realm) error { return nil }, "")
109
110 testing.SetRealm(testing.NewUserRealm(user))
111 pid := dao.MustCreateProposal(cross(cur), dao.NewProposalRequest(
112 "Silent", "A proposal whose executor has no description", silent))
113 out := dao.Render(cross(cur), pid.String())
114
115 println("empty-description proposal discloses creation realm:",
116 strings.Contains(out, "Executor created in: `gno.land/r/template/silent`"))
117 println("and prints no empty metadata block:",
118 !strings.Contains(out, "This proposal contains the following metadata"))
119
120 // 2. CreationRealm() is dispatched through the public dao.Executor
121 // interface, so a third-party executor picks its own value — and the
122 // disclosure now renders for every proposal. InlineCode does not delete the
123 // hostile text; it folds it onto one line inside a code span, where it can
124 // no longer forge page structure. So assert structure, not absence.
125 hpid := dao.MustCreateProposal(cross(cur), dao.NewProposalRequest(
126 "Hostile", "A proposal whose executor forges page structure", &hostileExecutor{}))
127 hout := dao.Render(cross(cur), hpid.String())
128
129 println("only the genuine Stats heading exists:",
130 strings.Count(hout, "\n### Stats") == 1)
131 println("no forged acceptance line:",
132 !strings.Contains(hout, "\n- **PROPOSAL HAS BEEN ACCEPTED**"))
133 println("no forged tally line:",
134 !strings.Contains(hout, "\n- YES PERCENT: 100%"))
135 println("genuine tally and status still render:",
136 strings.Contains(hout, "\n- YES PERCENT: 0%") &&
137 strings.Contains(hout, "- **Proposal is open for votes**"))
138 // One line, inside a code span: the newlines are folded to spaces, so none
139 // of the payload can start a block. (No pad space before the value now —
140 // TrimSpace removes the payload's trailing newline, so InlineCode does not
141 // need to pad the fence.)
142 println("hostile value is confined to one code-span line:",
143 strings.Contains(hout, "Executor created in: `gno.land/r/sys/params ### Stats"))
144
145 // 3. The upgrade proposal rewrites AllowedDAOs — the sole authorization for
146 // replacing the implementation, mutating the member store and moving
147 // treasury funds. It used to carry an empty description, so the realm
148 // receiving that authority appeared nowhere a voter would read.
149 upid := dao.MustCreateProposal(cross(cur),
150 impl.NewUpgradeDaoImplRequest(cross(cur), impl.NewGovDAO(), "gno.land/r/gov/dao/v1/impl", "reason"))
151 uout := dao.Render(cross(cur), upid.String())
152
153 println("upgrade proposal states the grant:",
154 strings.Contains(uout, "may replace the implementation, mutate the member store, or move treasury funds"))
155 println("upgrade proposal names the granted realm:",
156 strings.Contains(uout, "`gno.land/r/gov/dao/v1/impl`"))
157
158 // 4. realmPkg is caller-supplied and lands inside a code span. md.EscapeText
159 // was wrong there: CommonMark 6.1 does not process backslash escapes inside
160 // code spans, so it rendered visible backslashes, and a backtick closed the
161 // span early. InlineCode widens the fence instead, so the payload stays
162 // inside it as literal text.
163 bpid := dao.MustCreateProposal(cross(cur),
164 impl.NewUpgradeDaoImplRequest(cross(cur), impl.NewGovDAO(),
165 "gno.land/r/x` **PROPOSAL HAS BEEN ACCEPTED** `", "reason"))
166 bout := dao.Render(cross(cur), bpid.String())
167
168 // The payload's own backtick sits INSIDE a widened `` fence, so it renders
169 // as literal code rather than closing the span and freeing the bold text.
170 println("fence widened to contain the backtick:",
171 strings.Contains(bout, "`` gno.land/r/x` **PROPOSAL HAS BEEN ACCEPTED** ` ``"))
172 println("no forged acceptance line from the breakout attempt:",
173 !strings.Contains(bout, "\n- **PROPOSAL HAS BEEN ACCEPTED**"))
174 println("no visible backslashes in the path:",
175 !strings.Contains(bout, "gno\\.land"))
176
177 // 4b. Guarding the raw value would print the label with nothing after it
178 // for a creation realm that sanitizes away entirely.
179 zpid := dao.MustCreateProposal(cross(cur), dao.NewProposalRequest(
180 "Blank", "A proposal whose creation realm strips to nothing", &blankExecutor{}))
181 zout := dao.Render(cross(cur), zpid.String())
182
183 println("a creation realm that strips to nothing prints no bare label:",
184 !strings.Contains(zout, "Executor created in:"))
185
186 wpid := dao.MustCreateProposal(cross(cur), dao.NewProposalRequest(
187 "Whitespace", "A proposal whose creation realm is only whitespace", &whitespaceExecutor{}))
188 println("a whitespace-only creation realm prints no bare label:",
189 !strings.Contains(dao.Render(cross(cur), wpid.String()), "Executor created in:"))
190
191 hupid := dao.MustCreateProposal(cross(cur), dao.NewProposalRequest(
192 "Huge", "A proposal whose creation realm is enormous", &hugeExecutor{}))
193 hurendered := dao.Render(cross(cur), hupid.String())
194 // The truncation marker must sit INSIDE the code span, so the value ends
195 // with its closing fence. That is what proves the clamp ran before the
196 // escaping and not after. Cutting an already-escaped value slices the
197 // closing fence off and leaves the span hanging open, and a length check
198 // alone cannot tell the two apart — both produce a short string ending in
199 // the marker.
200 println("an enormous creation realm is clamped before escaping:",
201 len(hurendered) < 2000 && strings.Contains(hurendered, "… truncated`"))
202
203 // 4c. The same breakout attempt through the creation realm, which is
204 // clamped before it is escaped. The fence must be sized from the clamped
205 // string, so it widens to three backticks and the payload stays literal.
206 fpid := dao.MustCreateProposal(cross(cur), dao.NewProposalRequest(
207 "Fence", "A proposal whose creation realm tries to close the code span", &fenceExecutor{}))
208 fout := dao.Render(cross(cur), fpid.String())
209
210 println("fence outscans the payload's backtick run:",
211 strings.Contains(fout, "Executor created in: ```gno.land/r/evil`` **INJECTED**```"))
212 println("no bold text escapes the code span:",
213 !strings.Contains(fout, "\n**INJECTED**"))
214
215 // 4d. A large all-whitespace creation realm. Clamping before trimming is
216 // what bounds the work here, and the marker is how the test can tell: cut
217 // first and the marker survives the trim, so the label renders. Trim first
218 // and the value collapses to nothing, printing no label — and the trim has
219 // already walked every byte the executor produced.
220 wpid2 := dao.MustCreateProposal(cross(cur), dao.NewProposalRequest(
221 "BigWhitespace", "A proposal whose creation realm is a huge run of spaces", &wsExecutor{}))
222 wout2 := dao.Render(cross(cur), wpid2.String())
223
224 println("a huge whitespace creation realm is clamped before trimming:",
225 strings.Contains(wout2, "Executor created in: `… truncated`"))
226
227 // 5. Proposals live on the proxy but their voting status lives on the
228 // GovDAO instance, so replacing the implementation leaves earlier
229 // proposals renderable but statusless. renderProposalPage took a
230 // user-supplied pid and dereferenced that nil status.
231 testing.SetRealm(testing.NewCodeRealm("gno.land/r/gov/dao/impl/v0"))
232 dao.UpdateImpl(cross(cur), dao.NewUpdateRequest(impl.NewGovDAO(), nil))
233
234 testing.SetRealm(testing.NewUserRealm(user))
235 println("a proposal orphaned by an upgrade renders instead of panicking:",
236 strings.Contains(dao.Render(cross(cur), pid.String()), "not available"))
237}
238
239// Output:
240// empty-description proposal discloses creation realm: true
241// and prints no empty metadata block: true
242// only the genuine Stats heading exists: true
243// no forged acceptance line: true
244// no forged tally line: true
245// genuine tally and status still render: true
246// hostile value is confined to one code-span line: true
247// upgrade proposal states the grant: true
248// upgrade proposal names the granted realm: true
249// fence widened to contain the backtick: true
250// no forged acceptance line from the breakout attempt: true
251// no visible backslashes in the path: true
252// a creation realm that strips to nothing prints no bare label: true
253// a whitespace-only creation realm prints no bare label: true
254// an enormous creation realm is clamped before escaping: true
255// fence outscans the payload's backtick run: true
256// no bold text escapes the code span: true
257// a huge whitespace creation realm is clamped before trimming: true
258// a proposal orphaned by an upgrade renders instead of panicking: true