@cryptotaxi247 / kubo / commits / f6a9b347c

fix(cmds): cleanup unicode identify strings (#9465)

preserve private use characters as specified in https://github.com/libp2p/specs/pull/491 enforce 128 rune limit on untrusted peer data

Marcin Rataj committed Sep 19, 2025 at 04:46 UTC f6a9b347cb0d309c2bc1fcb363d9aae98905f09c
10 files changed +299 -9
core/commands/cmdutils/sanitize.go new
+50
@@ -0,0 +1,50 @@
1 +package cmdutils
2 +
3 +import (
4 + "strings"
5 + "unicode"
6 +)
7 +
8 +const maxRunes = 128
9 +
10 +// CleanAndTrim sanitizes untrusted strings from remote peers to prevent display issues
11 +// across web UIs, terminals, and logs. It replaces control characters, format characters,
12 +// and surrogates with U+FFFD (�), then enforces a maximum length of 128 runes.
13 +//
14 +// This follows the libp2p identify specification and RFC 9839 guidance:
15 +// replacing problematic code points is preferred over deletion as deletion
16 +// is a known security risk.
17 +func CleanAndTrim(str string) string {
18 + // Build sanitized result
19 + var result []rune
20 + for _, r := range str {
21 + // Replace control characters (Cc) with U+FFFD - prevents terminal escapes, CR, LF, etc.
22 + if unicode.Is(unicode.Cc, r) {
23 + result = append(result, '\uFFFD')
24 + continue
25 + }
26 + // Replace format characters (Cf) with U+FFFD - prevents RTL/LTR overrides, zero-width chars
27 + if unicode.Is(unicode.Cf, r) {
28 + result = append(result, '\uFFFD')
29 + continue
30 + }
31 + // Replace surrogate characters (Cs) with U+FFFD - invalid in UTF-8
32 + if unicode.Is(unicode.Cs, r) {
33 + result = append(result, '\uFFFD')
34 + continue
35 + }
36 + // Private use characters (Co) are preserved per spec
37 + result = append(result, r)
38 + }
39 +
40 + // Convert to string and trim whitespace
41 + sanitized := strings.TrimSpace(string(result))
42 +
43 + // Enforce maximum length (128 runes, not bytes)
44 + runes := []rune(sanitized)
45 + if len(runes) > maxRunes {
46 + return string(runes[:maxRunes])
47 + }
48 +
49 + return sanitized
50 +}
core/commands/id.go
+5 -2
@@ -12,6 +12,7 @@ import (
12 version "github.com/ipfs/kubo"
13 "github.com/ipfs/kubo/core"
14 "github.com/ipfs/kubo/core/commands/cmdenv"
15 + "github.com/ipfs/kubo/core/commands/cmdutils"
16
17 cmds "github.com/ipfs/go-ipfs-cmds"
18 ke "github.com/ipfs/kubo/core/commands/keyencode"
@@ -173,12 +174,14 @@ func printPeer(keyEnc ke.KeyEncoder, ps pstore.Peerstore, p peer.ID) (interface{
174 slices.Sort(info.Addresses)
175
176 protocols, _ := ps.GetProtocols(p) // don't care about errors here.
176 - info.Protocols = append(info.Protocols, protocols...)
177 + for _, proto := range protocols {
178 + info.Protocols = append(info.Protocols, protocol.ID(cmdutils.CleanAndTrim(string(proto))))
179 + }
180 slices.Sort(info.Protocols)
181
182 if v, err := ps.Get(p, "AgentVersion"); err == nil {
183 if vs, ok := v.(string); ok {
181 - info.AgentVersion = vs
184 + info.AgentVersion = cmdutils.CleanAndTrim(vs)
185 }
186 }
187
core/commands/stat_dht.go
+7 -2
@@ -7,6 +7,7 @@ import (
7 "time"
8
9 cmdenv "github.com/ipfs/kubo/core/commands/cmdenv"
10 + "github.com/ipfs/kubo/core/commands/cmdutils"
11
12 cmds "github.com/ipfs/go-ipfs-cmds"
13 dht "github.com/libp2p/go-libp2p-kad-dht"
@@ -92,7 +93,9 @@ This interface is not stable and may change from release to release.
93 info := dhtPeerInfo{ID: p.String()}
94
95 if ver, err := nd.Peerstore.Get(p, "AgentVersion"); err == nil {
95 - info.AgentVersion, _ = ver.(string)
96 + if vs, ok := ver.(string); ok {
97 + info.AgentVersion = cmdutils.CleanAndTrim(vs)
98 + }
99 } else if err == pstore.ErrNotFound {
100 // ignore
101 } else {
@@ -143,7 +146,9 @@ This interface is not stable and may change from release to release.
146 info := dhtPeerInfo{ID: pi.Id.String()}
147
148 if ver, err := nd.Peerstore.Get(pi.Id, "AgentVersion"); err == nil {
146 - info.AgentVersion, _ = ver.(string)
149 + if vs, ok := ver.(string); ok {
150 + info.AgentVersion = cmdutils.CleanAndTrim(vs)
151 + }
152 } else if err == pstore.ErrNotFound {
153 // ignore
154 } else {
core/commands/swarm.go
+7 -3
@@ -18,6 +18,7 @@ import (
18 "github.com/ipfs/kubo/commands"
19 "github.com/ipfs/kubo/config"
20 "github.com/ipfs/kubo/core/commands/cmdenv"
21 + "github.com/ipfs/kubo/core/commands/cmdutils"
22 "github.com/ipfs/kubo/core/node/libp2p"
23 "github.com/ipfs/kubo/repo"
24 "github.com/ipfs/kubo/repo/fsrepo"
@@ -27,6 +28,7 @@ import (
28 inet "github.com/libp2p/go-libp2p/core/network"
29 "github.com/libp2p/go-libp2p/core/peer"
30 pstore "github.com/libp2p/go-libp2p/core/peerstore"
31 + "github.com/libp2p/go-libp2p/core/protocol"
32 rcmgr "github.com/libp2p/go-libp2p/p2p/host/resource-manager"
33 ma "github.com/multiformats/go-multiaddr"
34 madns "github.com/multiformats/go-multiaddr-dns"
@@ -290,7 +292,7 @@ var swarmPeersCmd = &cmds.Command{
292 }
293
294 for _, s := range strs {
293 - ci.Streams = append(ci.Streams, streamInfo{Protocol: string(s)})
295 + ci.Streams = append(ci.Streams, streamInfo{Protocol: cmdutils.CleanAndTrim(string(s))})
296 }
297 }
298
@@ -476,13 +478,15 @@ func (ci *connInfo) identifyPeer(ps pstore.Peerstore, p peer.ID) (IdOutput, erro
478 slices.Sort(info.Addresses)
479
480 if protocols, err := ps.GetProtocols(p); err == nil {
479 - info.Protocols = append(info.Protocols, protocols...)
481 + for _, proto := range protocols {
482 + info.Protocols = append(info.Protocols, protocol.ID(cmdutils.CleanAndTrim(string(proto))))
483 + }
484 slices.Sort(info.Protocols)
485 }
486
487 if v, err := ps.Get(p, "AgentVersion"); err == nil {
488 if vs, ok := v.(string); ok {
485 - info.AgentVersion = vs
489 + info.AgentVersion = cmdutils.CleanAndTrim(vs)
490 }
491 }
492
docs/examples/kubo-as-a-library/go.mod
+1
@@ -84,6 +84,7 @@ require (
84 github.com/ipfs/go-ds-pebble v0.5.1 // indirect
85 github.com/ipfs/go-dsqueue v0.0.5 // indirect
86 github.com/ipfs/go-fs-lock v0.1.1 // indirect
87 + github.com/ipfs/go-ipfs-cmds v0.15.0 // indirect
88 github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect
89 github.com/ipfs/go-ipfs-pq v0.0.3 // indirect
90 github.com/ipfs/go-ipfs-redirects-file v0.1.2 // indirect
docs/examples/kubo-as-a-library/go.sum
+2
@@ -327,6 +327,8 @@ github.com/ipfs/go-fs-lock v0.1.1 h1:TecsP/Uc7WqYYatasreZQiP9EGRy4ZnKoG4yXxR33nw
327 github.com/ipfs/go-fs-lock v0.1.1/go.mod h1:2goSXMCw7QfscHmSe09oXiR34DQeUdm+ei+dhonqly0=
328 github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ=
329 github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE=
330 +github.com/ipfs/go-ipfs-cmds v0.15.0 h1:nQDgKadrzyiFyYoZMARMIoVoSwe3gGTAfGvrWLeAQbQ=
331 +github.com/ipfs/go-ipfs-cmds v0.15.0/go.mod h1:VABf/mv/wqvYX6hLG6Z+40eNAEw3FQO0bSm370Or3Wk=
332 github.com/ipfs/go-ipfs-delay v0.0.0-20181109222059-70721b86a9a8/go.mod h1:8SP1YXK1M1kXuc4KJZINY3TQQ03J2rwBG9QfXmbRPrw=
333 github.com/ipfs/go-ipfs-delay v0.0.1 h1:r/UXYyRcddO6thwOnhiznIAiSvxMECGgtv35Xs1IeRQ=
334 github.com/ipfs/go-ipfs-delay v0.0.1/go.mod h1:8SP1YXK1M1kXuc4KJZINY3TQQ03J2rwBG9QfXmbRPrw=
test/cli/agent_version_unicode_test.go new
+220
@@ -0,0 +1,220 @@
1 +package cli
2 +
3 +import (
4 + "strings"
5 + "testing"
6 +
7 + "github.com/ipfs/kubo/core/commands/cmdutils"
8 + "github.com/stretchr/testify/assert"
9 +)
10 +
11 +func TestCleanAndTrimUnicode(t *testing.T) {
12 + tests := []struct {
13 + name string
14 + input string
15 + expected string
16 + }{
17 + {
18 + name: "Basic ASCII",
19 + input: "kubo/1.0.0",
20 + expected: "kubo/1.0.0",
21 + },
22 + {
23 + name: "Polish characters preserved",
24 + input: "test-ąęćłńóśźż",
25 + expected: "test-ąęćłńóśźż",
26 + },
27 + {
28 + name: "Chinese characters preserved",
29 + input: "版本-中文测试",
30 + expected: "版本-中文测试",
31 + },
32 + {
33 + name: "Arabic text preserved",
34 + input: "اختبار-العربية",
35 + expected: "اختبار-العربية",
36 + },
37 + {
38 + name: "Emojis preserved",
39 + input: "version-1.0-🚀-🎉",
40 + expected: "version-1.0-🚀-🎉",
41 + },
42 + {
43 + name: "Complex Unicode with combining marks preserved",
44 + input: "h̸̢̢̢̢̢̢̢̢̢̢e̵̵̵̵̵̵̵̵̵̵l̷̷̷̷̷̷̷̷̷̷l̶̶̶̶̶̶̶̶̶̶o̴̴̴̴̴̴̴̴̴̴",
45 + expected: "h̸̢̢̢̢̢̢̢̢̢̢e̵̵̵̵̵̵̵̵̵̵l̷̷̷̷̷̷̷̷̷̷l̶̶̶̶̶̶̶̶̶̶o̴̴̴̴̴̴̴̴̴̴", // Preserved as-is (only 50 runes)
46 + },
47 + {
48 + name: "Long text with combining marks truncated at 128",
49 + input: strings.Repeat("ẽ̸̢̛̖̬͈͉͖͇͈̭̥́̓̌̾͊̊̂̄̍̅̂͌́", 10), // Very long text (260 runes)
50 + expected: "ẽ̸̢̛̖̬͈͉͖͇͈̭̥́̓̌̾͊̊̂̄̍̅̂͌́ẽ̸̢̛̖̬͈͉͖͇͈̭̥́̓̌̾͊̊̂̄̍̅̂͌́ẽ̸̢̛̖̬͈͉͖͇͈̭̥́̓̌̾͊̊̂̄̍̅̂͌́ẽ̸̢̛̖̬͈͉͖͇͈̭̥́̓̌̾͊̊̂̄̍̅̂͌́ẽ̸̢̛̖̬͈͉͖͇͈̭̥́̓̌̾͊̊̂̄̍̅̂", // Truncated at 128 runes
51 + },
52 + {
53 + name: "Zero-width characters replaced with U+FFFD",
54 + input: "test\u200Bzero\u200Cwidth\u200D\uFEFFchars",
55 + expected: "test�zero�width��chars",
56 + },
57 + {
58 + name: "RTL/LTR override replaced with U+FFFD",
59 + input: "test\u202Drtl\u202Eltr\u202Aoverride",
60 + expected: "test�rtl�ltr�override",
61 + },
62 + {
63 + name: "Bidi isolates replaced with U+FFFD",
64 + input: "test\u2066bidi\u2067isolate\u2068text\u2069end",
65 + expected: "test�bidi�isolate�text�end",
66 + },
67 + {
68 + name: "Control characters replaced with U+FFFD",
69 + input: "test\x00null\x1Fescape\x7Fdelete",
70 + expected: "test�null�escape�delete",
71 + },
72 + {
73 + name: "Combining marks preserved",
74 + input: "e\u0301\u0302\u0303\u0304\u0305", // e with 5 combining marks
75 + expected: "e\u0301\u0302\u0303\u0304\u0305", // All preserved
76 + },
77 + {
78 + name: "No truncation at 70 characters",
79 + input: "123456789012345678901234567890123456789012345678901234567890123456789",
80 + expected: "123456789012345678901234567890123456789012345678901234567890123456789",
81 + },
82 + {
83 + name: "No truncation with Unicode - 70 rockets preserved",
84 + input: strings.Repeat("🚀", 70),
85 + expected: strings.Repeat("🚀", 70),
86 + },
87 + {
88 + name: "Empty string",
89 + input: "",
90 + expected: "",
91 + },
92 + {
93 + name: "Only whitespace with control chars",
94 + input: " \t\n ",
95 + expected: "\uFFFD\uFFFD", // Tab and newline become U+FFFD, spaces trimmed
96 + },
97 + {
98 + name: "Leading and trailing whitespace",
99 + input: " test ",
100 + expected: "test",
101 + },
102 + {
103 + name: "Complex mix - invisible chars replaced with U+FFFD, Unicode preserved",
104 + input: "kubo/1.0-🚀\u200B h̸̢̏̔ḛ̶̽̀s̵t\u202E-ąęł-中文",
105 + expected: "kubo/1.0-🚀� h̸̢̏̔ḛ̶̽̀s̵t�-ąęł-中文",
106 + },
107 + {
108 + name: "Emoji with skin tone preserved",
109 + input: "👍🏽", // Thumbs up with skin tone modifier
110 + expected: "👍🏽", // Preserved as-is
111 + },
112 + {
113 + name: "Mixed scripts preserved",
114 + input: "Hello-你好-مرحبا-Здравствуйте",
115 + expected: "Hello-你好-مرحبا-Здравствуйте",
116 + },
117 + {
118 + name: "Format characters replaced with U+FFFD",
119 + input: "test\u00ADsoft\u2060word\u206Fnom\u200Ebreak",
120 + expected: "test�soft�word�nom�break", // Soft hyphen, word joiner, etc replaced
121 + },
122 + {
123 + name: "Complex Unicode text with many combining marks (91 runes, no truncation)",
124 + input: "ț̸̢͙̞̖̏̔ȩ̶̰͓̪͎̱̠̥̳͔̽̀̃̿̌̾̀͗̕̕͜s̵̢̛̖̬͈͉͖͇͈̭̥̃́̓̌̾͊̊̂̄̍̅̂͌́ͅţ̴̯̹̪͖͓̘̊́̑̄̋̈́͐̈́̔̇̄̂́̎̓͛͠ͅ test",
125 + expected: "ț̸̢͙̞̖̏̔ȩ̶̰͓̪͎̱̠̥̳͔̽̀̃̿̌̾̀͗̕̕͜s̵̢̛̖̬͈͉͖͇͈̭̥̃́̓̌̾͊̊̂̄̍̅̂͌́ͅţ̴̯̹̪͖͓̘̊́̑̄̋̈́͐̈́̔̇̄̂́̎̓͛͠ͅ test", // Not truncated (91 < 128)
126 + },
127 + {
128 + name: "Truncation at 128 characters",
129 + input: strings.Repeat("a", 150),
130 + expected: strings.Repeat("a", 128),
131 + },
132 + {
133 + name: "Truncation with Unicode at 128",
134 + input: strings.Repeat("🚀", 150),
135 + expected: strings.Repeat("🚀", 128),
136 + },
137 + {
138 + name: "Private use characters preserved (per spec)",
139 + input: "test\uE000\uF8FF", // Private use area characters
140 + expected: "test\uE000\uF8FF", // Should be preserved
141 + },
142 + {
143 + name: "U+FFFD replacement for multiple categories",
144 + input: "a\x00b\u200Cc\u202Ed", // control, format chars
145 + expected: "a\uFFFDb\uFFFDc\uFFFDd", // All replaced with U+FFFD
146 + },
147 + }
148 +
149 + for _, tt := range tests {
150 + t.Run(tt.name, func(t *testing.T) {
151 + result := cmdutils.CleanAndTrim(tt.input)
152 + assert.Equal(t, tt.expected, result, "CleanAndTrim(%q) = %q, want %q", tt.input, result, tt.expected)
153 + })
154 + }
155 +}
156 +
157 +func TestCleanAndTrimIdempotent(t *testing.T) {
158 + // Test that applying CleanAndTrim twice gives the same result
159 + inputs := []string{
160 + "test-ąęćłńóśźż",
161 + "版本-中文测试",
162 + "version-1.0-🚀-🎉",
163 + "h̸e̵l̷l̶o̴ w̸o̵r̷l̶d̴",
164 + "test\u200Bzero\u200Cwidth",
165 + }
166 +
167 + for _, input := range inputs {
168 + once := cmdutils.CleanAndTrim(input)
169 + twice := cmdutils.CleanAndTrim(once)
170 + assert.Equal(t, once, twice, "CleanAndTrim should be idempotent for %q", input)
171 + }
172 +}
173 +
174 +func TestCleanAndTrimSecurity(t *testing.T) {
175 + // Test that all invisible/dangerous characters are removed
176 + tests := []struct {
177 + name string
178 + input string
179 + check func(string) bool
180 + }{
181 + {
182 + name: "No zero-width spaces",
183 + input: "test\u200B\u200C\u200Dtest",
184 + check: func(s string) bool {
185 + return !strings.Contains(s, "\u200B") && !strings.Contains(s, "\u200C") && !strings.Contains(s, "\u200D")
186 + },
187 + },
188 + {
189 + name: "No bidi overrides",
190 + input: "test\u202A\u202B\u202C\u202D\u202Etest",
191 + check: func(s string) bool {
192 + for _, r := range []rune{0x202A, 0x202B, 0x202C, 0x202D, 0x202E} {
193 + if strings.ContainsRune(s, r) {
194 + return false
195 + }
196 + }
197 + return true
198 + },
199 + },
200 + {
201 + name: "No control characters",
202 + input: "test\x00\x01\x02\x1F\x7Ftest",
203 + check: func(s string) bool {
204 + for _, r := range s {
205 + if r < 0x20 || r == 0x7F {
206 + return false
207 + }
208 + }
209 + return true
210 + },
211 + },
212 + }
213 +
214 + for _, tt := range tests {
215 + t.Run(tt.name, func(t *testing.T) {
216 + result := cmdutils.CleanAndTrim(tt.input)
217 + assert.True(t, tt.check(result), "Security check failed for %q -> %q", tt.input, result)
218 + })
219 + }
220 +}
test/dependencies/go.mod
+1
@@ -141,6 +141,7 @@ require (
141 github.com/ipfs/go-cid v0.5.0 // indirect
142 github.com/ipfs/go-datastore v0.9.0 // indirect
143 github.com/ipfs/go-dsqueue v0.0.5 // indirect
144 + github.com/ipfs/go-ipfs-cmds v0.15.0 // indirect
145 github.com/ipfs/go-ipfs-redirects-file v0.1.2 // indirect
146 github.com/ipfs/go-ipld-cbor v0.2.1 // indirect
147 github.com/ipfs/go-ipld-format v0.6.3 // indirect
test/dependencies/go.sum
+2
@@ -350,6 +350,8 @@ github.com/ipfs/go-dsqueue v0.0.5 h1:TUOk15TlCJ/NKV8Yk2W5wgkEjDa44Nem7a7FGIjsMNU
350 github.com/ipfs/go-dsqueue v0.0.5/go.mod h1:i/jAlpZjBbQJLioN+XKbFgnd+u9eAhGZs9IrqIzTd9g=
351 github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ=
352 github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE=
353 +github.com/ipfs/go-ipfs-cmds v0.15.0 h1:nQDgKadrzyiFyYoZMARMIoVoSwe3gGTAfGvrWLeAQbQ=
354 +github.com/ipfs/go-ipfs-cmds v0.15.0/go.mod h1:VABf/mv/wqvYX6hLG6Z+40eNAEw3FQO0bSm370Or3Wk=
355 github.com/ipfs/go-ipfs-delay v0.0.1 h1:r/UXYyRcddO6thwOnhiznIAiSvxMECGgtv35Xs1IeRQ=
356 github.com/ipfs/go-ipfs-delay v0.0.1/go.mod h1:8SP1YXK1M1kXuc4KJZINY3TQQ03J2rwBG9QfXmbRPrw=
357 github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw=
version.go
+4 -2
@@ -3,6 +3,8 @@ package ipfs
3 import (
4 "fmt"
5 "runtime"
6 +
7 + "github.com/ipfs/kubo/core/commands/cmdutils"
8 )
9
10 // CurrentCommit is the current git commit, this is set as a ldflag in the Makefile.
@@ -27,13 +29,13 @@ func GetUserAgentVersion() string {
29 }
30 userAgent += userAgentSuffix
31 }
30 - return userAgent
32 + return cmdutils.CleanAndTrim(userAgent)
33 }
34
35 var userAgentSuffix string
36
37 func SetUserAgentSuffix(suffix string) {
36 - userAgentSuffix = suffix
38 + userAgentSuffix = cmdutils.CleanAndTrim(suffix)
39 }
40
41 type VersionInfo struct {