Skip to content

Commit 85492a1

Browse files
Merge pull request #586 from erikdarlingdata/fix/output-and-cli-hardening
Harden HTML export, repro script header, and CLI encryption
2 parents 4d8a82b + 38ea2b6 commit 85492a1

6 files changed

Lines changed: 193 additions & 10 deletions

File tree

‎src/PlanViewer.Cli/Commands/CliConnectionResolver.cs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,10 @@ deep inside MSAL with "0xwindow_handle_required" — a message that tells the us
7070
DisplayName = server,
7171
AuthenticationType = authType,
7272
TrustServerCertificate = trustCert,
73-
EncryptMode = trustCert ? "Optional" : "Mandatory"
73+
/* Keep encryption mandatory regardless of --trust-cert, as ConnectionHelper does for a
74+
direct login. --trust-cert only skips certificate validation (for self-signed certs);
75+
it must not also make encryption optional and let queries and results cross in plaintext. */
76+
EncryptMode = "Mandatory"
7477
};
7578
}
7679

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -509,9 +509,11 @@ private static void WriteWarnings(StringBuilder sb, StatementResult stmt)
509509

510510
foreach (var w in sorted)
511511
{
512-
var sevLower = w.Severity.ToLowerInvariant();
513-
sb.AppendLine($"<div class=\"warning-item {sevLower}\">");
514-
sb.AppendLine($"<span class=\"sev sev-{sevLower}\">{Encode(w.Severity)}</span>");
512+
// A shared plan's analysis is caller-supplied JSON, so Severity can hold anything.
513+
// Only a fixed class name goes into the attributes; the text itself is encoded.
514+
var sevClass = SeverityClass(w.Severity);
515+
sb.AppendLine($"<div class=\"warning-item {sevClass}\">");
516+
sb.AppendLine($"<span class=\"sev sev-{sevClass}\">{Encode(w.Severity)}</span>");
515517
if (w.Operator != null)
516518
sb.AppendLine($"<span class=\"warn-op\">{Encode(w.Operator)}</span>");
517519
sb.AppendLine($"<span class=\"warn-type\">{Encode(w.Type)}</span>");
@@ -619,4 +621,15 @@ private static string FormatKB(long kb)
619621
}
620622

621623
private static string Encode(string text) => HttpUtility.HtmlEncode(text);
624+
625+
/// <summary>
626+
/// Maps a warning severity to one of the stylesheet's three class names. Any other value,
627+
/// null included, gets "info", so severity text never reaches a class attribute.
628+
/// </summary>
629+
private static string SeverityClass(string? severity) => severity?.ToLowerInvariant() switch
630+
{
631+
"critical" => "critical",
632+
"warning" => "warning",
633+
_ => "info"
634+
};
622635
}

‎src/PlanViewer.Core/Services/ReproScriptBuilder.cs‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,14 @@ rather than leaving an unexplained placeholder. */
111111
warnings.Add($"Variables in query without values: {string.Join(", ", unresolvedVars)}. These may be local variables — fill in values before executing.");
112112
}
113113

114-
/* Header comment */
114+
/* Header comment. Every value in it goes through CommentSafe: the database name
115+
comes off plan XML, and a crafted one must not be able to end the comment early. */
115116
sb.AppendLine("/*");
116-
sb.AppendLine("Reproduction script generated by SQL Server Performance Monitor");
117-
sb.AppendLine($"Source: {source}");
117+
sb.AppendLine("Reproduction script generated by Performance Studio");
118+
sb.AppendLine($"Source: {CommentSafe(source)}");
118119
if (!string.IsNullOrEmpty(databaseName))
119120
{
120-
sb.AppendLine($"Database: [{databaseName}]");
121+
sb.AppendLine($"Database: [{CommentSafe(databaseName)}]");
121122
}
122123
sb.AppendLine($"Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
123124

@@ -127,15 +128,16 @@ rather than leaving an unexplained placeholder. */
127128
sb.AppendLine("Warnings:");
128129
foreach (var warning in warnings)
129130
{
130-
sb.AppendLine($" - {warning}");
131+
sb.AppendLine($" - {CommentSafe(warning)}");
131132
}
132133
}
133134

134135
sb.AppendLine("*/");
135136
sb.AppendLine();
136137

137138
/* USE database (skip for Azure SQL DB — USE is invalid there).
138-
Double any ']' in the identifier so names like 'cool]stuff' still parse. */
139+
Double any ']' in the identifier so names like 'cool]stuff' still parse. Line breaks
140+
in the name stay: a client that splits batches correctly never splits inside brackets. */
139141
if (!string.IsNullOrEmpty(databaseName) && !isAzureSqlDb)
140142
{
141143
sb.AppendLine($"USE [{databaseName.Replace("]", "]]")}];");
@@ -408,6 +410,21 @@ private static string EscapeSqlString(string value)
408410
return value.Replace("'", "''");
409411
}
410412

