Skip to content
Open
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
5 changes: 3 additions & 2 deletions src/Core/Configurations/RuntimeConfigValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,7 @@ private void ValidateRestMethods(Entity entity, string entityName)
/// <summary>
/// Helper method to validate that the rest path property for the entity is correctly configured.
/// The rest path should not be null/empty and should not contain any reserved characters.
/// Allows sub-directories (forward slashes) in the path.
/// </summary>
/// <param name="entityName">Name of the entity.</param>
/// <param name="pathForEntity">The rest path for the entity.</param>
Expand All @@ -672,10 +673,10 @@ private static void ValidateRestPathSettingsForEntity(string entityName, string
);
}

if (RuntimeConfigValidatorUtil.DoesUriComponentContainReservedChars(pathForEntity))
if (!RuntimeConfigValidatorUtil.TryValidateEntityRestPath(pathForEntity, out string? errorMessage))
{
throw new DataApiBuilderException(
message: $"The rest path: {pathForEntity} for entity: {entityName} contains one or more reserved characters.",
message: $"The rest path: {pathForEntity} for entity: {entityName} {errorMessage ?? "contains invalid characters."}",
statusCode: HttpStatusCode.ServiceUnavailable,
subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError
);
Expand Down
66 changes: 66 additions & 0 deletions src/Core/Configurations/RuntimeConfigValidatorUtil.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,72 @@ public static bool DoesUriComponentContainReservedChars(string uriComponent)
return _reservedUriCharsRgx.IsMatch(uriComponent);
}

/// <summary>
/// Method to validate an entity REST path allowing sub-directories (forward slashes).
/// Each segment of the path is validated for reserved characters.
/// </summary>
/// <param name="entityRestPath">The entity REST path to validate.</param>
/// <param name="errorMessage">Output parameter containing a specific error message if validation fails.</param>
/// <returns>true if the path is valid, false otherwise.</returns>
public static bool TryValidateEntityRestPath(string entityRestPath, out string? errorMessage)
{
errorMessage = null;

// Check for backslash usage - common mistake
if (entityRestPath.Contains('\\'))
{
errorMessage = "contains a backslash (\\). Use forward slash (/) for path separators.";
return false;
}

// Check for whitespace
if (entityRestPath.Any(char.IsWhiteSpace))
{
errorMessage = "contains whitespace which is not allowed in URL paths.";
return false;
}

// Split the path by '/' to validate each segment separately
string[] segments = entityRestPath.Split('/');

// Validate each segment doesn't contain reserved characters
foreach (string segment in segments)
{
if (string.IsNullOrEmpty(segment))
{
errorMessage = "contains empty path segments. Ensure there are no leading, consecutive, or trailing slashes.";
return false;
}

// Check for specific reserved characters and provide helpful messages
if (segment.Contains('?'))
{
errorMessage = "contains '?' which is reserved for query strings in URLs.";
return false;
}

if (segment.Contains('#'))
{
errorMessage = "contains '#' which is reserved for URL fragments.";
return false;
}

if (segment.Contains(':'))
{
errorMessage = "contains ':' which is reserved for port numbers in URLs.";
return false;
}

if (_reservedUriCharsRgx.IsMatch(segment))
{
errorMessage = "contains reserved characters that are not allowed in URL paths.";
return false;
}
}

return true;
}

