|
| 1 | +using Microsoft.SqlServer.TransactSql.ScriptDom; |
1 | 2 | using PlanViewer.Core.Services; |
2 | 3 |
|
3 | 4 | namespace PlanViewer.Core.Tests; |
@@ -126,6 +127,67 @@ public void BuildReproScript_RealWorldCompiledValues_SurviveTheFilter( |
126 | 127 | Assert.DoesNotContain("@p = ?", sql); |
127 | 128 | } |
128 | 129 |
|
| 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 | + |
129 | 191 | [Fact] |
130 | 192 | public void ExtractParametersFromPlan_StillReturnsRawParameters() |
131 | 193 | { |
|
0 commit comments