Skip to content

Commit 98d11c5

Browse files
committed
feat: add YAML anchors and aliases support to ClusterConfig
- Add resolveYAMLAnchors() function to process YAML anchors and aliases - Include security protections against YAML bombs and excessive nesting - Add comprehensive test coverage for functionality and security Fixes #8270
1 parent 5f55c80 commit 98d11c5

File tree

2 files changed

+233
-2
lines changed

2 files changed

+233
-2
lines changed

pkg/eks/api.go

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,18 +212,133 @@ func newAWSProvider(spec *api.ProviderConfig, configurationLoader AWSConfigurati
212212
return provider, nil
213213
}
214214

215+
// resolveYAMLAnchors processes YAML anchors and aliases, returning clean YAML
216+
// that can be safely parsed by strict unmarshaling. It removes the "aliases"
217+
// field commonly used for anchor definitions as it's not part of ClusterConfig schema.
218+
func resolveYAMLAnchors(data []byte) ([]byte, error) {
219+
// Security: Limit input size to prevent memory exhaustion attacks
220+
const maxInputSize = 1024 * 1024 // 1MB limit
221+
if len(data) > maxInputSize {
222+
return nil, fmt.Errorf("YAML input too large: %d bytes exceeds limit of %d bytes", len(data), maxInputSize)
223+
}
224+
225+
// Security: Check for excessive nesting depth to prevent stack overflow
226+
const maxNestingDepth = 5
227+
if nestingDepth := countNestingDepth(data); nestingDepth > maxNestingDepth {
228+
return nil, fmt.Errorf("YAML nesting too deep: %d levels exceeds limit of %d", nestingDepth, maxNestingDepth)
229+
}
230+
231+
// Resolve YAML anchors and aliases by unmarshaling to interface{} first.
232+
// This step processes any YAML anchors (&anchor) and aliases (*alias) in the input,
233+
// expanding them to their full values.
234+
var resolved interface{}
235+
if err := yaml.Unmarshal(data, &resolved); err != nil {
236+
return nil, err
237+
}
238+
239+
// Marshal back to get resolved YAML without anchors/aliases
240+
resolvedData, err := yaml.Marshal(resolved)
241+
if err != nil {
242+
return nil, err
243+
}
244+
245+
// Security: Check for excessive expansion (YAML bomb protection)
246+
const maxExpansionRatio = 5
247+
if len(resolvedData) > len(data)*maxExpansionRatio {
248+
return nil, fmt.Errorf("YAML expansion too large: %d bytes expanded from %d bytes (ratio: %d, limit: %d)",
249+
len(resolvedData), len(data), len(resolvedData)/len(data), maxExpansionRatio)
250+
}
251+
252+
// Remove any top-level fields that are not part of ClusterConfig schema.
253+
// The "aliases" field is commonly used to define YAML anchors but is not
254+
// a valid ClusterConfig field, so we filter it out after anchor resolution.
255+
var temp map[string]interface{}
256+
if err := yaml.Unmarshal(resolvedData, &temp); err != nil {
257+
return nil, err
258+
}
259+
260+
// Security: Remove any non-standard top-level fields that could bypass validation
261+
allowedTopLevelFields := map[string]bool{
262+
"accessConfig": true,
263+
"addons": true,
264+
"addonsConfig": true,
265+
"apiVersion": true,
266+
"autoModeConfig": true,
267+
"availabilityZones": true,
268+
"cloudWatch": true,
269+
"fargateProfiles": true,
270+
"gitops": true,
271+
"iam": true,
272+
"iamIdentityMappings": true,
273+
"identityProviders": true,
274+
"karpenter": true,
275+
"kind": true,
276+
"kubernetesNetworkConfig": true,
277+
"localZones": true,
278+
"managedNodeGroups": true,
279+
"metadata": true,
280+
"nodeGroups": true,
281+
"outpost": true,
282+
"privateCluster": true,
283+
"remoteNetworkConfig": true,
284+
"secretsEncryption": true,
285+
"vpc": true,
286+
"zonalShiftConfig": true,
287+
}
288+
289+
for field := range temp {
290+
if !allowedTopLevelFields[field] {
291+
delete(temp, field)
292+
}
293+
}
294+
295+
// Marshal back to clean YAML
296+
return yaml.Marshal(temp)
297+
}
298+
299+
// countNestingDepth estimates YAML nesting depth by counting indentation
300+
func countNestingDepth(data []byte) int {
301+
lines := strings.Split(string(data), "\n")
302+
maxDepth := 0
303+
for _, line := range lines {
304+
if strings.TrimSpace(line) == "" || strings.HasPrefix(strings.TrimSpace(line), "#") {
305+
continue
306+
}
307+
depth := 0
308+
for _, char := range line {
309+
if char == ' ' {
310+
depth++
311+
} else if char == '\t' {
312+
depth += 2 // Count tabs as 2 spaces
313+
} else {
314+
break
315+
}
316+
}
317+
if depth/2 > maxDepth { // Assuming 2-space indentation
318+
maxDepth = depth / 2
319+
}
320+
}
321+
return maxDepth
322+
}
323+
215324
// ParseConfig parses data into a ClusterConfig
216325
func ParseConfig(data []byte) (*api.ClusterConfig, error) {
326+
// Resolve YAML anchors and aliases before parsing
327+
cleanData, err := resolveYAMLAnchors(data)
328+
if err != nil {
329+
return nil, err
330+
}
331+
217332
// strict mode is not available in runtime.Decode, so we use the parser
218333
// directly; we don't store the resulting object, this is just the means
219334
// of detecting any unknown keys
220335
// NOTE: we must use sigs.k8s.io/yaml, as it behaves differently from
221336
// github.com/ghodss/yaml, which didn't handle nested structs well
222-
if err := yaml.UnmarshalStrict(data, &api.ClusterConfig{}); err != nil {
337+
if err := yaml.UnmarshalStrict(cleanData, &api.ClusterConfig{}); err != nil {
223338
return nil, err
224339
}
225340

226-
obj, err := runtime.Decode(scheme.Codecs.UniversalDeserializer(), data)
341+
obj, err := runtime.Decode(scheme.Codecs.UniversalDeserializer(), cleanData)
227342
if err != nil {
228343
return nil, err
229344
}

pkg/eks/api_anchors_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package eks
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"testing"
7+
8+
. "github.com/onsi/ginkgo/v2"
9+
. "github.com/onsi/gomega"
10+
11+
api "github.com/weaveworks/eksctl/pkg/apis/eksctl.io/v1alpha5"
12+
)
13+
14+
func TestYAMLAnchorsAndAliases(t *testing.T) {
15+
RegisterFailHandler(Fail)
16+
RunSpecs(t, "YAML Anchors and Aliases Suite")
17+
}
18+
19+
var _ = Describe("ParseConfig with YAML anchors and aliases", func() {
20+
BeforeEach(func() {
21+
err := api.Register()
22+
Expect(err).NotTo(HaveOccurred())
23+
})
24+
25+
It("should parse ClusterConfig with YAML anchors and aliases", func() {
26+
yamlWithAnchors := `---
27+
apiVersion: eksctl.io/v1alpha5
28+
kind: ClusterConfig
29+
30+
aliases:
31+
genericAttachPolicyARNs: &genericAttachPolicyARNs
32+
- arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly
33+
- arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy
34+
35+
genericNodeGroupSettings: &genericNodeGroupSettings
36+
minSize: 2
37+
maxSize: 5
38+
desiredCapacity: 2
39+
volumeSize: 30
40+
volumeType: gp3
41+
instanceTypes:
42+
- "t3a.small"
43+
- "t3.small"
44+
45+
metadata:
46+
name: eks-demo
47+
region: us-east-1
48+
49+
managedNodeGroups:
50+
- name: generic-1
51+
<<: *genericNodeGroupSettings
52+
iam:
53+
attachPolicyARNs: *genericAttachPolicyARNs
54+
55+
- name: generic-2
56+
<<: *genericNodeGroupSettings
57+
iam:
58+
attachPolicyARNs: *genericAttachPolicyARNs`
59+
60+
cfg, err := ParseConfig([]byte(yamlWithAnchors))
61+
Expect(err).NotTo(HaveOccurred())
62+
Expect(cfg).NotTo(BeNil())
63+
Expect(cfg.Metadata.Name).To(Equal("eks-demo"))
64+
Expect(cfg.Metadata.Region).To(Equal("us-east-1"))
65+
Expect(cfg.ManagedNodeGroups).To(HaveLen(2))
66+
67+
// Verify first node group
68+
ng1 := cfg.ManagedNodeGroups[0]
69+
Expect(ng1.Name).To(Equal("generic-1"))
70+
Expect(*ng1.MinSize).To(Equal(2))
71+
Expect(*ng1.MaxSize).To(Equal(5))
72+
Expect(*ng1.DesiredCapacity).To(Equal(2))
73+
Expect(*ng1.VolumeSize).To(Equal(30))
74+
Expect(*ng1.VolumeType).To(Equal("gp3"))
75+
Expect(ng1.InstanceTypes).To(Equal([]string{"t3a.small", "t3.small"}))
76+
Expect(ng1.IAM.AttachPolicyARNs).To(Equal([]string{
77+
"arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly",
78+
"arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy",
79+
}))
80+
81+
// Verify second node group has same settings
82+
ng2 := cfg.ManagedNodeGroups[1]
83+
Expect(ng2.Name).To(Equal("generic-2"))
84+
Expect(*ng2.MinSize).To(Equal(2))
85+
Expect(*ng2.MaxSize).To(Equal(5))
86+
Expect(*ng2.DesiredCapacity).To(Equal(2))
87+
Expect(*ng2.VolumeSize).To(Equal(30))
88+
Expect(*ng2.VolumeType).To(Equal("gp3"))
89+
Expect(ng2.InstanceTypes).To(Equal([]string{"t3a.small", "t3.small"}))
90+
Expect(ng2.IAM.AttachPolicyARNs).To(Equal([]string{
91+
"arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly",
92+
"arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy",
93+
}))
94+
})
95+
96+
It("should reject YAML input that is too large", func() {
97+
// Create a YAML that exceeds the 1MB limit
98+
largeYAML := "apiVersion: eksctl.io/v1alpha5\nkind: ClusterConfig\nmetadata:\n name: " + strings.Repeat("a", 1025*1024)
99+
_, err := ParseConfig([]byte(largeYAML))
100+
Expect(err).To(HaveOccurred())
101+
Expect(err.Error()).To(ContainSubstring("YAML input too large"))
102+
})
103+
104+
It("should reject YAML with excessive nesting depth", func() {
105+
// Create deeply nested YAML that exceeds the 5 level limit
106+
deepYAML := "apiVersion: eksctl.io/v1alpha5\nkind: ClusterConfig\nmetadata:\n name: test\n"
107+
for i := 0; i < 7; i++ {
108+
deepYAML += strings.Repeat(" ", i) + "level" + fmt.Sprintf("%d:\n", i)
109+
}
110+
deepYAML += strings.Repeat(" ", 7) + "value: deep"
111+
112+
_, err := ParseConfig([]byte(deepYAML))
113+
Expect(err).To(HaveOccurred())
114+
Expect(err.Error()).To(ContainSubstring("YAML nesting too deep"))
115+
})
116+
})

0 commit comments

Comments
 (0)