Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/ai-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ begrenzt. Auch Bestandslisten zu Workflows, Läufen oder Maschinen verwenden die
DB-Quelle. Ordnerrechte erweitern den Zugriff nicht auf SQL. Der Wissens-Assistent
ändert keine Workflows und startet keine Reparaturen.

Der gemeinsame SQL-Guard für Wissensassistent und Admin-Konsole lehnt im Lesemodus
PostgreSQL-Escape-Strings (`E'…'`/`e'…'`) vor der Ausführung ausdrücklich ab.
Standard-SQL-Literale mit verdoppelten Apostrophen bleiben unterstützt. Die abweichende
[PostgreSQL-Escape-Syntax](https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-STRINGS-ESCAPE)
wird vom verwendeten Lexer nicht interpretiert.

## Fehlerbehandlung

| HTTP | Code | Ursache |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ public static void Validate(string sql)
if (sql.Length > DbAdminQueryExecutor.MaxSqlLength)
throw new InvalidOperationException(
$"SQL exceeds the {DbAdminQueryExecutor.MaxSqlLength}-character limit.");
if (SqlStatementInspector.ContainsEscapeStringLiteral(sql))
throw new InvalidOperationException(
"Escape-string literals are not allowed in read mode. Use standard SQL string literals with doubled quotes.");
if (DbAdminQueryExecutor.ContainsMultipleStatements(sql))
throw new InvalidOperationException("Read mode accepts exactly one SQL statement.");

Expand Down
14 changes: 14 additions & 0 deletions src/NodePilot.Core/Security/SqlStatementInspector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ namespace NodePilot.Core.Security;
public static class SqlStatementInspector
{
private const string UnicodeEscapedIdentifierMarker = "<unicode-escaped-identifier>";
private const string EscapeStringLiteralMarker = "<escape-string-literal>";

private static readonly HashSet<string> UnicodeEscapedIdentifierMarkers =
new([UnicodeEscapedIdentifierMarker], StringComparer.Ordinal);
private static readonly HashSet<string> EscapeStringLiteralMarkers =
new([EscapeStringLiteralMarker], StringComparer.Ordinal);

private static readonly HashSet<string> DynamicDataExporters = new(StringComparer.OrdinalIgnoreCase)
{
Expand Down Expand Up @@ -52,6 +55,10 @@ public static bool ReferencesAnyIdentifier(string sql, IReadOnlySet<string> iden
public static bool ContainsUnicodeEscapedIdentifier(string sql)
=> ReferencesAnyIdentifier(sql, UnicodeEscapedIdentifierMarkers);

/// <summary>PostgreSQL E-strings use backslash escaping, outside this lexer's literal grammar.</summary>
public static bool ContainsEscapeStringLiteral(string sql)
=> ReferencesAnyIdentifier(sql, EscapeStringLiteralMarkers);

/// <summary>
/// True when <paramref name="second"/> follows <paramref name="first"/> as consecutive
/// unquoted identifier tokens. Punctuation and comments are ignored, matching normal SQL
Expand Down Expand Up @@ -157,6 +164,13 @@ private static IEnumerable<SqlIdentifier> Tokenize(string sql, bool includeSynta
continue;
}

if (sql[i] is 'E' or 'e' && i + 1 < sql.Length && sql[i + 1] == '\'')
{
yield return new SqlIdentifier(EscapeStringLiteralMarker, Quoted: false);
// Unsupported syntax ends reliable inspection; the read guard rejects the marker.
yield break;
}

if (sql[i] == '\'')
{
SkipQuotedLiteral(sql, ref i, '\'');
Expand Down
16 changes: 16 additions & 0 deletions tests/NodePilot.Api.Tests/Ai/SqlKnowledgeReaderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ namespace NodePilot.Api.Tests.Ai;
/// </summary>
public class SqlKnowledgeReaderTests
{
[Fact]
public async Task ExecuteRead_EscapeStringBeforeAliasedSecret_IsRejectedBeforeDatabaseExecution()
{
using var db = TestDbFactory.Create();
db.Users.Add(new User { Username = "admin", PasswordHash = "synthetic-secret-do-not-export" });
await db.SaveChangesAsync(TestContext.Current.CancellationToken);

var result = await NewReader(db).ExecuteReadAsync(
"""SELECT E'\'' AS marker, "PasswordHash" AS harmless FROM "Users" -- '""",
TestContext.Current.CancellationToken);

result.Error.Should().Contain("Escape-string literals are not allowed");
result.Rows.Should().BeEmpty();
result.Columns.Should().BeEmpty();
}

private sealed class FakeScopeFactory : IServiceScopeFactory
{
private readonly NodePilotDbContext _db;
Expand Down
16 changes: 16 additions & 0 deletions tests/NodePilot.Api.Tests/Controllers/DbAdminControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,22 @@ private sealed class AuditSaveTokenRecorder(string action)
}
}

[Fact]
public async Task ExecuteQuery_ReadMode_EscapeStringBeforeAliasedSecret_IsRejectedBeforeDatabaseExecution()
{
var (ctrl, db) = NewController();
db.Users.Add(AdminUser());
await db.SaveChangesAsync(TestContext.Current.CancellationToken);

var result = await ctrl.ExecuteQuery(new DbAdminQueryRequest(
"""SELECT E'\'' AS marker, "PasswordHash" AS harmless FROM "Users" -- '""", null),
TestContext.Current.CancellationToken);

var error = result.Should().BeOfType<ConflictObjectResult>().Subject.Value
.Should().BeOfType<DbAdminQueryError>().Subject;
error.Message.Should().Contain("Escape-string literals are not allowed");
}

// ── Secret-column containment (mirrors SqlKnowledgeReader; keeps the MCP run_readonly_sql
// tool and the UI query pane from pulling secrets out of the DB) ──────────────────────

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,28 @@ namespace NodePilot.Api.Tests.Services.DbAdmin;
/// </summary>
public class DbAdminReadOnlySqlGuardTests
{
[Theory]
[InlineData("SELECT E'hello'")]
[InlineData("SELECT e'hello'")]
[InlineData("""SELECT E'\'' AS marker, "PasswordHash" AS harmless FROM "Users" -- '""")]
[InlineData("""WITH data AS (SELECT e'\'' AS marker, "PasswordHash" AS harmless FROM "Users" -- '""" + "\n) SELECT harmless FROM data")]
public void Validate_RejectsUnsupportedEscapeStringSyntax(string sql)
{
var act = () => DbAdminReadOnlySqlGuard.Validate(sql);
act.Should().Throw<InvalidOperationException>().WithMessage("*Escape-string literals are not allowed*");
}

[Theory]
[InlineData("SELECT 'Dianne''s horse'")]
[InlineData("SELECT 'E''hello''' AS text")]
[InlineData("SELECT $$ E'hello' $$ AS text")]
[InlineData("SELECT $tag$ E'hello' $tag$ AS text")]
[InlineData("SELECT 1 /* E'hello' */")]
[InlineData("SELECT 1 -- E'hello'")]
[InlineData("SELECT \"E'hello'\" FROM Workflows")]
public void Validate_OrdinaryLiteralsAndQuotedEscapeText_RemainAllowed(string sql)
=> FluentActions.Invoking(() => DbAdminReadOnlySqlGuard.Validate(sql)).Should().NotThrow();

[Theory]
[InlineData("SELECT * FROM Users")]
[InlineData("SELECT u.* FROM Users u")]
Expand Down
Loading