413+
/// <summary>
414+
/// Makes text safe inside the header's block comment. "*/" would close the comment and
415+
/// "/*" would open a nested one (T-SQL block comments nest), so both are split with a
416+
/// space. Line breaks become spaces too, so each value stays on one line of the header
417+
/// and cannot put GO on a line of its own there.
418+
/// </summary>
419+
private static string CommentSafe(string? text)
420+
{
421+
/* \p{Cc} covers CR, LF, tab and NEL; U+2028 and U+2029 are the Unicode line and
422+
paragraph separators, which some editors also treat as line breaks. */
423+
return Regex.Replace(text ?? "", @"[\p{Cc}\u2028\u2029]", " ")
424+
.Replace("*/", "* /")
425+
.Replace("/*", "/ *");
426+
}
427+
411428
/// <summary>
412429
/// Validates a parameter name from plan XML as a plain @identifier.
413430
/// Anything else is dropped from the generated script.

‎tests/PlanViewer.Core.Tests/CliConnectionResolverTests.cs‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using Microsoft.Data.SqlClient;
2+
using PlanViewer.Cli;
13
using PlanViewer.Cli.Commands;
24
using PlanViewer.Core.Interfaces;
35
using PlanViewer.Core.Services;
@@ -55,6 +57,29 @@ instead of leaking. */
5557
}
5658
}
5759

60+
/* --trust-cert skips certificate validation and nothing else. It used to make encryption
61+
optional as well, unlike the direct-login path in ConnectionHelper, which always kept it
62+
mandatory. Both paths must agree. */
63+
[Theory]
64+
[InlineData("sql", false)]
65+
[InlineData("sql", true)]
66+
[InlineData("windows", false)]
67+
[InlineData("windows", true)]
68+
public void BuildServerConnection_KeepsEncryptionMandatory(string auth, bool trustCert)
69+
{
70+
var store = new InMemoryCredentialService();
71+
store.SaveCredential("srv", "user", "pass");
72+
73+
var connection = CliConnectionResolver.BuildServerConnection("srv", auth, trustCert, store);
74+
var resolved = new SqlConnectionStringBuilder(connection.GetConnectionString(store));
75+
var direct = new SqlConnectionStringBuilder(
76+
ConnectionHelper.BuildConnectionString("srv", "master", "user", "pass", trustCert));
77+
78+
Assert.Equal(SqlConnectionEncryptOption.Mandatory, resolved.Encrypt);
79+
Assert.Equal(trustCert, resolved.TrustServerCertificate);
80+
Assert.Equal(direct.Encrypt, resolved.Encrypt);
81+
}
82+
5883
/* Minimal stand-in: the resolver only asks whether a credential exists, and the entra refusal must fire
5984
before credentials ever matter. */
6085
private sealed class NoCredentials : ICredentialService

‎tests/PlanViewer.Core.Tests/HtmlExporterTests.cs‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,67 @@ public void Export_EscapesHtmlInQueryText()
6363
Assert.Contains("<!DOCTYPE html>", html);
6464
Assert.Contains("</html>", html);
6565
}
66+
67+
[Theory]
68+
[InlineData("Critical", "critical")]
69+
[InlineData("Warning", "warning")]
70+
[InlineData("Info", "info")]
71+
public void Export_KnownSeverity_KeepsItsClass(string severity, string cssClass)
72+
{
73+
var html = ExportWithSeverity(severity);
74+
75+
Assert.Contains($"<div class=\"warning-item {cssClass}\">", html);
76+
Assert.Contains($"<span class=\"sev sev-{cssClass}\">{severity}</span>", html);
77+
}
78+
79+
[Fact]
80+
public void Export_CraftedSeverity_CannotLeaveTheClassAttribute()
81+
{
82+
// A shared plan's analysis is caller-supplied JSON, so severity can hold markup.
83+
var html = ExportWithSeverity("\"><script>alert(1)</script><div class=\"");
84+
85+
Assert.DoesNotContain("<script>alert(1)</script>", html);
86+
Assert.Contains("<div class=\"warning-item info\">", html);
87+
Assert.Contains("&lt;script&gt;alert(1)&lt;/script&gt;", html);
88+
}
89+
90+
[Fact]
91+
public void Export_NullSeverity_ExportsAsInfo()
92+
{
93+
// JSON can send "severity": null, and the export used to throw on it.
94+
var html = ExportWithSeverity(null);
95+
96+
Assert.Contains("<div class=\"warning-item info\">", html);
97+
}
98+
99+
[Fact]
100+
public void Export_CraftedSeverityWithoutMarkup_CannotAddAnAttribute()
101+
{
102+
var html = ExportWithSeverity("x\" onmouseover=\"alert(1)");
103+
104+
Assert.DoesNotContain("onmouseover=\"alert(1)\"", html);
105+
Assert.Contains("<div class=\"warning-item info\">", html);
106+
}
107+
108+
[Fact]
109+
public void Export_CraftedSeverityOnAnOperator_IsMappedToo()
110+
{
111+
// Operator warnings reach the same list through the operator tree.
112+
var html = ExportWithSeverity("\"><script>alert(1)</script><div class=\"", onOperator: true);
113+
114+
Assert.DoesNotContain("<script>alert(1)</script>", html);
115+
Assert.Contains("<div class=\"warning-item info\">", html);
116+
}
117+
118+
private static string ExportWithSeverity(string? severity, bool onOperator = false)
119+
{
120+
var warning = new WarningResult { Severity = severity!, Type = "demo", Message = "demo" };
121+
var statement = new StatementResult { StatementText = "SELECT 1" };
122+
if (onOperator)
123+
statement.OperatorTree = new OperatorResult { Warnings = { warning } };
124+
else
125+
statement.Warnings.Add(warning);
126+
127+
return HtmlExporter.Export(new AnalysisResult { Statements = { statement } }, "demo");
128+
}
66129
}

