Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

clamp.gno

4.56 Kb · 85 lines
 1package impl
 2
 3// Attacker-controlled strings are clamped before they reach the sanitizer, so
 4// escaping costs a bounded constant rather than scaling with input.
 5//
 6// Sanitizing runs ~11,310 gas/byte (InlineCode) against ~31 for the raw
 7// concatenation it replaced, and Render is reachable unauthenticated through
 8// vm/qrender under maxGasQuery = 3_000_000_000. ExecutorCreationRealm is
 9// dispatched through the public dao.Executor interface, so a hostile executor
10// computes it per call while storing almost nothing. Measured on the real
11// render path: a 250KB value costs 2,839,117,770 gas unclamped against
12// 18,749,984 clamped. 250KB is just under the cap; about 265KB crosses it
13// (3,008,818,394), and past that the page cannot be rendered at all by
14// anyone. Choosing the larger number costs the attacker nothing, since the
15// value is computed per call and almost nothing is stored. Removing that
16// amplification is the point of this file.
17//
18// It does NOT make the page safe, and nothing here should be read as claiming
19// so. The executor's method body runs inside the same query and is unbounded:
20// an executor that simply burns CPU before returning a short string still
21// renders the proposal page permanently un-queryable, for a few hundred bytes
22// of on-chain storage. That predates this change — Render has always called
23// ExecutorString() and ExecutorCreationRealm() through the public interface —
24// and bounding it needs a gas budget around executor dispatch, not a clamp.
25const (
26	// A realm path. The longest deployable realm path in examples/ is 35 bytes
27	// (gno.land/r/gov/dao/treasury/test/v0), so this leaves about seven times
28	// the room anything real needs. A hostile executor returns any length it
29	// likes, which is the reason to clamp at all. It is headroom, not a
30	// guarantee: the package-path grammar puts no ceiling on how many segments
31	// a path may have, so a deeply nested realm could still be cut. That costs
32	// a truncation marker on the page and nothing else.
33	maxRenderedRealm = 256
34	// "execution failed: " plus an executor's error message. Also bounds what
35	// govdao.gno stores, so the realm never holds a reason it cannot show.
36	maxRenderedReason = 1024
37	// A strconv error that echoes the caller's own path segment. Everything in
38	// it is fixed text except the quoted segment, whose length the caller picks.
39	maxRenderedError = 256
40	// A proposal title. The longest in examples/ is about 40 bytes, so this is
41	// roughly nine times anything real, and long for a heading. Titles are
42	// escaped on both the proposal page and the list page, and the list page
43	// escapes one per proposal shown, so this is the bound that keeps a single
44	// oversized title from pricing the whole list out of the query cap.
45	maxRenderedTitle = 512
46	// A Payment's String(). The canonical impls render a coin set or an
47	// amount plus a token key, then " to " and a bech32 address; a coin set
48	// is bounded by the number of denoms a proposer cares to list and each
49	// denom's length is unbounded, which is the reason to clamp at all.
50	maxRenderedPayment = 512
51)
52
53// Always clamp first — before escaping, and before any other pass over the
54// value. Trimming used to run before the clamp, which meant it walked every
55// byte the executor returned: 250KB of spaces cost 1,368,719,824 gas to render
56// nothing, and 560KB cost 3,048,904,520, past the query cap. Clamping first
57// bounds that scan and brought the same 560KB down to 16,657,792.
58//
59// Never clamp after escaping either. The escapers size their wrapper
60// from the string they are handed — InlineCode picks a fence long enough to
61// outscan the backticks it can see. Cutting a value that has already been
62// escaped can slice the closing fence off and leave the span hanging open,
63// which is worse than not clamping at all. Both call sites read
64// InlineCode(clampField(...)) for that reason, and the enormous-value case in
65// filetests/executor_disclosure_filetest.gno fails if the two are swapped.
66//
67// clampField cuts s to at most max bytes, backing off to a rune boundary so a
68// well-formed multi-byte character is not split, and marks the result so a
69// reader can tell it was cut. Input that is already invalid UTF-8 can still
70// leave a dangling lead byte; the sanitizer tolerates that. The marker avoids markdown punctuation:
71// these values are escaped downstream, and parentheses would come back as
72// "\(truncated\)".
73func clampField(s string, max int) string {
74	if len(s) <= max {
75		return s
76	}
77
78	end := max
79	// UTF-8 continuation bytes are 0b10xxxxxx.
80	for end > 0 && s[end]&0xC0 == 0x80 {
81		end--
82	}
83
84	return s[:end] + "… truncated"
85}