Search Apps Documentation Source Content File Folder Download Copy Actions Download 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}