z_foreign_realm_capability_filetest.gno
3.80 Kb · 90 lines
1// PKGPATH: gno.land/r/zzattacker
2//
3// Consumer-level regression pin for the governance capability leak.
4//
5// The package-level pins live in p/moul/authz. This is the consumer-level
6// one: an unrelated realm must not be able to drive a valopers privileged
7// action.
8//
9// Before the fix this file could not exist as a negative test — the
10// realm exported NewInstructionsProposalCallback, a crossing closure
11// declared in valopers. The VM mints a crossing frame's `cur` from the
12// CALLEE's declaring package, so invoking it from anywhere ran
13// updateInstructions with cur.Address() == the ContractAuthority's
14// contractAddr. The contract-identity gate accepted, the init.gno
15// handler ran the action, and `instructions` was rewritten with no
16// proposal and no vote:
17//
18// err: undefined
19// PWNED-BY-ATTACKER
20//
21// The capability is now sealed inside a dao.ProposalRequest, whose
22// executor field is unexported with no accessor. This file asserts the
23// two halves of that: the leak is gone from the type system, and the
24// request an attacker can still construct is inert in their hands.
25//
26// NOTE for whoever edits valopers next: the property under test is
27// "no exported function returns anything carrying this realm's frame
28// identity". Re-exporting a func(realm) error, or returning a
29// dao.Executor (whose Execute method is exported and directly
30// invocable), reopens the hole even though this file would still
31// compile.
32package zzattacker
33
34import (
35 "strings"
36 "testing"
37
38 "gno.land/p/nt/testutils/v0"
39 "gno.land/r/gnops/valopers"
40 "gno.land/r/gov/dao"
41 daov3init "gno.land/r/gov/dao/init/v0"
42)
43
44// A real GovDAO exists and the attacker is not in it.
45var govdaoMember address = testutils.TestAddress("govdao-member")
46
47func init(cur realm) {
48 daov3init.InitWithUsers(cross(cur), govdaoMember)
49}
50
51func main(cur realm) {
52 // 1. The bare capability is gone. Uncommenting this must not compile:
53 //
54 // cb := valopers.NewInstructionsProposalCallback("PWNED-BY-ATTACKER")
55 // cb(cross(cur))
56 //
57 // What remains is the sealed request. An unrelated realm may still
58 // build one — construction is harmless — but dao.ProposalRequest
59 // exposes only Title/Description/Filter, so there is no way to reach
60 // the executor and run it. Only GovDAO executing the proposal can.
61 testing.SetRealm(testing.NewCodeRealm("gno.land/r/zzattacker"))
62
63 req := valopers.NewInstructionsProposalRequest(cross(cur), "PWNED-BY-ATTACKER")
64 println("attacker built a request:", req.Title())
65
66 // 2. The privileged write did not happen: the instructions are still
67 // the genesis text, not the attacker's. Under the exported callback
68 // this line already printed "defaced: true".
69 rendered := valopers.Render("")
70 println("instructions defaced:", strings.Contains(rendered, "PWNED-BY-ATTACKER"))
71 println("instructions intact:", strings.Contains(rendered, "Welcome to the **Valopers** realm"))
72
73 // 3. Runtime pin, not merely a symbol-absence one. dao.ProposalRequest
74 // exposes Title/Description/Filter and nothing that yields the
75 // executor, so the one remaining route for a holder is to ask
76 // GovDAO to adopt the request. A hostile realm cannot: this aborts.
77 // (A GovDAO *member* still can, and then it needs the votes — that
78 // is the legitimate path, covered by
79 // r/gnops/valopers/proposal/filetests/z_governed_instructions_filetest.gno.)
80 dao.MustCreateProposal(cross(cur), req)
81 println("UNREACHABLE: outsider got the proposal adopted")
82}
83
84// Output:
85// attacker built a request: /r/gnops/valopers: Update instructions
86// instructions defaced: false
87// instructions intact: true
88
89// Error:
90// proposal creation must be done directly by a user or through the r/gov/dao proxy. caller realm: realm{gno.land/r/gov/dao:g1p84dvfh4wrplyxx4zsmy77a8rxcnjup2j7zv5r}; caller's previous: realm{gno.land/r/zzattacker:g1q577nx395ehscyn9ucutvfpj03a5tuk7x4v3ad}