Marten has an injection vulnerability in its full-text search regConfig parameter
Description
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 matchingPlainTextSearch/PhraseSearch/WebStyleSearch/PrefixSearchextension methods
Details
In the affected versions, `FullTextWhereFragment` renders the WHERE-clause SQL by string interpolation:
private string Sql =>
$"to_tsvector('{_regConfig}'::regconfig, {_dataConfig}) @@ {_searchFunction}('{_regConfig}'::regconfig, ?)";
_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
SELECTstatements and exfiltrate database contents through error channels, response timing, or — if the application surfaces query results — directly. - Integrity / Availability: DDL,
UPDATE,DELETE, andpg_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
regConfigparameter (e.g. a?lang=query string mapped toregConfig). Applications that hard-coderegConfigto 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:
- Hard-code
regConfigto a compile-time constant ("english","simple", …) and never accept it from request input. - Validate any externally-sourced
regConfigvalue 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. - Drop the
regConfigargument 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.
Patches
1626249656829Reject non-identifier regConfig values in full-text search (SQL injection fix) (#4343)
2 files changed · +150 −0
src/LinqTests/Bugs/full_text_regconfig_sql_injection.cs+118 −0 added@@ -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 was + // interpolated verbatim. The matched substring is what would + // otherwise execute on the database. + sql.ShouldNotContain("pg_sleep", Case.Insensitive); + sql.ShouldNotContain("DROP TABLE", Case.Insensitive); + sql.ShouldNotContain("SELECT version", Case.Insensitive); + sql.ShouldNotContain("--"); + } + } + + [Theory] + [InlineData("english")] + [InlineData("french")] + [InlineData("simple")] + [InlineData("pg_catalog.english")] + public void accepts_known_safe_regConfig_values(string regConfig) + { + using var store = BuildStore(); + using var session = store.LightweightSession(); + + // None of these should throw; all should produce valid SQL referencing + // the regconfig name (so we know the parameter is still being honored). + var sql = session.Query<Article>() + .Where(x => x.PlainTextSearch("term", regConfig)) + .ToCommand(FetchType.FetchMany) + .CommandText; + + sql.ShouldContain(regConfig); + } +}
src/Marten/Linq/SqlGeneration/Filters/FullTextWhereFragment.cs+32 −0 modified@@ -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, ?)";
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
4News mentions
0No linked articles in our index yet.