Root cause
Injection & unsafe execution
Add PrefixSearch for full-text prefix matching (GH-1327)
How AI contributed: New attack surfacePublished May 14, 2026 in
jasperfx/marten
Summary Marten's full-text search APIs interpolated the user-supplied regConfig parameter directly into the generated SQL without parameterization or validation, making every code path that exposes regConfig to untrusted input a SQL injection sink. Affected APIs · IQuerySession.SearchAsync<T>(string searchTerm, string regConfig, ...) · IQuerySession.PlainTextSearchAsync<T>(...) · IQuerySession.PhraseSearchAsync<T>(...) · IQuerySession.WebStyleSearchAsync<T>(...) · IQuerySession.PrefixSearchAsync<T>(...) · IQueryable<T>.Where(x => x.Search(term, regConfig)) and the matching PlainTextSearch / PhraseSearch / WebStyleSearch / PrefixSearch extension methods Details In the affected versions, FullTextWhereFragment renders the WHERE-clause SQL by string interpolation: _regConfig arrives unchanged from the public API surface above. Any value containing a single quote terminates the SQL literal and lets an attacker append arbitrary PostgreSQL. Confirmed exploit shapes (with regConfig set to attacker-controlled input) Goal: Payload Time-based blind: english'::text); SELECT pg_sleep(5); -- Information disclosure: english'; SELECT version(); -- DDL execution: english'; DROP TABLE mt_doc_article; -- All five overloads listed above produced SQL containing the verbatim payload. Impact · Confidentiality: an attacker can append arbitrary SELECT statements and exfiltrate database contents through error channels, response timing, or — if the application surfaces query results — directly. · Integrity / Availability: DDL, UPDATE, DELETE, and pg_sleep-style denial-of-service payloads succeed under the same vector. Concrete impact depends on the database role used by the Marten connection string. · Precondition: the calling application must forward attacker-controlled input into the regConfig parameter (e.g. a ?lang= query string mapped to regConfig). Applications that hard-code regConfig to a compile-time constant are not exploitable. Patches Fixed in Marten 8.36.1 (and forward) by #4343. FullTextWhereFragment now validates regConfig against ^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$ (a simple PostgreSQL identifier, optionally schema-qualified, capped at NAMEDATALEN-1 per side) and throws ArgumentException for anything else. The default value ("english"), schema-qualified configs ("pg_catalog.english"), and the standard PostgreSQL text-search configurations all continue to work. Workarounds If users cannot upgrade immediately, do one of the following at the application boundary: 1. Hard-code regConfig to a compile-time constant ("english", "simple", …) and never accept it from request input. 2. Validate any externally-sourced regConfig value before passing it to Marten — e.g. against the same regex as the patch (^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$) or against an allowlist of PostgreSQL configurations the application actually uses. 3. Drop the regConfig argument from the call site so Marten falls back to the safe default. Resources · Patch PR: JasperFx/marten#4343 · Patched file: FullTextWhereFragment.cs · Regression tests: full_text_regconfig_sql_injection.cs · CWE-89: <https://cwe.mitre.org/data/definitions/89.html> Credit Reported privately to the JasperFx team with a working proof of concept covering all five affected overloads.
FullTextWhereFragment builds the full-text WHERE clause by string interpolation and places the caller-supplied regConfig inside the to_tsvector literal, so a value containing a single quote ends the literal and appends arbitrary PostgreSQL. The fix validates regConfig against a PostgreSQL identifier pattern at construction time and throws ArgumentException for anything else.
Root cause
Add PrefixSearch for full-text prefix matching (GH-1327)
Fix
Reject non-identifier regConfig values in full-text search (SQL injection fix) (#4343)
AI-assisted fix: Claude Code · Jeremy D. Miller
Case facts
@@ -33,4 +33,10 @@ public Task<IReadOnlyList<TDoc>> WebStyleSearchAsync<TDoc>(string searchTerm, { return Query<TDoc>().Where(d => d.WebStyleSearch(searchTerm, regConfig)).ToListAsync(token); }++ public Task<IReadOnlyList<TDoc>> PrefixSearchAsync<TDoc>(string searchTerm,+ string regConfig = FullTextIndexDefinition.DefaultRegConfig, CancellationToken token = default)+ {+ return Query<TDoc>().Where(d => d.PrefixSearch(searchTerm, regConfig)).ToListAsync(token);+ } }@@ -54,6 +54,7 @@ public ISqlFragment Parse(IQueryableMemberCollection memberCollection, IReadOnly } var searchTerm = (string)expression.Arguments[1].Value();+ searchTerm = TransformSearchTerm(searchTerm); var regConfig = expression.Arguments.Count > 2 ? (expression.Arguments[2].Value() as string)!@@ -65,4 +66,6 @@ public ISqlFragment Parse(IQueryableMemberCollection memberCollection, IReadOnly searchTerm, regConfig); }++ protected virtual string TransformSearchTerm(string searchTerm) => searchTerm; }@@ -0,0 +1,18 @@+#nullable enable+using System;+using System.Linq;++namespace Marten.Linq.Parsing.Methods.FullText;++internal class PrefixSearch: FullTextSearchMethodCallParser+{+ public PrefixSearch(): base(nameof(LinqExtensions.PrefixSearch), FullTextSearchFunction.to_tsquery)+ {+ }++ protected override string TransformSearchTerm(string searchTerm)+ {+ var words = searchTerm.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);+ return string.Join(" & ", words.Select(w => w + ":*"));+ }+}@@ -1,5 +1,7 @@ #nullable enable+using System; using System.Linq;+using System.Text.RegularExpressions; using Marten.Linq.Parsing.Methods.FullText; using Marten.Schema; using Weasel.Postgresql;@@ -10,6 +12,17 @@ namespace Marten.Linq.SqlGeneration.Filters; internal class FullTextWhereFragment: ISqlFragment {+ // PostgreSQL text-search configuration names are stored as identifiers in+ // pg_ts_config (see https://www.postgresql.org/docs/current/textsearch-configuration.html).+ // We allow simple unquoted identifiers — optionally schema-qualified — so values+ // like "english", "french", or "pg_catalog.english" pass through, while anything+ // containing whitespace, quotes, semicolons, or other punctuation is rejected.+ // This is a security-critical check: regConfig is interpolated into SQL by Sql below,+ // so any value that escapes this pattern would be a SQL injection sink.+ private static readonly Regex _regConfigPattern = new(+ @"^[a-zA-Z_][a-zA-Z0-9_]{0,62}(\.[a-zA-Z_][a-zA-Z0-9_]{0,62})?$",+ RegexOptions.Compiled | RegexOptions.CultureInvariant);+ private readonly string _dataConfig; private readonly string _regConfig; private readonly FullTextSearchFunction _searchFunction;@@ -18,13 +31,32 @@ internal class FullTextWhereFragment: ISqlFragment public FullTextWhereFragment(DocumentMapping? mapping, FullTextSearchFunction searchFunction, string searchTerm, string regConfig = FullTextIndexDefinition.DefaultRegConfig) {+ ValidateRegConfig(regConfig);+ _regConfig = regConfig; _dataConfig = GetDataConfig(mapping, regConfig).Replace("data", "d.data"); _searchFunction = searchFunction; _searchTerm = searchTerm; } + private static void ValidateRegConfig(string regConfig)+ {+ if (regConfig is null)+ {+ throw new ArgumentNullException(nameof(regConfig));+ }++ if (!_regConfigPattern.IsMatch(regConfig))+ {+ throw new ArgumentException(+ $"Invalid PostgreSQL text-search configuration name '{regConfig}'. " ++ "regConfig must be a simple PostgreSQL identifier (optionally schema-qualified), " ++ "matching ^[a-zA-Z_][a-zA-Z0-9_]*(\\.[a-zA-Z_][a-zA-Z0-9_]*)?$.",+ nameof(regConfig));+ }+ }+ // don't parameterize full-text search config as it ruins the performance with the query plan in PG private string Sql => $"to_tsvector('{_regConfig}'::regconfig, {_dataConfig}) @@ {_searchFunction}('{_regConfig}'::regconfig, ?)";@@ -0,0 +1,118 @@+using System;+using System.Linq;+using JasperFx;+using Marten;+using Marten.Linq;+using Marten.Testing.Harness;+using Shouldly;+using Xunit;++namespace LinqTests.Bugs;++// Reproduces and locks down the SQL injection vector reported against the+// regConfig parameter on Marten's full-text search APIs. Pre-fix, the regConfig+// string was interpolated directly into the generated SQL by+// FullTextWhereFragment, making every overload below a sink for arbitrary+// PostgreSQL syntax: time-based blind, information disclosure, DDL, etc.+//+// The tests below assert that a regConfig value containing an obvious payload+// (single quote, semicolon, comment marker, sleep call) does NOT survive into+// the generated SQL — either by being rejected at query-construction time+// (preferred) or by being stripped/escaped. Either outcome breaks the+// injection vector.+public class full_text_regconfig_sql_injection+{+ private const string TimeBasedBlindPayload = "english'::text); SELECT pg_sleep(5); --";+ private const string ExfiltrationPayload = "english'; SELECT version(); --";+ private const string DdlPayload = "english'; DROP TABLE mt_doc_article; --";++ public class Article+ {+ public Guid Id { get; set; }+ public string Title { get; set; } = string.Empty;+ public string Body { get; set; } = string.Empty;+ }++ private static IDocumentStore BuildStore() => DocumentStore.For(opts =>+ {+ opts.Connection(ConnectionSource.ConnectionString);+ opts.AutoCreateSchemaObjects = AutoCreate.None;+ });++ // The full set of full-text search overloads that take a user-controllable+ // regConfig argument and route through FullTextWhereFragment.+ public static TheoryData<string, Func<IQueryable<Article>, string, IQueryable<Article>>> Overloads => new()+ {+ { nameof(LinqExtensions.Search), (q, rc) => q.Where(x => x.Search("term", rc)) },+ { nameof(LinqExtensions.PlainTextSearch), (q, rc) => q.Where(x => x.PlainTextSearch("term", rc)) },+ { nameof(LinqExtensions.PhraseSearch), (q, rc) => q.Where(x => x.PhraseSearch("term", rc)) },+ { nameof(LinqExtensions.WebStyleSearch), (q, rc) => q.Where(x => x.WebStyleSearch("term", rc)) },+ { nameof(LinqExtensions.PrefixSearch), (q, rc) => q.Where(x => x.PrefixSearch("term", rc)) },+ };++ private static readonly string[] InjectionPayloads =+ [+ TimeBasedBlindPayload,+ ExfiltrationPayload,+ DdlPayload,+ ];++ [Theory]+ [MemberData(nameof(Overloads))]+ public void rejects_injection_payloads_in_regConfig(+ string overloadName,+ Func<IQueryable<Article>, string, IQueryable<Article>> apply)+ {+ _ = overloadName;+ using var store = BuildStore();+ using var session = store.LightweightSession();++ foreach (var payload in InjectionPayloads)+ {+ var query = apply(session.Query<Article>(), payload);++ // Either the query refuses to materialise (validation throws), or+ // the rendered SQL must NOT contain the raw payload. Both outcomes+ // close the injection vector; we accept either.+ string? sql = null;+ try+ {+ sql = query.ToCommand(FetchType.FetchMany).CommandText;+ }+ catch (ArgumentException)+ {+ // Acceptable: validation rejected the input before SQL was generated.+ continue;+ }++ // None of these tokens should ever appear in SQL generated from a+ // legitimate full-text search; their presence means the payload wasAI-assisted change 82cc0ee3d5ef643716d6b15f22ad268986c3b721dddb87ccc981e6655b053359 · Fix 21f27fccff1eec595b21fd8ca107934b80809df7cc4b14dfc9608fa3dfd786df