Skip to content

Commit e8e5a21

Browse files
Fix crashes, DoS, and security issues from Fable code review
Parallel sub-agent review surfaced several real bugs; this batch fixes the high-confidence, self-contained ones (each verified individually; build and all 77 Core tests green): Crashes / DoS: - ShowPlanParser: guard recursion depth in ParseRelOp/ParseStatementAndChildren and contain the tree walk, so hostile deeply-nested plan XML yields a ParseError instead of an uncatchable StackOverflowException. - Program.cs: single-instance file forwarding never worked (the mutex was disposed as the method returned, so it always launched a duplicate). Detect the running instance via the named pipe instead. - MainWindow: add backoff to the pipe listener's catch so a second instance can no longer spin it at 100% CPU. - QuerySessionControl.Format: add a catch to the Format_Click async void handler so a formatter exception no longer crashes the app. Correctness: - PlanAnalyzer: the "Cursor Missing LOCAL" rule looked for LOCAL before the CURSOR keyword (where it is never legal) and fired on every cursor, including ones already declared LOCAL. Match qualifiers between CURSOR and FOR. - DarkTheme: define the missing ForegroundMutedBrush (#B0B6C0) referenced by five call sites, and fix the code-behind fallback (was full foreground). - HtmlExporter: detect all spill warning types ("Spill to TempDb"/"Spill Occurred", not just "* Spill"), and lowercase severity with ToLowerInvariant so the CSS classes survive non-invariant locales. Security: - PlanShare: generate share IDs with RandomNumberGenerator instead of Random.Shared; the 8-char id is the only access control on a shared plan. - CLI ConnectionHelper: keep Encrypt=Mandatory regardless of --trust-cert; --trust-cert previously downgraded the connection to Optional (MITM-able). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 10bf55c commit e8e5a21

11 files changed

Lines changed: 68 additions & 32 deletions

File tree

‎server/PlanShare/Program.cs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,8 +347,10 @@ GROUP BY referrer ORDER BY count DESC LIMIT 10
347347

348348
static string GenerateId()
349349
{
350+
// The id is the only access control on a shared plan, so it must be
351+
// unpredictable — use a cryptographic RNG, not Random.Shared (xoshiro).
350352
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
351-
return new string(Random.Shared.GetItems<char>(chars.AsSpan(), 8));
353+
return new string(RandomNumberGenerator.GetItems<char>(chars.AsSpan(), 8));
352354
}
353355

354356
static string GenerateDeleteToken()

‎src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,7 @@ private IBrush FindBrushResource(string key)
543543
"BackgroundLightBrush" => new SolidColorBrush(Color.FromRgb(0x23, 0x26, 0x2E)),
544544
"BorderBrush" => new SolidColorBrush(Color.FromRgb(0x3A, 0x3D, 0x45)),
545545
"ForegroundBrush" => new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)),
546-
"ForegroundMutedBrush" => new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)),
546+
"ForegroundMutedBrush" => new SolidColorBrush(Color.FromRgb(0xB0, 0xB6, 0xC0)),
547547
_ => Brushes.White
548548
};
549549
}

‎src/PlanViewer.App/Controls/QuerySessionControl.Format.cs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,11 @@ private async void Format_Click(object? sender, RoutedEventArgs e)
143143
QueryEditor.CaretOffset = Math.Min(caretOffset, QueryEditor.Document.TextLength);
144144
SetStatus("Formatted");
145145
}
146+
catch (Exception ex)
147+
{
148+
// async void handler: an unhandled throw here would crash the app.
149+
SetStatus($"Format failed: {ex.Message}");
150+
}
146151
finally
147152
{
148153
FormatButton.IsEnabled = true;

‎src/PlanViewer.App/MainWindow.axaml.cs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,11 @@ await Dispatcher.UIThread.InvokeAsync(() =>
175175
}
176176
catch
177177
{
178-
// Pipe error — restart the listener
178+
// Pipe error (e.g. another instance already holds the single
179+
// server slot). Back off before retrying so we don't spin at
180+
// 100% CPU recreating the listener in a tight loop.
181+
try { await Task.Delay(1000, token); }
182+
catch (OperationCanceledException) { break; }
179183
}
180184
}
181185
}, token);

