Skip to content

Commit b9ff2d9

Browse files
authored
Merge pull request #108 from jongalloway/copilot/sub-pr-106
fix: address icon pack review feedback — sanitizer correctness, registry atomicity, determinism, color consistency, XSS fix
2 parents 25932dd + 302a1d4 commit b9ff2d9

7 files changed

Lines changed: 68 additions & 30 deletions

File tree

src/DiagramForge/DiagramRenderer.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,18 @@ private void ResolveIcons(Diagram diagram)
210210
foreach (var node in diagram.Nodes.Values)
211211
{
212212
if (node.IconRef is not null)
213-
node.ResolvedIcon = IconRegistry.Resolve(node.IconRef);
213+
{
214+
var icon = IconRegistry.Resolve(node.IconRef);
215+
if (icon is not null)
216+
{
217+
// Sanitize the SVG content before placing it into the output SVG
218+
// to prevent XSS / injection from untrusted diagram text or icon providers.
219+
string? sanitized = SvgIconSanitizer.Sanitize(icon.SvgContent);
220+
node.ResolvedIcon = sanitized is not null
221+
? icon with { SvgContent = sanitized }
222+
: null;
223+
}
224+
}
214225
}
215226
}
216227

src/DiagramForge/Icons/SvgIconSanitizer.cs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,13 @@ internal static class SvgIconSanitizer
3030
/// Sanitizes an SVG fragment, removing unsafe elements and attributes.
3131
/// Returns <see langword="null"/> if the content is entirely unsafe or cannot be parsed.
3232
/// </summary>
33-
public static string? Sanitize(string svgContent)
33+
public static string? Sanitize(string? svgContent)
3434
{
3535
if (string.IsNullOrWhiteSpace(svgContent))
3636
return null;
3737

3838
XElement root;
39+
bool addedWrapper = false;
3940
try
4041
{
4142
// Wrap bare fragments in <svg> if needed for parsing.
@@ -50,14 +51,15 @@ internal static class SvgIconSanitizer
5051
XmlResolver = null,
5152
};
5253

53-
// Try parsing as-is; if that fails, wrap in an svg element.
54+
// Try parsing as-is; if that fails, wrap in a <g> for multi-element fragments.
5455
try
5556
{
5657
using var reader = XmlReader.Create(new System.IO.StringReader(svgContent), settings);
5758
root = XElement.Load(reader, LoadOptions.PreserveWhitespace);
5859
}
5960
catch (XmlException)
6061
{
62+
addedWrapper = true;
6163
using var reader = XmlReader.Create(new System.IO.StringReader($"<g>{svgContent}</g>"), settings);
6264
root = XElement.Load(reader, LoadOptions.PreserveWhitespace);
6365
}
@@ -81,11 +83,15 @@ internal static class SvgIconSanitizer
8183
ConformanceLevel = ConformanceLevel.Fragment,
8284
}))
8385
{
84-
// Write the inner content only (skip the wrapper if we added one).
85-
if (root.Name.LocalName.Equals("svg", StringComparison.OrdinalIgnoreCase)
86-
|| root.Name.LocalName.Equals("g", StringComparison.OrdinalIgnoreCase))
86+
// Strip the outer <svg> wrapper to prevent nested <svg> in the rendered output
87+
// (DiagramIcon.SvgContent contract: no outer <svg>).
88+
// Also strip the internal parser-added <g> wrapper — it was not part of the
89+
// original content. Original <g> elements from the input are preserved as-is
90+
// so that attributes like fill="none" stroke="currentColor" are not lost.
91+
if (root.Name.LocalName.Equals("svg", StringComparison.OrdinalIgnoreCase) || addedWrapper)
8792
{
88-
root.WriteTo(writer);
93+
foreach (var child in root.Nodes())
94+
child.WriteTo(writer);
8995
}
9096
else
9197
{

src/DiagramForge/Layout/DefaultLayoutEngine.Architecture.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ private static void LayoutArchitectureDiagram(
2828
// Add space for the icon above the label.
2929
if (node.ResolvedIcon is not null)
3030
{
31-
double iconArea = SvgNodeWriter.DefaultIconSize + 6; // icon + gap
31+
double iconArea = SvgNodeWriter.DefaultIconSize + SvgNodeWriter.IconLabelGap;
3232
node.Height += iconArea;
3333
node.Width = Math.Max(node.Width, SvgNodeWriter.DefaultIconSize + 2 * theme.NodePadding);
3434
}

src/DiagramForge/Models/IconRegistry.cs

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,33 @@ public void RegisterPack(string packName, IIconProvider provider)
2727

2828
/// <summary>
2929
/// Registers an icon provider under both <paramref name="packName"/> and <paramref name="alias"/>.
30+
/// Both names are validated before either is registered to ensure an atomic operation.
3031
/// </summary>
3132
public void RegisterPack(string packName, string alias, IIconProvider provider)
3233
{
33-
RegisterPack(packName, provider);
34-
34+
ArgumentException.ThrowIfNullOrWhiteSpace(packName);
3535
ArgumentException.ThrowIfNullOrWhiteSpace(alias);
36+
ArgumentNullException.ThrowIfNull(provider);
37+
38+
// Validate both names up-front before touching the registry to keep the
39+
// operation atomic — partial registration is not observable to callers.
40+
if (_packs.ContainsKey(packName))
41+
throw new ArgumentException($"An icon pack named '{packName}' is already registered.", nameof(packName));
42+
if (_packs.ContainsKey(alias))
43+
throw new ArgumentException($"An icon pack named '{alias}' is already registered.", nameof(alias));
44+
if (string.Equals(packName, alias, StringComparison.OrdinalIgnoreCase))
45+
throw new ArgumentException($"Pack name and alias must be different.", nameof(alias));
46+
47+
// TryAdd may still fail under concurrent load, but the existing single-name
48+
// overload already throws in that case, so the behaviour is consistent.
49+
if (!_packs.TryAdd(packName, provider))
50+
throw new ArgumentException($"An icon pack named '{packName}' is already registered.", nameof(packName));
51+
3652
if (!_packs.TryAdd(alias, provider))
53+
{
54+
_packs.TryRemove(packName, out _);
3755
throw new ArgumentException($"An icon pack named '{alias}' is already registered.", nameof(alias));
56+
}
3857
}
3958

4059
/// <summary>
@@ -62,10 +81,10 @@ public void RegisterPack(string packName, string alias, IIconProvider provider)
6281
return null;
6382
}
6483

65-
// Bare name — search all packs.
66-
foreach (var provider in _packs.Values)
84+
// Bare name — search all packs in sorted name order for deterministic results.
85+
foreach (var packName in _packs.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase))
6786
{
68-
var icon = provider.GetIcon(reference);
87+
var icon = _packs[packName].GetIcon(reference);
6988
if (icon is not null)
7089
return icon;
7190
}

src/DiagramForge/Rendering/SvgNodeWriter.cs

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ internal static class SvgNodeWriter
88
private const double DefaultLabelLineHeight = 1.15;
99
private const double AnnotationFontSizeRatio = 0.85;
1010
internal const double DefaultIconSize = 48;
11-
private const double IconLabelGap = 6;
11+
internal const double IconLabelGap = 6;
1212

1313
internal static void AppendNode(StringBuilder sb, Node node, Theme theme, int nodeIndex = 0)
1414
{
@@ -151,7 +151,7 @@ internal static void AppendNode(StringBuilder sb, Node node, Theme theme, int no
151151
double iconAreaHeight = 0;
152152
if (hasIcon && !textOnly)
153153
{
154-
AppendNodeIcon(sb, node, theme);
154+
AppendNodeIcon(sb, node, theme, resolvedTextColor);
155155
iconAreaHeight = DefaultIconSize + IconLabelGap;
156156
}
157157

@@ -204,7 +204,11 @@ private static void AppendNodeLabel(
204204
/// Renders a resolved icon inside a node, centered horizontally and positioned
205205
/// in the upper portion to leave room for the label below.
206206
/// </summary>
207-
private static void AppendNodeIcon(StringBuilder sb, Node node, Theme theme)
207+
/// <param name="resolvedTextColor">
208+
/// The already-resolved text color for the node (same value used for the label),
209+
/// ensuring icon and label use a consistent, contrast-aware color.
210+
/// </param>
211+
private static void AppendNodeIcon(StringBuilder sb, Node node, Theme theme, string resolvedTextColor)
208212
{
209213
var icon = node.ResolvedIcon;
210214
if (icon is null)
@@ -218,13 +222,10 @@ private static void AppendNodeIcon(StringBuilder sb, Node node, Theme theme)
218222
string[] viewBoxParts = icon.ViewBox.Split(' ', StringSplitOptions.RemoveEmptyEntries);
219223
string viewBox = viewBoxParts.Length == 4 ? icon.ViewBox : "0 0 24 24";
220224

221-
// Use theme-aware icon color (the icon uses currentColor for stroke/fill).
222-
string iconColor = SvgRenderSupport.Escape(
223-
SvgRenderSupport.ResolveNodeTextColor(
224-
node.FillColor ?? theme.NodeFillColor, theme));
225-
225+
// Use the same resolved text color as the label so icon and label are always consistent,
226+
// including when the node fill is sourced from a theme palette.
226227
sb.AppendLine($""" <g transform="translate({SvgRenderSupport.F(iconX)},{SvgRenderSupport.F(iconY)})">""");
227-
sb.AppendLine($""" <svg width="{SvgRenderSupport.F(iconSize)}" height="{SvgRenderSupport.F(iconSize)}" viewBox="{SvgRenderSupport.Escape(viewBox)}" overflow="visible" color="{iconColor}">""");
228+
sb.AppendLine($""" <svg width="{SvgRenderSupport.F(iconSize)}" height="{SvgRenderSupport.F(iconSize)}" viewBox="{SvgRenderSupport.Escape(viewBox)}" overflow="visible" color="{resolvedTextColor}">""");
228229
sb.AppendLine($" {icon.SvgContent}");
229230
sb.AppendLine(" </svg>");
230231
sb.AppendLine(" </g>");

tests/DiagramForge.Tests/DiagramRendererIconTests.cs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ public void Render_ArchitectureDiagram_ResolvesBuiltInIcon(string icon)
1818
var renderer = new DiagramRenderer();
1919
string svg = renderer.Render($"architecture-beta\n service svc({icon})[Label]");
2020

21-
// The SVG should contain the icon content rendered inside the node.
22-
Assert.Contains("viewBox", svg);
21+
// The SVG should contain an icon <svg> element with the 48px icon size.
22+
Assert.Contains("width=\"48.00\"", svg);
2323
}
2424

2525
[Fact]
@@ -49,11 +49,12 @@ public void RegisterIconPack_IconResolvesInRender()
4949
var renderer = new DiagramRenderer();
5050
renderer.RegisterIconPack("custom", new StubIconProvider("myicon"));
5151

52-
// Architecture diagram referencing custom:myicon — the parser treats
53-
// the icon keyword as a string, so the reference stays as-is.
54-
// For now, built-in architecture icons use bare names.
55-
// Custom pack icons would be used via future parser support for pack:name syntax.
56-
Assert.Contains("custom", renderer.IconRegistry.RegisteredPacks);
52+
// Architecture diagram referencing "custom:myicon" — parser captures the
53+
// pack:name token verbatim, so the registry resolves it during rendering.
54+
string svg = renderer.Render("architecture-beta\n service svc(custom:myicon)[Label]");
55+
56+
// The stub icon's path content must appear in the rendered SVG.
57+
Assert.Contains("d=\"M0 0\"", svg);
5758
}
5859

5960
[Fact]

tests/DiagramForge.Tests/Icons/SvgIconSanitizerTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ public void Sanitize_JavascriptUri_IsRemoved()
9696
[Fact]
9797
public void Sanitize_Null_ReturnsNull()
9898
{
99-
Assert.Null(SvgIconSanitizer.Sanitize(null!));
99+
Assert.Null(SvgIconSanitizer.Sanitize(null));
100100
}
101101

102102
[Fact]

0 commit comments

Comments
 (0)