Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/Core/Authorization/AuthorizationResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1155,6 +1155,60 @@ public void ParseValidDbPolicy(string policy, string expectedParsedPolicy)
Assert.AreEqual(parsedPolicy, expectedParsedPolicy);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="claimValue">The raw claim value (as it appears in the JWT) to substitute.</param>
/// <param name="expectedParsedPolicy">The parsed policy after safe substitution.</param>
[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<string> { "col1" },
databasePolicy: policyDefinition);
AuthorizationResolver authZResolver = AuthorizationHelpers.InitAuthorizationResolver(runtimeConfig);

Mock<HttpContext> 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);
}

/// <summary>
/// Tests authorization policy processing mechanism by validating value type compatibility
/// of claims present in HttpContext.User.Claims.
Expand Down