Skip to content

Commit c6d24ff

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 c6d24ff

File tree

2 files changed

+228
-2
lines changed

2 files changed

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

0 commit comments

Comments
 (0)