fix(security): prevent alias depth bypass in YAML validator
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 38s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m18s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m20s
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 17s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 38s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m18s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m20s
The global 'seen' set allowed anchored subtrees validated at a shallow depth to be skipped when later referenced via alias at a greater depth. This could let effective nesting exceed MaxYAMLDepth, enabling DoS. Fix: replace the single 'seen' set with two tracking maps: - validated (node -> min depth): only short-circuits when current depth <= previously validated depth; re-checks at deeper contexts. - visiting (node -> bool): per-path recursion stack for true cycle detection (breaks alias loops without suppressing depth checks). Add TestYAMLAliasDepthBypass that constructs a document with an anchored 15-level subtree referenced via alias under 6 levels of nesting, verifying the combined effective depth (22) is rejected. Addresses security-review-bot findings on review #2774.
This commit is contained in:
+60
-13
@@ -484,7 +484,6 @@ func TestYAMLDeeplyNestedRejection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestYAMLEmptyFileRejection(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -536,7 +535,7 @@ func TestYAMLFileSizeLimit(t *testing.T) {
|
||||
|
||||
func TestYAMLAliasCycleDetection(t *testing.T) {
|
||||
// Test that our checkYAMLDepth function handles alias cycles gracefully
|
||||
// by using the seen map to prevent infinite recursion.
|
||||
// by using the visiting map to prevent infinite recursion.
|
||||
|
||||
// Create a node structure where an alias points to a parent node,
|
||||
// simulating what could happen with crafted input.
|
||||
@@ -559,17 +558,18 @@ func TestYAMLAliasCycleDetection(t *testing.T) {
|
||||
})
|
||||
|
||||
nodeCount := 0
|
||||
seen := make(map[ast.Node]struct{})
|
||||
validated := make(map[ast.Node]int)
|
||||
visiting := make(map[ast.Node]bool)
|
||||
|
||||
// This should NOT hang or stack overflow - the seen map prevents infinite recursion
|
||||
err := checkYAMLDepth(parent, 0, MaxYAMLDepth, MaxYAMLNodes, seen, &nodeCount)
|
||||
// This should NOT hang or stack overflow - cycle detection prevents infinite recursion
|
||||
err := checkYAMLDepth(parent, 0, MaxYAMLDepth, MaxYAMLNodes, validated, visiting, &nodeCount)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error traversing cyclic structure: %v", err)
|
||||
}
|
||||
|
||||
// Verify we tracked the parent in the seen map
|
||||
if _, ok := seen[parent]; !ok {
|
||||
t.Error("parent node not tracked in seen map")
|
||||
// Verify we tracked the parent in the validated map
|
||||
if _, ok := validated[parent]; !ok {
|
||||
t.Error("parent node not tracked in validated map")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,16 +644,63 @@ func TestCheckYAMLDepthCycleDetectionDirect(t *testing.T) {
|
||||
})
|
||||
|
||||
nodeCount := 0
|
||||
seen := make(map[ast.Node]struct{})
|
||||
err := checkYAMLDepth(node, 0, MaxYAMLDepth, MaxYAMLNodes, seen, &nodeCount)
|
||||
validated := make(map[ast.Node]int)
|
||||
visiting := make(map[ast.Node]bool)
|
||||
err := checkYAMLDepth(node, 0, MaxYAMLDepth, MaxYAMLNodes, validated, visiting, &nodeCount)
|
||||
|
||||
// Should complete without infinite recursion due to cycle detection
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
// The seen map should contain multiple entries
|
||||
if len(seen) < 2 {
|
||||
t.Errorf("seen map has %d entries, expected at least 2", len(seen))
|
||||
// The validated map should contain multiple entries
|
||||
if len(validated) < 2 {
|
||||
t.Errorf("validated map has %d entries, expected at least 2", len(validated))
|
||||
}
|
||||
}
|
||||
|
||||
func TestYAMLAliasDepthBypass(t *testing.T) {
|
||||
// Test that an anchored subtree first validated at a shallow depth is
|
||||
// re-checked when referenced via alias at a deeper position. Without the
|
||||
// depth-aware validated map, the alias reference would skip re-checking
|
||||
// and allow the effective nesting to exceed MaxYAMLDepth.
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "alias-depth-bypass.yaml")
|
||||
|
||||
// Build YAML with an anchor at shallow depth containing a subtree near the limit,
|
||||
// then reference it via alias deep enough that effective depth exceeds MaxYAMLDepth.
|
||||
var sb strings.Builder
|
||||
sb.WriteString("name: test\nidentity: test\n")
|
||||
|
||||
// Create the anchored subtree at depth 1 (key level) that nests 15 levels deep.
|
||||
sb.WriteString("anchor_key: &deep_anchor\n")
|
||||
for i := 0; i < 15; i++ {
|
||||
sb.WriteString(strings.Repeat(" ", i+1))
|
||||
sb.WriteString(fmt.Sprintf("level%d:\n", i))
|
||||
}
|
||||
sb.WriteString(strings.Repeat(" ", 16))
|
||||
sb.WriteString("leaf: value\n")
|
||||
|
||||
// Create a wrapper that nests 6 levels deep, then references the anchor.
|
||||
// Effective depth at alias target = 6 (wrapper nesting) + 1 (alias) + 15 (subtree) = 22 > 20
|
||||
sb.WriteString("wrapper:\n")
|
||||
for i := 0; i < 6; i++ {
|
||||
sb.WriteString(strings.Repeat(" ", i+1))
|
||||
sb.WriteString(fmt.Sprintf("n%d:\n", i))
|
||||
}
|
||||
sb.WriteString(strings.Repeat(" ", 7))
|
||||
sb.WriteString("alias_ref: *deep_anchor\n")
|
||||
|
||||
if err := os.WriteFile(path, []byte(sb.String()), 0644); err != nil {
|
||||
t.Fatalf("failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadPersona(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for alias depth bypass, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "nesting depth exceeds") {
|
||||
t.Errorf("error = %q, want containing 'nesting depth exceeds'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user