/// <summary>
/// Method to validate if the TTL passed by the user is valid
/// </summary>
Expand Down
41 changes: 24 additions & 17 deletions src/Core/Services/RestService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,12 @@ public bool TryGetRestRouteFromConfig([NotNullWhen(true)] out string? configured
/// as the substring following the '/'.
/// For example, a request route should be of the form
/// {EntityPath}/{PKColumn}/{PkValue}/{PKColumn}/{PKValue}...
/// or {SubDir}/.../{EntityPath}/{PKColumn}/{PkValue}/{PKColumn}/{PKValue}...
///
/// Note: Uses longest-prefix matching (most-specific match wins). When multiple
/// entity paths could match, the longest matching path takes precedence. For example,
/// if both "cart" and "cart/item" are valid entity paths, a request to
/// "/cart/item/id/123" will match "cart/item" with primaryKeyRoute "id/123".
/// </summary>
/// <param name="routeAfterPathBase">The request route (no '/' prefix) containing the entity path
/// (and optionally primary key).</param>
Expand All @@ -448,26 +454,27 @@ public bool TryGetRestRouteFromConfig([NotNullWhen(true)] out string? configured

RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig();

// Split routeAfterPath on the first occurrence of '/', if we get back 2 elements
// this means we have a non-empty primary key route which we save. Otherwise, save
// primary key route as empty string. Entity Path will always be the element at index 0.
// ie: {EntityPath}/{PKColumn}/{PkValue}/{PKColumn}/{PKValue}...
// splits into [{EntityPath}] when there is an empty primary key route and into
// [{EntityPath}, {Primarykeyroute}] when there is a non-empty primary key route.
int maxNumberOfElementsFromSplit = 2;
string[] entityPathAndPKRoute = routeAfterPathBase.Split(new[] { '/' }, maxNumberOfElementsFromSplit);
string entityPath = entityPathAndPKRoute[0];
string primaryKeyRoute = entityPathAndPKRoute.Length == maxNumberOfElementsFromSplit ? entityPathAndPKRoute[1] : string.Empty;

if (!runtimeConfig.TryGetEntityNameFromPath(entityPath, out string? entityName))
// Split routeAfterPath to extract segments
string[] segments = routeAfterPathBase.Split('/');

// Try longest paths first (most-specific match wins)
// Start with all segments, then remove one at a time
for (int i = segments.Length; i >= 1; i--)
{
throw new DataApiBuilderException(
message: $"Invalid Entity path: {entityPath}.",
statusCode: HttpStatusCode.NotFound,
subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound);
string entityPath = string.Join("/", segments.Take(i));
if (runtimeConfig.TryGetEntityNameFromPath(entityPath, out string? entityName))
{
// Found entity
string primaryKeyRoute = i < segments.Length ? string.Join("/", segments.Skip(i)) : string.Empty;
return (entityName!, primaryKeyRoute);
}
}

return (entityName!, primaryKeyRoute);
// No entity found - show the full path for better debugging
throw new DataApiBuilderException(
message: $"Invalid Entity path: {routeAfterPathBase}.",
statusCode: HttpStatusCode.NotFound,
subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound);
}

/// <summary>
Expand Down
30 changes: 20 additions & 10 deletions src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2066,21 +2066,31 @@ public void ValidateRestMethodsForEntityInConfig(
[DataTestMethod]
[DataRow(true, "EntityA", "", true, "The rest path for entity: EntityA cannot be empty.",
DisplayName = "Empty rest path configured for an entity fails config validation.")]
[DataRow(true, "EntityA", "entity?RestPath", true, "The rest path: entity?RestPath for entity: EntityA contains one or more reserved characters.",
[DataRow(true, "EntityA", "entity?RestPath", true, "The rest path: entity?RestPath for entity: EntityA contains '?' which is reserved for query strings in URLs.",
DisplayName = "Rest path for an entity containing reserved character ? fails config validation.")]
[DataRow(true, "EntityA", "entity#RestPath", true, "The rest path: entity#RestPath for entity: EntityA contains one or more reserved characters.",
DisplayName = "Rest path for an entity containing reserved character ? fails config validation.")]
[DataRow(true, "EntityA", "entity[]RestPath", true, "The rest path: entity[]RestPath for entity: EntityA contains one or more reserved characters.",
DisplayName = "Rest path for an entity containing reserved character ? fails config validation.")]
[DataRow(true, "EntityA", "entity+Rest*Path", true, "The rest path: entity+Rest*Path for entity: EntityA contains one or more reserved characters.",
DisplayName = "Rest path for an entity containing reserved character ? fails config validation.")]
[DataRow(true, "Entity?A", null, true, "The rest path: Entity?A for entity: Entity?A contains one or more reserved characters.",
[DataRow(true, "EntityA", "entity#RestPath", true, "The rest path: entity#RestPath for entity: EntityA contains '#' which is reserved for URL fragments.",
DisplayName = "Rest path for an entity containing reserved character # fails config validation.")]
[DataRow(true, "EntityA", "entity[]RestPath", true, "The rest path: entity[]RestPath for entity: EntityA contains reserved characters that are not allowed in URL paths.",
DisplayName = "Rest path for an entity containing reserved character [] fails config validation.")]
[DataRow(true, "EntityA", "entity+Rest*Path", true, "The rest path: entity+Rest*Path for entity: EntityA contains reserved characters that are not allowed in URL paths.",
DisplayName = "Rest path for an entity containing reserved character +* fails config validation.")]
[DataRow(true, "Entity?A", null, true, "The rest path: Entity?A for entity: Entity?A contains '?' which is reserved for query strings in URLs.",
DisplayName = "Entity name for an entity containing reserved character ? fails config validation.")]
[DataRow(true, "Entity&*[]A", null, true, "The rest path: Entity&*[]A for entity: Entity&*[]A contains one or more reserved characters.",
DisplayName = "Entity name containing reserved character ? fails config validation.")]
[DataRow(true, "Entity&*[]A", null, true, "The rest path: Entity&*[]A for entity: Entity&*[]A contains reserved characters that are not allowed in URL paths.",
DisplayName = "Entity name containing reserved character &*[] fails config validation.")]
[DataRow(false, "EntityA", "entityRestPath", true, DisplayName = "Rest path correctly configured as a non-empty string without any reserved characters.")]
[DataRow(false, "EntityA", "entityRest/?Path", false,
DisplayName = "Rest path for an entity containing reserved character but with rest disabled passes config validation.")]
[DataRow(false, "EntityA", "shopping-cart/item", true,
DisplayName = "Rest path with sub-directory passes config validation.")]
[DataRow(false, "EntityA", "api/v1/books", true,
DisplayName = "Rest path with multiple sub-directories passes config validation.")]
[DataRow(true, "EntityA", "entity\\path", true, "The rest path: entity\\path for entity: EntityA contains a backslash (\\). Use forward slash (/) for path separators.",
DisplayName = "Rest path with backslash fails config validation with helpful message.")]
[DataRow(false, "EntityA", "/entity/path", true,
DisplayName = "Rest path with leading slash is trimmed and passes config validation.")]
[DataRow(true, "EntityA", "entity//path", true, "The rest path: entity//path for entity: EntityA contains empty path segments. Ensure there are no leading, consecutive, or trailing slashes.",
DisplayName = "Rest path with consecutive slashes fails config validation.")]
public void ValidateRestPathForEntityInConfig(
bool exceptionExpected,
string entityName,
Expand Down
Loading
Loading