Skip to content

fix: harden ReadOnly SQL validation and fix security-review findings#154

Merged
daniel3303 merged 9 commits into
mainfrom
fix/security-review-hardening
Jul 7, 2026
Merged

fix: harden ReadOnly SQL validation and fix security-review findings#154
daniel3303 merged 9 commits into
mainfrom
fix/security-review-hardening

Conversation

@daniel3303

Copy link
Copy Markdown
Owner

Summary

  • Closes a fail-open gap in the ReadOnly statement whitelist: parenthesized query heads ((SELECT ...), set-operation chains, trailing clauses) were waved through unvalidated; they are now unwrapped and validated recursively, unreadable statement shapes are rejected, and recursion depth is bounded so adversarial nesting cannot crash the process with a StackOverflow.
  • Replaces the ReadUncommitted transaction isolation (dirty reads on SQL Server presented to the LLM as fact) with the provider default; SQLite ReadOnly requests a deferred BEGIN via the enforcer since BEGIN IMMEDIATE conflicts with PRAGMA query_only.
  • Caches the schema description for the application lifetime (keyed by DbContext type) instead of per request scope, wires the previously dead MaxOutputTokens option, honors a custom Anthropic endpoint, and documents that include/exclude is schema hiding rather than access control.

Code changes

  • src/Equibles.AgentQL.EntityFrameworkCore/Query/ReadOnly/ReadOnlyStatementValidator.cs — unwraps parenthesized heads, validates set-operation tails and trailing clauses, rejects unreadable shapes, guards unterminated CTE parens, caps nesting depth at 64.
  • src/Equibles.AgentQL.EntityFrameworkCore/Query/QueryExecutor.cs — provider-default transaction isolation; ReadOnly isolation hint comes from the session enforcer.
  • src/Equibles.AgentQL.EntityFrameworkCore/Query/ReadOnly/IReadOnlySessionEnforcer.cs, SqliteReadOnlySessionEnforcer.cs — new ReadOnlyIsolationLevel member; SQLite overrides to a deferred BEGIN.
  • src/Equibles.AgentQL/Configuration/SchemaDescriptionCache.cs (new), AgentQLOptions.cs, src/Equibles.AgentQL.EntityFrameworkCore/Schema/SchemaProvider.cs — app-lifetime schema cache on the options singleton, keyed by context type, single-flight build with retry on failure.
  • src/Equibles.AgentQL.MicrosoftAI/Extensions/ServiceCollectionExtensions.cs — applies MaxOutputTokens via ConfigureOptions; Anthropic client uses Endpoint when set.
  • src/Equibles.AgentQL.MicrosoftAI/SelfCorrectingChatClient.cs — question extraction skips only messages that start with the injected reminder tag.
  • README.md — exclusion-is-not-access-control warning; updated whitelist description.
  • tests/…/ReadOnlyStatementValidatorTests.cs, tests/…/QueryExecutorReadOnlyStatementWhitelistTests.cs — coverage for paren shapes (allowed + rejected), depth bound, malformed WITH, and end-to-end proof parenthesized SELECTs still execute.

A statement starting with '(' carried no readable verb and was waved
through the whitelist unchecked. Parenthesized heads are now unwrapped
and validated recursively, set-operation tails (UNION/INTERSECT/EXCEPT)
validate their right-hand side as a statement, and any statement whose
shape cannot be read is rejected instead of assumed harmless. Also
guards the CTE walker against unterminated body parens, which previously
threw a negative-length substring error.
…dUncommitted

ReadUncommitted allowed dirty reads on SQL Server in ReadOnly mode —
uncommitted data that may later be rolled back, which the LLM would
present as fact. The executor now uses the provider's default isolation
(ReadCommitted on PostgreSQL/SQL Server). SQLite is the exception: its
default maps to BEGIN IMMEDIATE, which acquires a write lock that PRAGMA
query_only itself refuses, so the SQLite enforcer requests a deferred
BEGIN via a new ReadOnlyIsolationLevel hint on the enforcer interface.
SchemaProvider is scoped, so its instance-level cache re-ran the full
EF model walk (plus a connection open for server info) on every request
scope. The cache now lives on the AgentQLOptions singleton, keyed by
options lifetime, with a semaphore so the factory runs once under
concurrent first calls.
…ndpoint

AgentQLChatOptions.MaxOutputTokens was documented but never read; it is
now applied through ConfigureOptions when the per-request ChatOptions
leaves it unset. The Anthropic factory also honors an explicitly
configured Endpoint (BaseUrl) instead of silently ignoring it, while
keeping the SDK default when none is set.
…uestion

ExtractQuestion skipped any user message merely containing the literal
'<system-reminder>' text. Injected reminders always start with the tag,
so matching on the prefix keeps genuine user questions that mention the
tag in the running.
…tion

States explicitly that include/exclude is schema hiding, not access
control — the executor does not block SQL referencing excluded tables
or columns, so sensitive data must be protected at the database. Also
updates the ReadOnly whitelist description for the parenthesized-head
validation.
The paren-unwrap, set-operation and CTE recursion had no depth guard, so
a MaxSqlLength-sized run of nested parens could recurse thousands of
frames deep and kill the process with an uncatchable StackOverflow.
Depth is now capped at 64 nested query heads; deeper input is rejected
with the usual silent-neutralization contract.
DI hands the last-registered AgentQLOptions singleton to every provider,
so a host registering two contexts would have seen one context's cached
schema served for the other. The cache dictionary is now keyed by the
context CLR type; a failed build caches nothing and is retried.
// default, so falling back to GetEndpoint() here would break the
// out-of-the-box Anthropic setup.
var client = string.IsNullOrEmpty(options.Endpoint)
? new AnthropicClient { ApiKey = options.ApiKey }
// out-of-the-box Anthropic setup.
var client = string.IsNullOrEmpty(options.Endpoint)
? new AnthropicClient { ApiKey = options.ApiKey }
: new AnthropicClient { ApiKey = options.ApiKey, BaseUrl = options.Endpoint };
Microsoft.EntityFrameworkCore.Sqlite 10.0.8 transitively brings in
SQLitePCLRaw.lib.e_sqlite3 2.1.11, flagged by NuGet audit with a known
high-severity vulnerability (GHSA-2m69-gcr7-jv3q), which fails the
warnings-as-errors lint build. Enables central transitive pinning and
lifts the native lib to 3.53.3 (versioned by SQLite release; the managed
bundle/core stay on 2.1.x and accept it).
@daniel3303 daniel3303 merged commit 2586c2e into main Jul 7, 2026
7 checks passed
@daniel3303 daniel3303 deleted the fix/security-review-hardening branch July 7, 2026 15:26
@daniel3303 daniel3303 mentioned this pull request Jul 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants