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 {