‎src/PlanViewer.App/Program.cs‎

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,13 @@
22
using System;
33
using System.IO;
44
using System.IO.Pipes;
5-
using System.Threading;
65
using Velopack;
76

87
namespace PlanViewer.App;
98

109
class Program
1110
{
1211
private const string PipeName = "SQLPerformanceStudio_OpenFile";
13-
private const string MutexName = "SQLPerformanceStudio_SingleInstance";
1412

1513
[STAThread]
1614
public static void Main(string[] args)
@@ -33,34 +31,31 @@ public static AppBuilder BuildAvaloniaApp()
3331
.LogToTrace();
3432

3533
/// <summary>
36-
/// Tries to connect to an already-running instance and send the file path.
37-
/// Returns true if the message was delivered (caller should exit).
34+
/// Tries to hand the file path to an already-running instance over its named pipe.
35+
/// A failed/timed-out connect means no instance is listening, so the caller should
36+
/// launch normally. Returns true only if the path was actually delivered.
3837
/// </summary>
38+
/// <remarks>
39+
/// Detection is via the pipe itself rather than a named mutex: the previous mutex
40+
/// was disposed as soon as this method returned, so no instance ever held it and
41+
/// the forwarding path was never taken.
42+
/// </remarks>
3943
private static bool TrySendToRunningInstance(string filePath)
4044
{
41-
bool createdNew;
42-
using var mutex = new Mutex(true, MutexName, out createdNew);
43-
44-
if (createdNew)
45-
{
46-
// We're the first instance — release and let normal startup proceed
47-
mutex.ReleaseMutex();
48-
return false;
49-
}
50-
51-
// Another instance owns the mutex — try sending the file path
5245
try
5346
{
5447
using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out);
55-
client.Connect(3000); // 3 second timeout
48+
// Short timeout: a running instance's listener is idle and connects
49+
// immediately; when none is running this is the only added launch delay.
50+
client.Connect(500);
5651
using var writer = new StreamWriter(client);
5752
writer.WriteLine(filePath);
5853
writer.Flush();
5954
return true;
6055
}
6156
catch
6257
{
63-
// Pipe not available — fall through to launch normally
58+
// No instance listening (or pipe busy) — fall through to launch normally.
6459
return false;
6560
}
6661
}

‎src/PlanViewer.App/Themes/DarkTheme.axaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
<SolidColorBrush x:Key="BackgroundDarkBrush" Color="#15171C"/>
66
<SolidColorBrush x:Key="BackgroundLightBrush" Color="#22252D"/>
77
<SolidColorBrush x:Key="ForegroundBrush" Color="#E4E6EB"/>
8+
<SolidColorBrush x:Key="ForegroundMutedBrush" Color="#B0B6C0"/>
89
<SolidColorBrush x:Key="BorderBrush" Color="#3A3D45"/>
910
<SolidColorBrush x:Key="AccentBrush" Color="#2eaef1"/>
1011

‎src/PlanViewer.Cli/ConnectionHelper.cs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ public static string BuildConnectionString(
1717
ApplicationName = "PlanViewer",
1818
ConnectTimeout = 15,
1919
TrustServerCertificate = trustCert,
20-
Encrypt = trustCert ? SqlConnectionEncryptOption.Optional : SqlConnectionEncryptOption.Mandatory
20+
// Keep encryption mandatory regardless of --trust-cert. --trust-cert only
21+
// skips certificate *validation* (for self-signed certs); it must not also
22+
// drop the connection to optional/plaintext, which would allow a MITM.
23+
Encrypt = SqlConnectionEncryptOption.Mandatory
2124
};
2225

