Skip to content

Commit f74eeba

Browse files
authored
Merge pull request #174 from datum-cloud/fix/validate-base62-format
fix: validate VPC and VPCAttachment base62 format in parseConf
2 parents 59b1c7f + e4a4981 commit f74eeba

2 files changed

Lines changed: 201 additions & 14 deletions

File tree

internal/cni/cni_test.go

Lines changed: 152 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@ import (
2020
)
2121

2222
const (
23-
testVPC = "abc"
24-
testAttachment = "def"
25-
testVPCHex1234 = "0000000004d2" // decimal 1234
26-
testRD65000_1 = "65000:1" // RD/RT for ASN 65000, NN 1
27-
testContainerID = "test-container"
23+
testVPC = "abc"
24+
testAttachment = "def"
25+
testVPCHex1234 = "0000000004d2" // decimal 1234
26+
testRD65000_1 = "65000:1" // RD/RT for ASN 65000, NN 1
27+
testContainerID = "test-container"
28+
testInvalidBase62 = "abc-def" // shared invalid base62 string for tests
2829
)
2930

3031
func fakeClient(objs ...client.Object) client.Client {
@@ -127,6 +128,82 @@ func TestParseConf(t *testing.T) {
127128
),
128129
wantErr: `invalid interface_type "unknown": must be "veth" or "tap"`,
129130
},
131+
{
132+
name: "missing vpc",
133+
input: fmt.Sprintf(
134+
`{"cniVersion":"1.0.0","name":"test",`+
135+
`"type":"galactic-cni","vpcattachment":"%s"}`,
136+
testAttachment,
137+
),
138+
wantErr: "vpc is required and must be a non-empty base62 string",
139+
},
140+
{
141+
name: "empty vpc",
142+
input: fmt.Sprintf(
143+
`{"cniVersion":"1.0.0","name":"test",`+
144+
`"type":"galactic-cni","vpc":"",`+
145+
`"vpcattachment":"%s"}`,
146+
testAttachment,
147+
),
148+
wantErr: "vpc is required and must be a non-empty base62 string",
149+
},
150+
{
151+
name: "vpc with invalid char hyphen",
152+
input: fmt.Sprintf(
153+
`{"cniVersion":"1.0.0","name":"test",`+
154+
`"type":"galactic-cni","vpc":"%s",`+
155+
`"vpcattachment":"%s"}`,
156+
testInvalidBase62, testAttachment,
157+
),
158+
wantErr: fmt.Sprintf("invalid base62 value for field 'vpc': %q", testInvalidBase62),
159+
},
160+
{
161+
name: "vpc with invalid char underscore",
162+
input: fmt.Sprintf(
163+
`{"cniVersion":"1.0.0","name":"test",`+
164+
`"type":"galactic-cni","vpc":"abc_def",`+
165+
`"vpcattachment":"%s"}`,
166+
testAttachment,
167+
),
168+
wantErr: `invalid base62 value for field 'vpc': "abc_def"`,
169+
},
170+
{
171+
name: "missing vpcattachment",
172+
input: fmt.Sprintf(
173+
`{"cniVersion":"1.0.0","name":"test",`+
174+
`"type":"galactic-cni","vpc":"%s"}`,
175+
testVPC,
176+
),
177+
wantErr: "vpcattachment is required and must be a non-empty base62 string",
178+
},
179+
{
180+
name: "empty vpcattachment",
181+
input: fmt.Sprintf(
182+
`{"cniVersion":"1.0.0","name":"test",`+
183+
`"type":"galactic-cni","vpc":"%s",`+
184+
`"vpcattachment":""}`,
185+
testVPC,
186+
),
187+
wantErr: "vpcattachment is required and must be a non-empty base62 string",
188+
},
189+
{
190+
name: "vpcattachment with invalid char space",
191+
input: fmt.Sprintf(
192+
`{"cniVersion":"1.0.0","name":"test",`+
193+
`"type":"galactic-cni","vpc":"%s",`+
194+
`"vpcattachment":"def ghi"}`,
195+
testVPC,
196+
),
197+
wantErr: `invalid base62 value for field 'vpcattachment': "def ghi"`,
198+
},
199+
{
200+
name: "valid vpc and vpcattachment with mixed case base62",
201+
input: `{"cniVersion":"1.0.0","name":"test",` +
202+
`"type":"galactic-cni","vpc":"Abc123XYZ",` +
203+
`"vpcattachment":"DeF456"}`,
204+
wantVPC: "Abc123XYZ",
205+
wantIfType: interfaceTypeVeth,
206+
},
130207
}
131208

