-
Notifications
You must be signed in to change notification settings - Fork 206
Expand file tree
/
Copy pathReadDataToolTests.cs
More file actions
423 lines (372 loc) · 18.6 KB
/
Copy pathReadDataToolTests.cs
File metadata and controls
423 lines (372 loc) · 18.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license.
using Microsoft.Extensions.Logging;
using Moq;
using Mssql.McpServer;
namespace MssqlMcp.Tests
{
[Collection("Database Tests")]
public sealed class MssqlMcpReadDataToolTests : IDisposable
{
private readonly string _tableName;
private readonly Tools _tools;
public MssqlMcpReadDataToolTests()
{
_tableName = $"ReadDataTest_{Guid.NewGuid():N}";
var connectionFactory = new SqlConnectionFactory();
var loggerMock = new Mock<ILogger<Tools>>();
_tools = new Tools(connectionFactory, loggerMock.Object);
}
public void Dispose()
{
// Clean up test table if it exists
var _ = _tools.DropTable($"DROP TABLE IF EXISTS {_tableName}").GetAwaiter().GetResult();
}
[Fact]
public async Task ReadData_ReturnsData_WhenSqlIsValid()
{
// Set up test table with data
var createResult = await _tools.CreateTable($"CREATE TABLE {_tableName} (Id INT PRIMARY KEY)") as DbOperationResult;
Assert.NotNull(createResult);
Assert.True(createResult.Success);
var insertResult = await _tools.InsertData($"INSERT INTO {_tableName} (Id) VALUES (1)") as DbOperationResult;
Assert.NotNull(insertResult);
Assert.True(insertResult.Success);
var sql = $"SELECT * FROM {_tableName}";
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.True(result.Success);
Assert.NotNull(result.Data);
}
[Fact]
public async Task ReadData_ReturnsError_WhenSqlIsInvalid()
{
var sql = "SELECT FROM";
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.False(result.Success);
Assert.Contains("Database query execution failed", result.Error ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ReadData_Security_RejectsDeleteStatement()
{
var sql = "DELETE FROM users WHERE id = 1";
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.False(result.Success);
Assert.Contains("Query must start with SELECT", result.Error ?? string.Empty);
}
[Fact]
public async Task ReadData_Security_RejectsDropStatement()
{
var sql = "DROP TABLE users";
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.False(result.Success);
Assert.Contains("Query must start with SELECT", result.Error ?? string.Empty);
}
[Fact]
public async Task ReadData_Security_RejectsUpdateStatement()
{
var sql = "UPDATE users SET admin = 1";
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.False(result.Success);
Assert.Contains("Query must start with SELECT", result.Error ?? string.Empty);
}
[Fact]
public async Task ReadData_Security_RejectsInsertStatement()
{
var sql = "INSERT INTO users VALUES ('hacker', 'password')";
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.False(result.Success);
// INSERT gets caught either by "must start with SELECT" or keyword detection
Assert.True(
result.Error?.Contains("Query must start with SELECT") == true ||
result.Error?.Contains("Dangerous keyword 'INSERT'") == true
);
}
[Theory]
[InlineData("DELETE", "DELETE FROM users")]
[InlineData("DROP", "SELECT * FROM users WHERE 1=1 OR DROP TABLE accounts")]
[InlineData("TRUNCATE", "SELECT * FROM users; TRUNCATE TABLE logs")]
[InlineData("EXEC", "SELECT * FROM users EXEC sp_help")]
[InlineData("EXECUTE", "SELECT * FROM users EXECUTE xp_cmdshell")]
[InlineData("ALTER", "SELECT * FROM users; ALTER TABLE users ADD admin BIT")]
[InlineData("CREATE", "SELECT * FROM users; CREATE TABLE hacked (id INT)")]
[InlineData("GRANT", "SELECT * FROM users; GRANT ALL TO hacker")]
[InlineData("REVOKE", "SELECT * FROM users; REVOKE SELECT ON users FROM public")]
[InlineData("BACKUP", "SELECT * FROM users; BACKUP DATABASE test TO DISK='hack.bak'")]
[InlineData("RESTORE", "SELECT * FROM users; RESTORE DATABASE test FROM DISK='hack.bak'")]
[InlineData("SHUTDOWN", "SELECT * FROM users; SHUTDOWN")]
public async Task ReadData_Security_RejectsAllDangerousKeywords(string keyword, string sql)
{
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.False(result.Success);
// Different keywords might trigger different validation rules - some caught by "must start with SELECT", others by keyword detection
Assert.True(
result.Error?.Contains($"Dangerous keyword '{keyword}'", StringComparison.OrdinalIgnoreCase) == true ||
result.Error?.Contains("Query must start with SELECT") == true ||
result.Error?.Contains("malicious SQL pattern") == true ||
result.Error?.Contains("Multiple SQL statements") == true
);
}
[Fact]
public async Task ReadData_Security_RejectsSemicolonInjection()
{
var sql = "SELECT * FROM users; DROP TABLE accounts--";
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.False(result.Success);
// Should catch either multiple statements or the DROP keyword
Assert.True(
result.Error?.Contains("Multiple SQL statements") == true ||
result.Error?.Contains("Dangerous keyword 'DROP'") == true
);
}
[Fact]
public async Task ReadData_Security_RejectsUnionWithDangerousKeyword()
{
var sql = "SELECT id FROM users UNION SELECT * FROM passwords; DELETE FROM logs";
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.False(result.Success);
Assert.Contains("Dangerous keyword 'DELETE' detected", result.Error ?? string.Empty);
}
[Fact]
public async Task ReadData_Security_RejectsStoredProcedureExecution()
{
var sql = "SELECT * FROM users WHERE id = 1 EXEC xp_cmdshell 'dir'";
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.False(result.Success);
Assert.Contains("Dangerous keyword 'EXEC' detected", result.Error ?? string.Empty);
}
[Fact]
public async Task ReadData_Security_RejectsCommentInjection()
{
// The validation strips comments first, so DELETE in comments should be caught
var sql = "SELECT * FROM users /* injected DELETE FROM accounts */";
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.False(result.Success);
Assert.Contains("Dangerous keyword 'DELETE' detected", result.Error ?? string.Empty);
}
[Fact]
public async Task ReadData_Security_RejectsLineCommentInjection()
{
var sql = "SELECT * FROM users -- DELETE FROM accounts";
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.False(result.Success);
// After stripping comments, should be valid unless DELETE is in the actual query
// This one should actually pass since DELETE is only in the comment
// Let's test one where it matters
var sql2 = "SELECT * FROM users WHERE id = 1 OR 1=1 -- UNION DELETE";
var result2 = await _tools.ReadData(sql2) as DbOperationResult;
// This should pass as DELETE is in comment
}
[Fact]
public async Task ReadData_Security_RejectsWaitforDelay()
{
var sql = "SELECT * FROM users WHERE id = 1 WAITFOR DELAY '00:00:05'";
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.False(result.Success);
Assert.Contains("Dangerous keyword 'WAITFOR' detected", result.Error ?? string.Empty);
}
[Fact]
public async Task ReadData_Security_RejectsCharObfuscation()
{
var sql = "SELECT * FROM users WHERE name = 'test' + CHAR(59) + 'DROP TABLE users'";
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.False(result.Success);
Assert.Contains("Dangerous keyword 'DROP' detected", result.Error ?? string.Empty);
}
[Fact]
public async Task ReadData_Security_RejectsNCharObfuscation()
{
var sql = "SELECT * FROM users WHERE name = NCHAR(0x44) + NCHAR(0x52) + NCHAR(0x4F) + NCHAR(0x50)";
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.False(result.Success);
Assert.Contains("Potentially malicious SQL pattern detected.", result.Error ?? string.Empty);
}
[Fact]
public async Task ReadData_Security_RejectsBulkOperations()
{
var sql = "SELECT * FROM OPENROWSET('SQLNCLI', 'Server=hack;Trusted_Connection=yes;', 'SELECT * FROM users')";
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.False(result.Success);
// Should be caught by OPENROWSET keyword or pattern
Assert.True(
result.Error?.Contains("Dangerous keyword 'OPENROWSET'") == true ||
result.Error?.Contains("malicious SQL pattern") == true
);
}
[Fact]
public async Task ReadData_Security_RejectsNonSelectStatement()
{
var sql = "INSERT INTO users VALUES ('hacker', 'password')";
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.False(result.Success);
Assert.Contains("Query must start with SELECT", result.Error ?? string.Empty);
}
[Fact]
public async Task ReadData_Security_RejectsEmptyQuery()
{
var sql = " ";
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.False(result.Success);
Assert.Contains("Query must be a non-empty string", result.Error ?? string.Empty);
}
[Fact]
public async Task ReadData_Security_RejectsNullQuery()
{
string sql = null!;
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.False(result.Success);
Assert.Contains("Query must be a non-empty string", result.Error ?? string.Empty);
}
[Fact]
public async Task ReadData_Security_RejectsVeryLongQuery()
{
// Build a query that exceeds the 10,000 character limit
var sql = "SELECT " + new string('a', 10001) + " FROM users";
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.False(result.Success);
Assert.Contains("Query is too long", result.Error ?? string.Empty);
}
[Fact]
public async Task ReadData_Security_RejectsMultipleStatements()
{
var sql = "SELECT * FROM users; SELECT * FROM passwords";
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.False(result.Success);
Assert.Contains("Security validation failed: Potentially malicious SQL pattern detected. Only simple SELECT queries are allowed.", result.Error ?? string.Empty);
}
[Fact]
public async Task ReadData_Security_RejectsCaseVariationsOfDangerousKeywords()
{
var queries = new[]
{
"DeLeTe FROM users",
"dRoP TABLE users",
"UpDaTe users SET admin = 1",
"iNsErT INTO users VALUES (1)",
"tRuNcAtE TABLE logs"
};
foreach (var sql in queries)
{
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.False(result.Success);
// Should be rejected either for not starting with SELECT or dangerous keyword
Assert.True(
result.Error?.Contains("Query must start with SELECT") == true ||
result.Error?.Contains("Dangerous keyword") == true
);
}
}
[Theory]
[InlineData("SELECT * FROM users")]
[InlineData("SELECT id, name FROM customers WHERE active = 1")]
[InlineData("SELECT COUNT(*) FROM orders")]
[InlineData("SELECT updated_at, created_at FROM logs")] // Tests that 'UPDATE' in column name is OK
[InlineData("SELECT * FROM user_updates")] // Tests that 'UPDATE' in table name is OK
[InlineData("select * from users")] // Lowercase should work
[InlineData("SeLeCt * FrOm users")] // Mixed case should work
[InlineData("SELECT TOP 10 * FROM users ORDER BY created_at DESC")] // TOP and ORDER BY
[InlineData("SELECT u.*, o.order_date FROM users u JOIN orders o ON u.id = o.user_id")] // JOIN syntax
public async Task ReadData_Security_AllowsValidSelectQueries(string sql)
{
// Create test table to actually run these queries
var testTableName = $"ReadDataTest_{Guid.NewGuid():N}";
await _tools.CreateTable($"CREATE TABLE {testTableName} (id INT, name VARCHAR(50), active BIT, updated_at DATETIME, created_at DATETIME, user_id INT, order_date DATETIME)");
// Replace placeholder table names with our test table
sql = sql.Replace("users", testTableName)
.Replace("customers", testTableName)
.Replace("orders", testTableName)
.Replace("logs", testTableName)
.Replace("user_updates", testTableName);
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.True(result.Success);
// Clean up
await _tools.DropTable($"DROP TABLE IF EXISTS {testTableName}");
}
[Fact]
public async Task ReadData_Security_AllowsSelectWithUpdatedAtColumn()
{
// This specifically tests that we don't false-positive on column names containing keywords
var testTableName = $"ReadDataTest_{Guid.NewGuid():N}";
await _tools.CreateTable($"CREATE TABLE {testTableName} (id INT, updated_at DATETIME, deleted_flag BIT, created_by VARCHAR(50))");
var sql = $"SELECT id, updated_at, deleted_flag, created_by FROM {testTableName}";
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.True(result.Success);
// Clean up
await _tools.DropTable($"DROP TABLE IF EXISTS {testTableName}");
}
[Fact]
public async Task ReadData_Security_AllowsComplexValidQuery()
{
// Test a complex but valid SELECT query
var testTableName = $"ReadDataTest_{Guid.NewGuid():N}";
await _tools.CreateTable($"CREATE TABLE {testTableName} (id INT, category VARCHAR(50), amount DECIMAL(10,2), created_at DATETIME)");
var sql = $@"SELECT
category,
COUNT(*) as count,
SUM(amount) as total,
AVG(amount) as average,
MIN(created_at) as first_created,
MAX(created_at) as last_created
FROM {testTableName}
WHERE amount > 0
GROUP BY category
HAVING COUNT(*) > 1
ORDER BY total DESC";
var result = await _tools.ReadData(sql) as DbOperationResult;
Assert.NotNull(result);
Assert.True(result.Success);
// Clean up
await _tools.DropTable($"DROP TABLE IF EXISTS {testTableName}");
}
[Fact]
public async Task ReadData_SanitizeResult_RemovesSuspiciousCharactersFromColumnNames()
{
// Create a table with suspicious characters in column names
var testTableName = $"ReadDataTest_{Guid.NewGuid():N}";
await _tools.CreateTable($@"CREATE TABLE {testTableName} (
[normal_id] INT,
[bad<script>] VARCHAR(50),
[evil&injection] VARCHAR(50),
[ok_name] VARCHAR(50)
)");
// Insert test data
await _tools.InsertData($"INSERT INTO {testTableName} VALUES (1, 'test', 'data', 'ok')");
// Query the data - this will go through SanitizeResult
var result = await _tools.ReadData($"SELECT * FROM {testTableName}") as DbOperationResult;
Assert.NotNull(result);
Assert.True(result.Success);
Assert.NotNull(result.Data);
// Check that suspicious characters were removed from column names
var firstRow = ((List<Dictionary<string, object?>>)result.Data).First();
Assert.Contains("normal_id", firstRow.Keys);
Assert.Contains("badscript", firstRow.Keys); // <script> should be removed
Assert.Contains("evilinjection", firstRow.Keys); // &injection should be sanitized
Assert.Contains("ok_name", firstRow.Keys);
// Cleanup
await _tools.DropTable($"DROP TABLE IF EXISTS {testTableName}");
}
}
}