diff --git a/.github/test-suites/query-security-deployment-contract.md b/.github/test-suites/query-security-deployment-contract.md index 861121e22..3bbca24cd 100644 --- a/.github/test-suites/query-security-deployment-contract.md +++ b/.github/test-suites/query-security-deployment-contract.md @@ -14,7 +14,7 @@ that possess those capabilities.** |---|---|---|---| | PostgreSQL 16 | Dedicated NOSUPERUSER/NOCREATEDB/NOCREATEROLE/NOBYPASSRLS identity; grant CONNECT, USAGE and SELECT only to intended schemas/tables; no membership granting write or privileged routine EXECUTE; restrict search_path/schema CREATE | Same-connection native READ ONLY transaction and pinned pg_catalog resolution; 11-case mandatory Testcontainers Host Query corpus exercises sequence/UDF writes, whitelist escapes, shadowed operators, injection and legitimate CTE controls | Privileged SECURITY DEFINER/dblink/external-side-effect routines that can bypass connection-local read-only | | MySQL 8.4 InnoDB | Dedicated SELECT-only principal on intended objects, without INSERT/UPDATE/DELETE, CREATE TEMPORARY TABLES, EXECUTE/CREATE ROUTINE, FILE or elevated global roles; protect schemas from attacker-controlled routines | Next-transaction READ ONLY fence; 3-case mandatory live MySQL native/UDF corpus, including real TEMPORARY-function negative control and normal builtins | Native or stored routines performing separate-connection/external I/O; unqualified routines without physical identity proof | -| SQL Server 2022 | Dedicated restricted SQL Login with explicit object SELECT, no DML/DDL, EXECUTE AS/IMPERSONATE, TRUSTWORTHY/UNSAFE assembly privilege, elevated cross-database ownership chains or untrusted CLR EXECUTE | Same-connection effective principal/permission attestation for no-whitelist Query, plus 2-case mandatory native/UDF least-privilege corpus | SQL CLR/external code or privileged cross-database modules outside the attested principal scope | +| SQL Server 2022 | Dedicated restricted SQL Login with explicit object SELECT, no DML/DDL, EXECUTE AS/IMPERSONATE, TRUSTWORTHY/UNSAFE assembly privilege, elevated cross-database ownership chains or untrusted CLR EXECUTE | Same-connection effective principal/permission attestation **and typed policy rejection of opaque qualified/quoted native functions** for no-whitelist Query; 2-case mandatory native/UDF least-privilege corpus | SQL CLR/external code or privileged cross-database modules outside the attested principal scope | | Oracle 26ai | Dedicated CREATE SESSION plus intended object SELECT grants only; no DML/DDL/ANY privileges or EXECUTE on definer-rights/autonomous/external PL/SQL packages; enforce PDB-scoped roles | Native provider READ ONLY transaction and 4-case mandatory real UDF, builtin and autonomous-transaction negative/positive corpus | Privileged autonomous transactions and external packages not restricted at the DB role boundary | | Firebird 5 | Dedicated DB user/role with SELECT only on intended relations, no DML/DDL privileges and no EXECUTE on untrusted stored procedures/PSQL/external UDRs | Native READ ONLY transaction and mandatory Firebird real native/UDF whitelist negative control | EXTERNAL/privileged procedures or autonomous effects not sandboxed by the DB role | | SQLite | Read-only filesystem permissions and a read-only connection for Query-only targets wherever possible; embedding hosts **must not register writable callbacks** or callbacks opening separate privileged connections | File-backed 11-case mandatory Host Query corpus for hidden tables, overridden builtins, same-connection and **separate-connection callback writes**, whitelist and legal CTEs; query_only fence, built-in collision check, and same-connection EXPLAIN attestation of invoked callback opcodes | Untrusted virtual tables, host code outside invoked SQL function opcodes, writable same-process handles or external I/O outside the inspected Query | @@ -49,6 +49,7 @@ interpret this guard as authorization for arbitrary external callbacks. - A Query-only MCP tool grant is **not** a DB privilege grant. A null/empty table allowset means *no table whitelist*, not an empty readable set. To protect confidential rows, configure explicit target DB privileges **and** the intended MCP table allowset. Do not issue production Query keys bound to sysadmin/root/DBA identities. - Avoid privileged functions whose behavior the compiler cannot prove. Deny their EXECUTE at the database role level and disable/limit extensions, SQL CLR, native UDRs and callbacks as appropriate; fail closed instead of claiming arbitrary code purity. +- SQL Server Query with an absent **or empty** table allowset now rejects opaque qualified/quoted provider-native functions at the typed Host policy stage, before any physical connection. It still requires the independently attested least-privileged SQL Server login for permitted built-ins and table SELECTs. An explicit nonempty allowset retains its existing native-function restriction. Standalone SqlCore parsing/analysis remains compatible. The guard is a bounded defense against ambiguous UDF identity, **not** proof that unqualified functions or arbitrary external modules are pure. - Use per-provider real positive SELECT plus explicit negative forbidden SELECT/native routine controls before allowing new production credentials. Native functions with external effects are unsupported unless the deployment explicitly constrains them using DB role/host sandbox controls outside the SQL compiler. - The 32 physical Query cases across the six engines (PostgreSQL 11, SQLite 11, MySQL 3, SQL Server 2, Oracle 4, Firebird 1) are **bounded security evidence**, not a proof of arbitrary third-party routine purity. Each CI owner must prove nonempty, exact pass counts and zero skips. diff --git a/backend/src/Modules/HsSqlAgent.Server/Services/TypedQueryRuntime.cs b/backend/src/Modules/HsSqlAgent.Server/Services/TypedQueryRuntime.cs index d15daa984..49bd1638d 100644 --- a/backend/src/Modules/HsSqlAgent.Server/Services/TypedQueryRuntime.cs +++ b/backend/src/Modules/HsSqlAgent.Server/Services/TypedQueryRuntime.cs @@ -146,12 +146,15 @@ private static SqlExecutionPlanPolicy CreateQueryPolicy( securityPolicy.QueryMaxRows, effectiveTables is null && provider is (SqlAgentToolType.MySQL + or SqlAgentToolType.MsSqlServer or SqlAgentToolType.Oracle or SqlAgentToolType.Firebird) - // All three use a native read-only Query transaction, but - // cannot prove purity of an arbitrary schema-qualified native - // routine: MySQL TEMPORARY writes and Oracle/Firebird - // autonomous or external side effects escape that guarantee. - // Host-only typed policy; standalone SqlCore grammar unchanged. + // Read-only transaction fences (MySQL/Oracle/Firebird) cannot + // prove separate-connection or external routine purity. + // SQL Server's live effective-principal attestation is also + // not a semantic proof of EXECUTE AS/CLR/cross-database UDFs, + // nor protection against a permission race. Reject opaque + // native function identities before opening any connection. + // This is Host-only policy: standalone SqlCore grammar remains. ? SqlQueryNativeFunctionPolicy.RejectOpaqueQualifiedOrQuoted : SqlQueryNativeFunctionPolicy.PreserveCompilerGrammar); diff --git a/backend/src/UnitTest/HsSqlAgent.Server.Test/Services/Query/QueryNativeFunctionLiveTests.cs b/backend/src/UnitTest/HsSqlAgent.Server.Test/Services/Query/QueryNativeFunctionLiveTests.cs index ff6b43640..7d6373799 100644 --- a/backend/src/UnitTest/HsSqlAgent.Server.Test/Services/Query/QueryNativeFunctionLiveTests.cs +++ b/backend/src/UnitTest/HsSqlAgent.Server.Test/Services/Query/QueryNativeFunctionLiveTests.cs @@ -75,8 +75,26 @@ CREATE FUNCTION dbo.leak_secret() RETURNS NVARCHAR(64) var sql = "SELECT dbo.leak_secret() AS leaked FROM dbo.users"; Assert.Equal(Secret, (await ScalarAsync(setup, sql, ct))?.ToString()); var before = await PhysicalCountAsync(setup, "dbo.users", "dbo.secrets", ct); + + // The same opaque function must be denied at the compiler policy + // boundary even with no whitelist; the physical principal guard + // must not be the only reason this privileged login is rejected. + var provider = new MsSqlServerProvider(); + var noWhitelist = Assert.Throws(() => + new TypedQueryRuntime().Compile( + provider, sql, SqlAgentToolType.MsSqlServer, Policy(), + allowedTables: null)); + Assert.Equal("SQL_POLICY_NATIVE_QUERY_FUNCTION_NOT_PROVEN", + Assert.IsType( + noWhitelist.Data["HsSqlAgent.SqlCore.Diagnostic"]).Code); + var noWhitelistTool = await Tool(provider, SqlAgentToolType.MsSqlServer, + target, allowedTable: null).ExecuteQuerySql(sql, ct); + Assert.False(noWhitelistTool.Success); + Assert.Empty(noWhitelistTool.Rows); + Assert.DoesNotContain(Secret, noWhitelistTool.ToString()); + await AssertMcpDeniesAsync( - new MsSqlServerProvider(), SqlAgentToolType.MsSqlServer, + provider, SqlAgentToolType.MsSqlServer, target, "dbo.users", sql, "SELECT id FROM dbo.users", ct); Assert.Equal(before, await PhysicalCountAsync(setup, "dbo.users", "dbo.secrets", ct)); } diff --git a/backend/src/UnitTest/HsSqlAgent.Server.Test/Services/Query/RestrictedNativeFunctionQueryTests.cs b/backend/src/UnitTest/HsSqlAgent.Server.Test/Services/Query/RestrictedNativeFunctionQueryTests.cs index c5d71df5a..355e3493e 100644 --- a/backend/src/UnitTest/HsSqlAgent.Server.Test/Services/Query/RestrictedNativeFunctionQueryTests.cs +++ b/backend/src/UnitTest/HsSqlAgent.Server.Test/Services/Query/RestrictedNativeFunctionQueryTests.cs @@ -203,15 +203,45 @@ public void ReadOnlyNativeTransactionProvider_NoWhitelistCannotAuthorizeOpaqueFu } [Fact] - public void UnrestrictedSqlServerQuery_NativeFunctionNamesRetainExistingCompilerBehavior() + public void SqlServerQueryWithoutWhitelist_RejectsOpaqueNativeFunctionOnBothClrPaths() { + // A DB permission probe only attests the connected principal at one + // instant. Do not compile a provider-native routine call when Query + // has no table whitelist: privileged EXECUTE AS, CLR and cross-database + // module effects are not established by the SELECT AST. var provider = new Mock(); provider.SetupGet(x => x.Type).Returns(SqlAgentToolType.MsSqlServer); + const string opaqueSql = "SELECT dbo.leak_secret() AS leaked FROM dbo.users"; + var empty = new HashSet(StringComparer.OrdinalIgnoreCase); - var compiled = new TypedQueryRuntime().Compile( - provider.Object, "SELECT dbo.leak_secret() AS leaked FROM dbo.users", - SqlAgentToolType.MsSqlServer, Policy, allowedTables: null); + foreach (IReadOnlySet? allowed in new IReadOnlySet?[] { null, empty }) + { + var diagnosticException = Assert.Throws(() => + new TypedQueryRuntime().Compile( + provider.Object, opaqueSql, SqlAgentToolType.MsSqlServer, + Policy, allowed)); + var diagnostic = Assert.IsType( + diagnosticException.Data["HsSqlAgent.SqlCore.Diagnostic"]); + Assert.Equal("SQL_POLICY_NATIVE_QUERY_FUNCTION_NOT_PROVEN", diagnostic.Code); + Assert.Equal(SqlDiagnosticStage.Policy, diagnostic.Stage); - Assert.Contains("leak_secret", compiled.Sql, StringComparison.OrdinalIgnoreCase); + Assert.Throws(() => + new TypedQueryRuntime().CompileWithFacts( + provider.Object, opaqueSql, SqlAgentToolType.MsSqlServer, + Policy, allowed, targetProfile: null)); + + var builtIn = new TypedQueryRuntime().Compile( + provider.Object, "SELECT ABS(id) AS amount FROM dbo.users", + SqlAgentToolType.MsSqlServer, Policy, allowed); + Assert.Contains("ABS(", builtIn.Sql, StringComparison.OrdinalIgnoreCase); + } + + // Host-only policy cannot silently narrow the public standalone + // SqlCore grammar used for analysis and non-MCP consumers. + var standalone = SqlCoreFacade.CompileQuery( + opaqueSql, SqlAgentToolType.MsSqlServer, SqlAgentToolType.MsSqlServer, + new SqlPlanValidationContext("standalone-sqlserver-grammar"), + new SqlExecutionPlanPolicy(20)); + Assert.Contains("leak_secret", standalone.Sql, StringComparison.OrdinalIgnoreCase); } }