‎tests/PlanViewer.Core.Tests/ReproScriptBuilderSafetyTests.cs‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using Microsoft.SqlServer.TransactSql.ScriptDom;
12
using PlanViewer.Core.Services;
23

34
namespace PlanViewer.Core.Tests;
@@ -126,6 +127,67 @@ public void BuildReproScript_RealWorldCompiledValues_SurviveTheFilter(
126127
Assert.DoesNotContain("@p = ?", sql);
127128
}
128129

130+
// The header comment shows the plan's database name. A crafted name must stay inside it:
131+
// "*/" would close the comment, "/*" would open a nested one that swallows the script,
132+
// and a line break could put GO on a line of its own. ScriptDom parses each script, so a
133+
// statement the name smuggled out would show up as a PRINT or an extra batch.
134+
//
135+
// The USE line keeps the name as it is, line breaks included, on purpose. It doubles "]",
136+
// and go-sqlcmd and ODBC sqlcmd do not split a batch inside a bracketed name. A client that
137+
// splits at every GO line is out of scope: the statement text can hold such a line too.
138+
[Theory]
139+
[InlineData("master*/\nGO\nPRINT 'INJECTED';\nGO\n/*")] // its own batch in a GO-aware client
140+
[InlineData("master*/ PRINT 'INJECTED'; /*")] // same batch, no GO needed
141+
[InlineData("master\r\nGO\r\nPRINT 'INJECTED';\r\nGO")] // line breaks alone
142+
[InlineData("master/*")] // nested comment
143+
[InlineData("master/*/")] // delimiters that overlap
144+
[InlineData("master*/*")]
145+
[InlineData("master\vGO\fPRINT 'INJECTED';\u0085GO\u2028x\u2029y")] // VT, FF, NEL, LS, PS
146+
public void BuildReproScript_HostileDatabaseName_StaysInTheHeaderComment(string databaseName)
147+
{
148+
var sql = ReproScriptBuilder.BuildReproScript("SELECT 1", databaseName, null, null);
149+
150+
var script = ParseScript(sql);
151+
Assert.Single(script.Batches);
152+
Assert.DoesNotContain(script.Batches[0].Statements, s => s is PrintStatement);
153+
154+
var header = HeaderComment(sql);
155+
var databaseLine = Assert.Single(header.Split('\n'), line => line.StartsWith("Database: [", StringComparison.Ordinal));
156+
Assert.StartsWith("Database: [master", databaseLine);
157+
Assert.DoesNotMatch(@"[\p{Cc}\u2028\u2029]", databaseLine.TrimEnd('\r'));
158+
Assert.DoesNotContain(header.Split('\n'), line => line.Trim() == "GO");
159+
}
160+
161+
[Fact]
162+
public void BuildReproScript_HostileSource_StaysInTheHeaderComment()
163+
{
164+
var sql = ReproScriptBuilder.BuildReproScript(
165+
"SELECT 1", "db", null, null, source: "x*/ PRINT 'INJECTED'; /*");
166+
167+
var script = ParseScript(sql);
168+
Assert.DoesNotContain(script.Batches.SelectMany(b => b.Statements), s => s is PrintStatement);
169+
Assert.Contains("Source: x* / PRINT 'INJECTED'; / *", HeaderComment(sql));
170+
}
171+
172+
private static TSqlScript ParseScript(string sql)
173+
{
174+
var fragment = new TSql160Parser(initialQuotedIdentifiers: true)
175+
.Parse(new StringReader(sql), out var errors);
176+
Assert.Empty(errors);
177+
return (TSqlScript)fragment;
178+
}
179+
180+
// Everything up to the first "*/", which must be the header's own closing line: a value
181+
// that ended the comment early would put it somewhere else.
182+
private static string HeaderComment(string sql)
183+
{
184+
Assert.StartsWith("/*", sql);
185+
var end = sql.IndexOf("*/", StringComparison.Ordinal);
186+
Assert.Equal('\n', sql[end - 1]);
187+
Assert.DoesNotContain("/*", sql[2..end]);
188+
return sql[..end];
189+
}
190+
129191
[Fact]
130192
public void ExtractParametersFromPlan_StillReturnsRawParameters()
131193
{

0 commit comments

Comments
 (0)