fix: escape interpolated identifiers and literals in constructed SQL (#390) - #403
Merged
Conversation
…390) The companion audit to weasel#416 on the SQL Server side, plus the fixes it turned up and a shared escaping helper the codebase was missing. ## What the audit found Nothing upstream is a sanitizing boundary, so nothing could be waved through on the grounds that it "came from Weasel": SqlServerMigrator.AssertValidIdentifier is literally `// Nothing yet` (weaker than the Postgres one weasel#416 already judged insufficient), SchemaUtils.QuoteName brackets only reserved keywords and escapes nothing, and DbObjectName performs no validation at all. Every site had to be classified on its own. Good news first: SQL Server's managed tenant partitioning never puts a tenant id in an identifier position. Weasel.SqlServer allocates a compact integer ordinal and names partition objects from that, binding the tenant id as a parameter everywhere it touches the registry — so the stored/second-order shape that bit Marten (a per-tenant sequence name read back out of a registry table and interpolated) does not exist here. Class (c) — runtime data or a public-API argument reaching an escaping-required position: - AdvancedOperations.CleanAllDocumentsAsync / CleanAllEventDataAsync / CompletelyRemoveAllAsync interpolate table names read back out of INFORMATION_SCHEMA and sys.tables into `[{table}]`. Second-order by construction: the value is planted in the catalog by one call and fires from an unrelated later one. - DocumentIndex.IndexName and DocumentForeignKey.ConstraintName are public-API arguments (Index(..., idx => idx.IndexName = ...), ForeignKey(..., fk => fk.ConstraintName = ...)) that land in a string literal (the sys.indexes / sys.foreign_keys existence probe) AND a bracketed identifier (CREATE INDEX / ADD CONSTRAINT) in the same statement pair. Two positions, two different escapes, neither applied — the exact "easy to get half-right" case the issue calls out. - EventGraph tag tables: RegisterTagType<TTag>(string tableSuffix) is a public-API argument that a dozen sites composed into `[{schema}].[pc_event_tag_{suffix}]` by hand. Class (b), fixed anyway because the divergence is a correctness bug independent of injection: DocumentIndex/DocumentForeignKey built `COL_LENGTH('{schema}.{table}', ...)` UNQUOTED while the ALTER TABLE beside it used `[{schema}].[{table}]` — the same object, two spellings, agreeing only until quoting is needed. DocumentTableEnsurer nests a qualified name inside a dynamic-SQL string literal. FlatTableProjection's SetValue writes its configured value into a literal unescaped. ## The fix A single Polecat.Internal.SqlEscaping (QuoteIdentifier / QualifiedName / Literal / LiteralBody) that every constructed name and literal now routes through, deliberately with NO "is this already escaped?" shortcut — weasel#416's postmortem is that such a test cannot be made safely from the shape of untrusted input, and skipping escaping for a quote-wrapped value is strictly worse than the missing escape it replaces. Beyond escaping, the audit's other lesson was one-builder-per-object-name. Twelve independent compositions of the DCB tag table name and five of pc_natural_key_* now go through EventGraph.TagTableName / NaturalKeyTableName, and DocumentMapping, PolecatDocumentSchemaResolver, MasterTableTenancy, HiloSequence (seven copies) and the EventGraph table-name properties all compose through the helper. Two sites were already correct and are left alone: PolecatDocumentStorage's tenant filter and SubClassPolecatStorage's doc_type filter already double embedded quotes. ## Tests Unit coverage for each escape, including that a name bound for both positions composes the two in order, and that there is no already-quoted shortcut. Round-trip integration coverage per the issue's deliverable: tenant ids containing `'` (with a `'; DROP TABLE pc_events--` payload) and `]` survive document store/load/LINQ and event append/fetch under conjoined tenancy with tenant isolation intact, and an index name containing both characters is created and its existence probe matches on a second schema apply — which is what proves the literal and identifier positions agree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G8tN8ApXiKhyVzia4iwmof
jeremydmiller
added a commit
that referenced
this pull request
Aug 2, 2026
weasel#420 + weasel#422: the identifier validation added for weasel#416 now covers every provider migrator instead of PostgreSQL alone, and PostgreSQL's own copy is folded onto the shared Weasel.Core helper so the providers cannot drift apart again. This is the directly relevant half for Polecat: the SQL Server migrator previously validated NOTHING at all. Oracle, MySQL and Sqlite each missed their own quoting characters and were fixed in the same pass. Pairs with #403, which escaped Polecat's own interpolated identifiers and literals -- that closed the call sites, this closes the layer underneath them. Full suite net9.0: 1657/0/3, identical to the same-day main baseline. Claude-Session: https://claude.ai/code/session_01VpDCvJcBDZerieJB4JEHde Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merged
jeremydmiller
added a commit
that referenced
this pull request
Aug 3, 2026
Minor rather than patch: 5.9.1's line added public surface (IEventBinarySerializer, EventStoreOptions.AddEventType/AddEventTypes) and moved the whole JasperFx/Weasel matrix forward. Since 5.9.1: - feat: pluggable binary event serialization via IEventBinarySerializer (#388/#402) - feat: EventStoreOptions.AddEventType / AddEventTypes (#395/#396) - fix: escape interpolated identifiers and literals in constructed SQL (#390/#403) - fix: throw a lone DcbConcurrencyException unwrapped from SaveChangesAsync (#394/#397) - deps: JasperFx 2.37.2 -> 2.38.0, Weasel 9.23.0 -> 9.23.2 (#405, #407) - Polecat's ProjectionScenario is now a thin subclass of the lifted JasperFx.Events.TestSupport harness rather than a seven-file copy of Marten's (#404/#408, jasperfx#616) -- a behavior change for anyone already using it, see the release notes - test infrastructure: compliance waves 1-3 (#393, #400, #407), parallel-safe test suite (#389), IntegrationContext.StoreOptions document cleaning (#398/#401)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #390. Companion audit to weasel#416 on the SQL Server side, plus the fixes it turned up and the shared escaping helper the codebase was missing.
The audit
Nothing upstream is a sanitizing boundary, so nothing could be waved through on the grounds that it "came from Weasel". The issue asked specifically about this, and the answers are worse than the Postgres side:
SqlServerMigrator.AssertValidIdentifier// Nothing yet— an empty method bodySchemaUtils.QuoteNameschema.tableotherwiseDbObjectName/SqlServerObjectNameGood news first: SQL Server's managed tenant partitioning never puts a tenant id in an identifier position.
Weasel.SqlServer.ManagedTenantPartitionsallocates a compact integer ordinal and names partition objects from that, binding the tenant id as a parameter everywhere it touches the registry. The stored/second-order shape that bit Marten — a per-tenant sequence name read back out of a registry table and interpolated — structurally does not exist here.Class (c): runtime data or a public-API argument in an escaping-required position
AdvancedOperations.CleanAllDocumentsAsync/CleanAllEventDataAsync/CompletelyRemoveAllAsyncinterpolate table names read back out ofINFORMATION_SCHEMAandsys.tablesinto[{table}]. Second-order by construction: the value is planted in the catalog by one call and fires from an unrelated later one.DocumentIndex.IndexNameandDocumentForeignKey.ConstraintNameare public-API arguments (Index(…, idx => idx.IndexName = …),ForeignKey<T>(…, fk => fk.ConstraintName = …)) that land in a string literal (thesys.indexes/sys.foreign_keysexistence probe) and a bracketed identifier (CREATE INDEX/ADD CONSTRAINT) in the same statement pair. Two positions, two different escapes, neither applied — precisely the "easy to get half-right" case the issue calls out.RegisterTagType<TTag>(string tableSuffix)is a public-API argument that twelve sites composed into[{schema}].[pc_event_tag_{suffix}]by hand.Class (b), fixed anyway because the divergence is a correctness bug independent of injection
DocumentIndexandDocumentForeignKeybuiltCOL_LENGTH('{schema}.{table}', …)unquoted while theALTER TABLEbeside it used[{schema}].[{table}]— the same object, two spellings, agreeing only until quoting is needed.DocumentTableEnsurernests a qualified name inside a dynamic-SQL string literal.FlatTableProjection'sSetValuewrites its configured value into a literal unescaped.Two sites were already correct and are left alone:
PolecatDocumentStorage's tenant filter andSubClassPolecatStorage'sdoc_typefilter already double embedded quotes.The fix
A single
Polecat.Internal.SqlEscaping(QuoteIdentifier/QualifiedName/Literal/LiteralBody) that every constructed name and literal routes through — deliberately with no "is this already escaped?" shortcut. weasel#416's postmortem is that such a test cannot be made safely from the shape of untrusted input, and skipping escaping for a quote-wrapped value is strictly worse than the missing escape it replaces.Beyond escaping, the audit's other lesson was one builder per object name. Twelve independent compositions of the tag table name and five of
pc_natural_key_*now go throughEventGraph.TagTableName/NaturalKeyTableName, andDocumentMapping,PolecatDocumentSchemaResolver,MasterTableTenancy,HiloSequence(seven copies) and theEventGraphtable-name properties all compose through the helper.Cross-repo finding
SqlServerMigrator.AssertValidIdentifierbeing an empty method is worth its own Weasel issue — the issue asked whether it rejects],[,"and;, and it rejects nothing at all. Polecat does not rely on it either way after this change, but the Marten/Weasel side should know.Tests
Unit coverage for each escape, including that a name bound for both positions composes the two in order, and that there is no already-quoted shortcut. Round-trip integration coverage per the issue's stated deliverable: tenant ids containing
'(carrying a'; DROP TABLE pc_events--payload) and]survive document store/load/LINQ and event append/fetch under conjoined tenancy with tenant isolation intact, and an index name containing both characters is created and its existence probe matches on a second schema apply — which is what proves the literal and identifier positions agree.Full
Polecat.Testssuite locally against dockerized SQL Server 2025, net10.0: 1647 total, 0 failed, 1644 passed, 3 skipped.🤖 Generated with Claude Code
https://claude.ai/code/session_01G8tN8ApXiKhyVzia4iwmof