diff --git a/src/Core/Authorization/AuthorizationResolver.cs b/src/Core/Authorization/AuthorizationResolver.cs
index 7c3c2fedb2..205dc3d646 100644
--- a/src/Core/Authorization/AuthorizationResolver.cs
+++ b/src/Core/Authorization/AuthorizationResolver.cs
@@ -845,7 +845,12 @@ private static string GetClaimValue(Claim claim)
switch (claim.ValueType)
{
case ClaimValueTypes.String:
- return $"'{claim.Value}'";
+ // Escape embedded single quotes per OData 4.01 ABNF (Section 7: Literal Data Values)
+ // by doubling them. This prevents an attacker-influenced claim value from breaking
+ // out of the string literal and injecting additional OData predicates into the
+ // database authorization policy expression.
+ // See: http://docs.oasis-open.org/odata/odata/v4.01/cs01/abnf/odata-abnf-construction-rules.txt
+ return $"'{claim.Value.Replace("'", "''")}'";
case ClaimValueTypes.Boolean:
case ClaimValueTypes.Integer:
case ClaimValueTypes.Integer32:
diff --git a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
index c16d362268..d1d3f47c49 100644
--- a/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
+++ b/src/Service.Tests/Authorization/AuthorizationResolverUnitTests.cs
@@ -1155,6 +1155,60 @@ public void ParseValidDbPolicy(string policy, string expectedParsedPolicy)
Assert.AreEqual(parsedPolicy, expectedParsedPolicy);
}
+ ///
+ /// Validates that single quote characters embedded in a string-typed claim value are
+ /// escaped (doubled) per OData 4.01 ABNF when substituted into a database authorization
+ /// policy. Without escaping, an attacker who can influence a referenced JWT claim could
+ /// break out of the string literal and inject additional OData predicates - bypassing
+ /// row-level authorization. The substituted claim must remain enclosed in a single
+ /// string literal regardless of its contents.
+ ///
+ /// The raw claim value (as it appears in the JWT) to substitute.
+ /// The parsed policy after safe substitution.
+ [DataTestMethod]
+ [DataRow(
+ "alice' or 1 eq 1 or '",
+ "col1 eq 'alice'' or 1 eq 1 or '''",
+ DisplayName = "Injection attempt with OR predicate is neutralized by escaping single quotes")]
+ [DataRow(
+ "O'Brien",
+ "col1 eq 'O''Brien'",
+ DisplayName = "Legitimate single-quote-bearing value (e.g. surname) is safely escaped")]
+ [DataRow(
+ "''",
+ "col1 eq ''''''",
+ DisplayName = "Value composed solely of single quotes is fully escaped")]
+ [DataRow(
+ "no quotes here",
+ "col1 eq 'no quotes here'",
+ DisplayName = "Value without single quotes is unchanged aside from enclosing quotes")]
+ public void DbPolicy_StringClaim_SingleQuotesEscaped_PreventsODataInjection(
+ string claimValue,
+ string expectedParsedPolicy)
+ {
+ const string policyDefinition = "@item.col1 eq @claims.userId";
+
+ RuntimeConfig runtimeConfig = InitRuntimeConfig(
+ entityName: TEST_ENTITY,
+ roleName: TEST_ROLE,
+ operation: TEST_OPERATION,
+ includedCols: new HashSet { "col1" },
+ databasePolicy: policyDefinition);
+ AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig);
+
+ Mock context = new();
+
+ ClaimsIdentity identity = new(TEST_AUTHENTICATION_TYPE, TEST_CLAIMTYPE_NAME, AuthenticationOptions.ROLE_CLAIM_TYPE);
+ identity.AddClaim(new Claim("userId", claimValue, ClaimValueTypes.String));
+ ClaimsPrincipal principal = new(identity);
+ context.Setup(x => x.User).Returns(principal);
+ context.Setup(x => x.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]).Returns(TEST_ROLE);
+
+ string parsedPolicy = authZResolver.ProcessDBPolicy(TEST_ENTITY, TEST_ROLE, TEST_OPERATION, context.Object);
+
+ Assert.AreEqual(expectedParsedPolicy, parsedPolicy);
+ }
+
///
/// Tests authorization policy processing mechanism by validating value type compatibility
/// of claims present in HttpContext.User.Claims.