132209
for _, tt := range tests {
@@ -154,6 +231,67 @@ func TestParseConf(t *testing.T) {
154231
}
155232
}
156233

234+
// ---- isValidBase62 -------------------------------------------------------
235+
236+
func TestIsValidBase62(t *testing.T) {
237+
tests := []struct {
238+
name string
239+
input string
240+
want bool
241+
}{
242+
{"empty", "", false},
243+
{"digits only", "1234567890", true},
244+
{"lowercase only", "abcdefghij", true},
245+
{"uppercase only", "ABCDEFGHIJ", true},
246+
{"mixed case", "aBcDeFgHiJ", true},
247+
{"mixed digits and letters", "abc123XYZ", true},
248+
{"hyphen", testInvalidBase62, false},
249+
{"underscore", "abc_def", false},
250+
{"space", "abc def", false},
251+
{"dot", "abc.def", false},
252+
{"slash", "abc/def", false},
253+
{"plus", "abc+def", false},
254+
{"equals", "abc=def", false},
255+
{"single digit", "0", true},
256+
{"single lowercase", "a", true},
257+
{"single uppercase", "Z", true},
258+
}
259+
260+
for _, tt := range tests {
261+
t.Run(tt.name, func(t *testing.T) {
262+
got := isValidBase62(tt.input)
263+
if got != tt.want {
264+
t.Errorf("isValidBase62(%q) = %v, want %v", tt.input, got, tt.want)
265+
}
266+
})
267+
}
268+
}
269+
270+
func TestSanitizeForError(t *testing.T) {
271+
printable := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()"
272+
tests := []struct {
273+
name string
274+
input string
275+
want string
276+
}{
277+
{"normal string", testInvalidBase62, testInvalidBase62},
278+
{"empty", "", ""},
279+
{"newline", "abc\ndef", sanitizeForErrorBinary},
280+
{"null byte", "abc\x00def", sanitizeForErrorBinary},
281+
{"del char", "abc\x7fdef", sanitizeForErrorBinary},
282+
{"printable range", printable, printable},
283+
}
284+
285+
for _, tt := range tests {
286+
t.Run(tt.name, func(t *testing.T) {
287+
got := sanitizeForError(tt.input)
288+
if got != tt.want {
289+
t.Errorf("sanitizeForError(%q) = %q, want %q", tt.input, got, tt.want)
290+
}
291+
})
292+
}
293+
}
294+
157295
// ---- bgpVRFInstanceName --------------------------------------------------
158296

