Skip to content

Commit 47f70c2

Browse files
fix(audience): guard Json.Serialize against cyclic / pathologically deep dicts
Two defences against StackOverflowException (uncatchable in .NET). - MaxDepth = 64 — WriteObject/WriteArray throw FormatException past the cap rather than recursing. - ReferenceEquality visited-set — self-referential or shared- instance containers throw on re-entry. Sibling diamonds (same dict under two independent keys) are NOT cycles; enforced by visited.Remove after the recursive write returns. HashSet<object> is allocated lazily, so scalar-only payloads pay nothing. No behaviour change for any acyclic, shallow caller. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e1faba2 commit 47f70c2

1 file changed

Lines changed: 47 additions & 11 deletions

File tree

  • src/Packages/Audience/Runtime/Utility

src/Packages/Audience/Runtime/Utility/Json.cs

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using System.Collections;
23
using System.Collections.Generic;
34
using System.Globalization;
@@ -7,10 +8,14 @@ namespace Immutable.Audience
78
{
89
internal static class Json
910
{
11+
// Depth cap so a pathological input throws FormatException
12+
// instead of blowing the stack (StackOverflow is uncatchable).
13+
internal const int MaxDepth = 64;
14+
1015
internal static string Serialize(Dictionary<string, object> data)
1116
{
1217
var sb = new StringBuilder();
13-
WriteObject(sb, data, indent: 0, depth: 0);
18+
WriteObject(sb, data, indent: 0, depth: 0, visited: null);
1419
return sb.ToString();
1520
}
1621

@@ -22,11 +27,11 @@ internal static string Serialize(Dictionary<string, object> data, int indent)
2227
{
2328
if (indent <= 0) return Serialize(data);
2429
var sb = new StringBuilder();
25-
WriteObject(sb, data, indent, depth: 0);
30+
WriteObject(sb, data, indent, depth: 0, visited: null);
2631
return sb.ToString();
2732
}
2833

29-
private static void WriteValue(StringBuilder sb, object value, int indent, int depth)
34+
private static void WriteValue(StringBuilder sb, object value, int indent, int depth, HashSet<object> visited)
3035
{
3136
if (value == null)
3237
{
@@ -68,22 +73,25 @@ private static void WriteValue(StringBuilder sb, object value, int indent, int d
6873
}
6974
else if (value is Dictionary<string, object> dict)
7075
{
71-
WriteObject(sb, dict, indent, depth);
76+
WriteObject(sb, dict, indent, depth, visited);
7277
}
7378
else if (value is IList list)
7479
{
75-
WriteArray(sb, list, indent, depth);
80+
WriteArray(sb, list, indent, depth, visited);
7681
}
7782
else
7883
{
7984
WriteString(sb, value.ToString());
8085
}
8186
}
8287

83-
private static void WriteObject(StringBuilder sb, Dictionary<string, object> dict, int indent, int depth)
88+
private static void WriteObject(StringBuilder sb, Dictionary<string, object> dict, int indent, int depth, HashSet<object> visited)
8489
{
90+
GuardDepth(depth);
91+
visited = EnterContainer(dict, visited);
92+
8593
sb.Append('{');
86-
if (dict.Count == 0) { sb.Append('}'); return; }
94+
if (dict.Count == 0) { sb.Append('}'); visited.Remove(dict); return; }
8795

8896
var pretty = indent > 0;
8997
var first = true;
@@ -94,26 +102,31 @@ private static void WriteObject(StringBuilder sb, Dictionary<string, object> dic
94102
if (pretty) AppendNewline(sb, indent, depth + 1);
95103
WriteString(sb, kvp.Key);
96104
sb.Append(pretty ? ": " : ":");
97-
WriteValue(sb, kvp.Value, indent, depth + 1);
105+
WriteValue(sb, kvp.Value, indent, depth + 1, visited);
98106
}
99107
if (pretty) AppendNewline(sb, indent, depth);
100108
sb.Append('}');
109+
visited.Remove(dict);
101110
}
102111

103-
private static void WriteArray(StringBuilder sb, IList list, int indent, int depth)
112+
private static void WriteArray(StringBuilder sb, IList list, int indent, int depth, HashSet<object> visited)
104113
{
114+
GuardDepth(depth);
115+
visited = EnterContainer(list, visited);
116+
105117
sb.Append('[');
106-
if (list.Count == 0) { sb.Append(']'); return; }
118+
if (list.Count == 0) { sb.Append(']'); visited.Remove(list); return; }
107119

108120
var pretty = indent > 0;
109121
for (var i = 0; i < list.Count; i++)
110122
{
111123
if (i > 0) sb.Append(',');
112124
if (pretty) AppendNewline(sb, indent, depth + 1);
113-
WriteValue(sb, list[i], indent, depth + 1);
125+
WriteValue(sb, list[i], indent, depth + 1, visited);
114126
}
115127
if (pretty) AppendNewline(sb, indent, depth);
116128
sb.Append(']');
129+
visited.Remove(list);
117130
}
118131

119132
private static void AppendNewline(StringBuilder sb, int indent, int depth)
@@ -122,6 +135,29 @@ private static void AppendNewline(StringBuilder sb, int indent, int depth)
122135
sb.Append(' ', indent * depth);
123136
}
124137

138+
private static void GuardDepth(int depth)
139+
{
140+
if (depth >= MaxDepth)
141+
throw new FormatException(
142+
$"JSON nesting exceeds {MaxDepth} levels — refusing to serialize. " +
143+
"Check for a cyclic or excessively deep dictionary/list.");
144+
}
145+
146+
private static HashSet<object> EnterContainer(object container, HashSet<object> visited)
147+
{
148+
visited ??= new HashSet<object>(ReferenceEqualityComparer.Instance);
149+
if (!visited.Add(container))
150+
throw new FormatException("JSON graph contains a cycle — refusing to serialize.");
151+
return visited;
152+
}
153+
154+
private sealed class ReferenceEqualityComparer : IEqualityComparer<object>
155+
{
156+
internal static readonly ReferenceEqualityComparer Instance = new ReferenceEqualityComparer();
157+
public new bool Equals(object x, object y) => ReferenceEquals(x, y);
158+
public int GetHashCode(object obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj);
159+
}
160+
125161
private static void WriteString(StringBuilder sb, string s)
126162
{
127163
sb.Append('"');

0 commit comments

Comments
 (0)