2326
if (multipleActiveResultSets)

‎src/PlanViewer.Core/Output/HtmlExporter.cs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,9 @@ private static bool HasSpillInTree(OperatorResult? node)
373373
if (node == null) return false;
374374
foreach (var w in node.Warnings)
375375
{
376-
if (w.Type.EndsWith(" Spill", StringComparison.Ordinal))
376+
// Covers "Sort Spill"/"Hash Spill"/"Exchange Spill" as well as the
377+
// standalone "Spill to TempDb" and "Spill Occurred" warning types.
378+
if (w.Type.Contains("Spill", StringComparison.Ordinal))
377379
return true;
378380
}
379381
foreach (var child in node.Children)
@@ -507,7 +509,7 @@ private static void WriteWarnings(StringBuilder sb, StatementResult stmt)
507509

508510
foreach (var w in sorted)
509511
{
510-
var sevLower = w.Severity.ToLower();
512+
var sevLower = w.Severity.ToLowerInvariant();
511513
sb.AppendLine($"<div class=\"warning-item {sevLower}\">");
512514
sb.AppendLine($"<span class=\"sev sev-{sevLower}\">{Encode(w.Severity)}</span>");
513515
if (w.Operator != null)

‎src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -290,11 +290,15 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg, Ser
290290
// https://erikdarling.com/cursor-declarations-that-use-openjson-can-bloat-your-plan-cache/).
291291
if (!cfg.IsRuleDisabled(37) && !string.IsNullOrEmpty(stmt.StatementText))
292292
{
293-
// DECLARE <name> [qualifier(s)] CURSOR ... FOR
294-
// Flags the declaration if LOCAL isn't among the qualifiers before CURSOR.
293+
// DECLARE <name> [INSENSITIVE|SCROLL] CURSOR [qualifier(s)] FOR ...
294+
// In the T-SQL extended syntax, LOCAL/GLOBAL appear AFTER the CURSOR
295+
// keyword (only INSENSITIVE/SCROLL are legal before it), so the LOCAL
296+
// qualifier must be looked for between CURSOR and the FOR that introduces
297+
// the SELECT. Capturing tokens *before* CURSOR never sees LOCAL and would
298+
// fire on every cursor, including ones already declared LOCAL.
295299
var cursorDeclMatch = Regex.Match(
296300
stmt.StatementText,
297-
@"\bDECLARE\s+\w+\s+((?:\w+\s+)*)CURSOR\b",
301+
@"\bDECLARE\s+\w+\s+(?:INSENSITIVE\s+|SCROLL\s+)*CURSOR\b(.*?)\bFOR\b",
298302
RegexOptions.IgnoreCase | RegexOptions.Singleline);
299303
if (cursorDeclMatch.Success)
300304
{

‎src/PlanViewer.Core/Services/ShowPlanParser.RelOp.cs‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ namespace PlanViewer.Core.Services;
99

1010
public static partial class ShowPlanParser
1111
{
12-
private static PlanNode ParseRelOp(XElement relOpEl)
12+
private static PlanNode ParseRelOp(XElement relOpEl, int depth = 0)
1313
{
14+
if (depth > MaxParseDepth)
15+
throw new InvalidOperationException("Plan operator nesting exceeds the supported depth limit.");
16+
1417
var node = new PlanNode
1518
{
1619
NodeId = (int)ParseDouble(relOpEl.Attribute("NodeId")?.Value),
@@ -762,7 +765,7 @@ private static PlanNode ParseRelOp(XElement relOpEl)
762765
// Recurse into child RelOps
763766
foreach (var childRelOp in FindChildRelOps(relOpEl))
764767
{
765-
var childNode = ParseRelOp(childRelOp);
768+
var childNode = ParseRelOp(childRelOp, depth + 1);
766769
childNode.Parent = node;
767770
node.Children.Add(childNode);
768771
}

0 commit comments

Comments
 (0)