159297
func TestBGPVRFInstanceName(t *testing.T) {
@@ -656,9 +794,9 @@ func TestCmdCheckMissingVPC(t *testing.T) {
656794
if err == nil {
657795
t.Fatalf("expected error for missing VPC, got nil")
658796
}
659-
// parseConf allows empty VPC; the error comes from intf generation.
660-
if !strings.Contains(err.Error(), "CHECK failed") {
661-
t.Fatalf("error %q does not contain 'CHECK failed'", err.Error())
797+
// parseConf now rejects empty VPC before CHECK runs.
798+
if !strings.Contains(err.Error(), "vpc is required") {
799+
t.Fatalf("error %q does not contain 'vpc is required'", err.Error())
662800
}
663801
}
664802

@@ -850,8 +988,9 @@ func TestCmdStatusMissingVPC(t *testing.T) {
850988
if err == nil {
851989
t.Fatalf("expected error for missing VPC, got nil")
852990
}
853-
if !strings.Contains(err.Error(), "STATUS failed") {
854-
t.Fatalf("error %q does not contain 'STATUS failed'", err.Error())
991+
// parseConf now rejects empty VPC before STATUS runs.
992+
if !strings.Contains(err.Error(), "vpc is required") {
993+
t.Fatalf("error %q does not contain 'vpc is required'", err.Error())
855994
}
856995
}
857996

@@ -870,7 +1009,8 @@ func TestCmdStatusMissingVPCAttachment(t *testing.T) {
8701009
if err == nil {
8711010
t.Fatalf("expected error for missing VPCAttachment, got nil")
8721011
}
873-
if !strings.Contains(err.Error(), "STATUS failed") {
874-
t.Fatalf("error %q does not contain 'STATUS failed'", err.Error())
1012+
// parseConf now rejects empty VPCAttachment before STATUS runs.
1013+
if !strings.Contains(err.Error(), "vpcattachment is required") {
1014+
t.Fatalf("error %q does not contain 'vpcattachment is required'", err.Error())
8751015
}
8761016
}

internal/cni/config.go

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,53 @@ package cni
66

77
import (
88
"encoding/json"
9+
"errors"
910
"fmt"
1011
)
1112

13+
const sanitizeForErrorBinary = "<binary>"
14+
15+
// isValidBase62 reports whether s contains only valid base62 characters
16+
// ([0-9a-zA-Z]) and is non-empty. VPC and VPCAttachment identifiers are
17+
// base62-encoded and used throughout the ADD path (interface naming,
18+
// SRv6 SID encoding). Rejecting them early in parseConf prevents cryptic
19+
// errors deep in the stack after partial kernel state has been created.
20+
func isValidBase62(s string) bool {
21+
if len(s) == 0 {
22+
return false
23+
}
24+
for _, c := range s {
25+
if (c < '0' || c > '9') && (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') {
26+
return false
27+
}
28+
}
29+
return true
30+
}
31+
1232
// parseConf unmarshals the CNI configuration from stdin data and validates
13-
// the interface type. Returns an error if the config is malformed or
14-
// interface_type is not one of the supported values.
33+
// the interface type and base62-encoded identifier fields. Returns an error
34+
// if the config is malformed, interface_type is unsupported, or VPC/
35+
// VPCAttachment contain invalid characters.
1536
func parseConf(data []byte) (*PluginConf, error) {
1637
conf := &PluginConf{}
1738
if err := json.Unmarshal(data, &conf); err != nil {
1839
return nil, fmt.Errorf("parse CNI config: %w", err)
1940
}
41+
if !isValidBase62(conf.VPC) {
42+
// Use "empty" instead of "invalid" for a zero-length value so the
43+
// error message is actionable for the most common mistake (missing
44+
// field entirely).
45+
if len(conf.VPC) == 0 {
46+
return nil, errors.New("vpc is required and must be a non-empty base62 string")
47+
}
48+
return nil, fmt.Errorf("invalid base62 value for field 'vpc': %q", sanitizeForError(conf.VPC))
49+
}
50+
if !isValidBase62(conf.VPCAttachment) {
51+
if len(conf.VPCAttachment) == 0 {
52+
return nil, errors.New("vpcattachment is required and must be a non-empty base62 string")
53+
}
54+
return nil, fmt.Errorf("invalid base62 value for field 'vpcattachment': %q", sanitizeForError(conf.VPCAttachment))
55+
}
2056
if conf.InterfaceType == "" {
2157
conf.InterfaceType = interfaceTypeVeth
2258
}
@@ -31,6 +67,17 @@ func parseConf(data []byte) (*PluginConf, error) {
3167
return conf, nil
3268
}
3369

70+
// sanitizeForError returns s unchanged if it contains only printable ASCII
71+
// characters; otherwise returns "<binary>" to avoid corrupting log output.
72+
func sanitizeForError(s string) string {
73+
for _, c := range s {
74+
if c < 0x20 || c > 0x7e {
75+
return sanitizeForErrorBinary
76+
}
77+
}
78+
return s
79+
}
80+
3481
// subnetAnnotationKey returns the annotation key for storing the allocated
3582
// subnet for the given container ID. Kubernetes limits the name part of an
3683
// annotation key to 63 bytes; "allocated-subnet." is 17 bytes, leaving 46

0 commit comments

Comments
 (0)