Skip to content

Commit 2885360

Browse files
Support for relative nextLink using next-link-relative property for Reverse Proxy Scenarios (#2788)
## Why make this change? - Closes #2677 When Data API builder (DAB) is deployed behind a reverse proxy that handles SSL termination (converting https to http), the pagination nextLink URL in REST responses was generated with the http scheme instead of https. This resulted in broken next page links for clients accessing the service securely. The reported bug highlighted that the nextLink should use the original client-facing scheme and host, or provide a relative/schema-less link, or leverage the x-forwarded-proto and x-forwarded-host headers set by the reverse proxy. ## What is this change? - Users can also optionally set `x-forwarded-proto` and `x-forwarded-host headers` as needed to match their deployment environment. If not set, it will pick the default path from the request context. - Also refer [X-Forwarded-Proto](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto) and [X-Forwarded-Host](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-Host). ## How was this tested? - [ ] Integration Tests - [x] Unit Tests ## Sample Request(s) - GET `http://localhost:5000/api/<your_entity>`
1 parent 8ea24db commit 2885360

File tree

7 files changed

+366
-58
lines changed

7 files changed

+366
-58
lines changed

schemas/dab.draft.schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,11 @@
168168
"description": "Sets the default number of records returned in a single response. When this limit is reached, a continuation token is provided to retrieve the next page. If set to null, the default value is 100.",
169169
"default": 100,
170170
"minimum": 1
171+
},
172+
"next-link-relative": {
173+
"type": "boolean",
174+
"default": false,
175+
"description": "When true, nextLink in paginated results will use a relative URL."
171176
}
172177
}
173178
},

src/Config/ObjectModel/PaginationOptions.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,14 @@ public record PaginationOptions
3737
[JsonPropertyName("max-page-size")]
3838
public int? MaxPageSize { get; init; } = null;
3939

40+
/// <summary>
41+
/// When true, nextLink in paginated responses will be relative (default: false).
42+
/// </summary>
43+
[JsonPropertyName("next-link-relative")]
44+
public bool? NextLinkRelative { get; init; } = false;
45+
4046
[JsonConstructor]
41-
public PaginationOptions(int? DefaultPageSize = null, int? MaxPageSize = null)
47+
public PaginationOptions(int? DefaultPageSize = null, int? MaxPageSize = null, bool? NextLinkRelative = null)
4248
{
4349
if (MaxPageSize is not null)
4450
{
@@ -69,6 +75,8 @@ public PaginationOptions(int? DefaultPageSize = null, int? MaxPageSize = null)
6975
statusCode: HttpStatusCode.ServiceUnavailable,
7076
subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError);
7177
}
78+
79+
this.NextLinkRelative = NextLinkRelative ?? false;
7280
}
7381

7482
/// <summary>

src/Config/ObjectModel/RuntimeConfig.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,11 @@ public uint MaxPageSize()
592592
return (uint?)Runtime?.Pagination?.MaxPageSize ?? PaginationOptions.MAX_PAGE_SIZE;
593593
}
594594

595+
public bool NextLinkRelative()
596+
{
597+
return Runtime?.Pagination?.NextLinkRelative ?? false;
598+
}
599+
595600
public int MaxResponseSizeMB()
596601
{
597602
return Runtime?.Host?.MaxResponseSizeMB ?? HostOptions.MAX_RESPONSE_LENGTH_DAB_ENGINE_MB;

src/Core/Resolvers/SqlPaginationUtil.cs

Lines changed: 166 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212
using Azure.DataApiBuilder.Core.Services;
1313
using Azure.DataApiBuilder.Service.Exceptions;
1414
using Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes;
15-
using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries;
15+
using Microsoft.AspNetCore.Http;
16+
using Microsoft.AspNetCore.Http.Extensions;
1617
using Microsoft.AspNetCore.WebUtilities;
18+
using QueryBuilder = Azure.DataApiBuilder.Service.GraphQLBuilder.Queries.QueryBuilder;
1719

1820
namespace Azure.DataApiBuilder.Core.Resolvers
1921
{
@@ -572,49 +574,101 @@ public static string Base64Decode(string base64EncodedData)
572574
}
573575

574576
/// <summary>
575-
/// Create the URL that will provide for the next page of results
576-
/// using the same query options.
577-
/// Return value formatted as a JSON array: [{"nextLink":"[base]/api/[entity]?[queryParams_URIescaped]$after=[base64encodedPaginationToken]"}]
577+
/// Constructs the base Uri for Pagination
578578
/// </summary>
579-
/// <param name="path">The request path excluding query parameters (e.g. https://localhost/api/myEntity)</param>
580-
/// <param name="queryStringParameters">Collection of query string parameters that are URI escaped.</param>
581-
/// <param name="newAfterPayload">The contents to add to the $after query parameter. Should be base64 encoded pagination token.</param>
582-
/// <returns>JSON element - array with nextLink.</returns>
583-
public static JsonElement CreateNextLink(string path, NameValueCollection? queryStringParameters, string newAfterPayload)
579+
/// <remarks>
580+
/// This method uses the "X-Forwarded-Proto" and "X-Forwarded-Host" headers to determine
581+
/// the scheme and host of the request, falling back to the request's original scheme and host if the headers
582+
/// are not present or invalid. The method ensures that the scheme is either "http" or "https" and that the host
583+
/// is a valid hostname or IP address.
584+
/// </remarks>
585+
/// <param name="httpContext">The HTTP context containing the request information.</param>
586+
/// <param name="baseRoute">An optional base route to prepend to the request path. If not specified, no base route is used.</param>
587+
/// <returns>A string representing the fully constructed Base request URL for Pagination.</returns>
588+
public static string ConstructBaseUriForPagination(HttpContext httpContext, string? baseRoute = null)
589+
{
590+
HttpRequest req = httpContext.Request;
591+
592+
// use scheme from X-Forwarded-Proto or fallback to request scheme
593+
string scheme = ResolveRequestScheme(req);
594+
595+
// Use host from X-Forwarded-Host or fallback to request host
596+
string host = ResolveRequestHost(req);
597+
598+
// If the base route is not empty, we need to insert it into the URI before the rest path.
599+
// Path is of the form ....restPath/pathNameForEntity. We want to insert the base route before the restPath.
600+
// Finally, it will be of the form: .../baseRoute/restPath/pathNameForEntity.
601+
return UriHelper.BuildAbsolute(
602+
scheme: scheme,
603+
host: new HostString(host),
604+
pathBase: string.IsNullOrWhiteSpace(baseRoute) ? PathString.Empty : new PathString(baseRoute),
605+
path: req.Path);
606+
}
607+
608+
/// <summary>
609+
/// Builds a query string by appending or replacing the <c>$after</c> token with the specified value.
610+
/// </summary>
611+
/// <remarks>This method does not include the <paramref name="path"/> in the returned query
612+
/// string. It only processes and formats the query string parameters.</remarks>
613+
/// <param name="queryStringParameters">A collection of existing query string parameters. If <see langword="null"/>, an empty collection is used.
614+
/// The <c>$after</c> parameter, if present, will be removed before appending the new token.</param>
615+
/// <param name="newAfterPayload">The new value for the <c>$after</c> token. If this value is <see langword="null"/>, empty, or whitespace, no
616+
/// <c>$after</c> token will be appended.</param>
617+
/// <returns>A URL-encoded query string containing the updated parameters, including the new <c>$after</c> token if
618+
/// specified. If no parameters are provided and <paramref name="newAfterPayload"/> is empty, an empty string is
619+
/// returned.</returns>
620+
public static string BuildQueryStringWithAfterToken(NameValueCollection? queryStringParameters, string newAfterPayload)
584621
{
585622
if (queryStringParameters is null)
586623
{
587624
queryStringParameters = new();
588625
}
589626
else
590627
{
591-
// Purge old $after value so this function can replace it.
592628
queryStringParameters.Remove("$after");
593629
}
594630

595-
// To prevent regression of current behavior, retain the call to FormatQueryString
596-
// which URI escapes other query parameters. Since $after has been removed,
597-
// this will not affect the base64 encoded paging token.
598-
string queryString = FormatQueryString(queryStringParameters: queryStringParameters);
631+
// Format existing query string (URL encoded)
632+
string queryString = FormatQueryString(queryStringParameters);
599633

600-
// When a new $after payload is provided, append it to the query string with the
601-
// appropriate prefix: ? if $after is the only query parameter. & if $after is one of many query parameters.
634+
// Append new $after token
602635
if (!string.IsNullOrWhiteSpace(newAfterPayload))
603636
{
604637
string afterPrefix = string.IsNullOrWhiteSpace(queryString) ? "?" : "&";
605638
queryString += $"{afterPrefix}{RequestParser.AFTER_URL}={newAfterPayload}";
606639
}
607640

608-
// ValueKind will be array so we can differentiate from other objects in the response
609-
// to be returned.
610-
// [{"nextLink":"[base]/api/[entity]?[queryParams_URIescaped]$after=[base64encodedPaginationToken]"}]
641+
// Construct final link
642+
// return $"{path}{queryString}";
643+
return queryString;
644+
}
645+
646+
/// <summary>
647+
/// Gets a consolidated next link for pagination in JSON format.
648+
/// </summary>
649+
/// <param name="baseUri">The base Pagination Uri</param>
650+
/// <param name="queryString">The query string with after value</param>
651+
/// <param name="isNextLinkRelative">True, if the next link should be relative</param>
652+
/// <returns></returns>
653+
public static JsonElement GetConsolidatedNextLinkForPagination(string baseUri, string queryString, bool isNextLinkRelative = false)
654+
{
655+
UriBuilder uriBuilder = new(baseUri)
656+
{
657+
// Form final link by appending the query string
658+
Query = queryString
659+
};
660+
661+
// Construct final link- absolute or relative
662+
string nextLinkValue = isNextLinkRelative
663+
? uriBuilder.Uri.PathAndQuery // returns just "/api/<Entity>?$after...", no host
664+
: uriBuilder.Uri.AbsoluteUri; // returns full URL
665+
666+
// Return serialized JSON object
611667
string jsonString = JsonSerializer.Serialize(new[]
612668
{
613-
new
614-
{
615-
nextLink = @$"{path}{queryString}"
616-
}
669+
new { nextLink = nextLinkValue }
617670
});
671+
618672
return JsonSerializer.Deserialize<JsonElement>(jsonString);
619673
}
620674

@@ -695,5 +749,94 @@ public static string FormatQueryString(NameValueCollection? queryStringParameter
695749

696750
return queryString;
697751
}
752+
753+
/// <summary>
754+
/// Extracts and request scheme from "X-Forwarded-Proto" or falls back to the request scheme.
755+
/// </summary>
756+
/// <param name="req">The HTTP request.</param>
757+
/// <returns>The scheme string ("http" or "https").</returns>
758+
/// <exception cref="DataApiBuilderException">Thrown when client explicitly sets an invalid scheme.</exception>
759+
private static string ResolveRequestScheme(HttpRequest req)
760+
{
761+
string? rawScheme = req.Headers["X-Forwarded-Proto"].FirstOrDefault();
762+
string? normalized = rawScheme?.Trim().ToLowerInvariant();
763+
764+
bool isExplicit = !string.IsNullOrEmpty(rawScheme);
765+
bool isValid = IsValidScheme(normalized);
766+
767+
if (isExplicit && !isValid)
768+
{
769+
// Log a warning and ignore the invalid value, fallback to request's scheme
770+
Console.WriteLine($"Warning: Invalid scheme '{rawScheme}' in X-Forwarded-Proto header. Falling back to request scheme: '{req.Scheme}'.");
771+
return req.Scheme;
772+
}
773+
774+
return isValid ? normalized! : req.Scheme;
775+
}
776+
777+
/// <summary>
778+
/// Extracts the request host from "X-Forwarded-Host" or falls back to the request host.
779+
/// </summary>
780+
/// <param name="req">The HTTP request.</param>
781+
/// <returns>The host string.</returns>
782+
/// <exception cref="DataApiBuilderException">Thrown when client explicitly sets an invalid host.</exception>
783+
private static string ResolveRequestHost(HttpRequest req)
784+
{
785+
string? rawHost = req.Headers["X-Forwarded-Host"].FirstOrDefault();
786+
string? trimmed = rawHost?.Trim();
787+
788+
bool isExplicit = !string.IsNullOrEmpty(rawHost);
789+
bool isValid = IsValidHost(trimmed);
790+
791+
if (isExplicit && !isValid)
792+
{
793+
// Log a warning and ignore the invalid value, fallback to request's host
794+
Console.WriteLine($"Warning: Invalid host '{rawHost}' in X-Forwarded-Host header. Falling back to request host: '{req.Host}'.");
795+
return req.Host.ToString();
796+
}
797+
798+
return isValid ? trimmed! : req.Host.ToString();
799+
}
800+
801+
/// <summary>
802+
/// Checks if the provided scheme is valid.
803+
/// </summary>
804+
/// <param name="scheme">Scheme, e.g., "http" or "https".</param>
805+
/// <returns>True if valid, otherwise false.</returns>
806+
private static bool IsValidScheme(string? scheme)
807+
{
808+
return scheme is "http" or "https";
809+
}
810+
811+
/// <summary>
812+
/// Checks if the provided host is a valid hostname or IP address.
813+
/// </summary>
814+
/// <param name="host">The host name (with optional port).</param>
815+
/// <returns>True if valid, otherwise false.</returns>
816+
private static bool IsValidHost(string? host)
817+
{
818+
if (string.IsNullOrWhiteSpace(host))
819+
{
820+
return false;
821+
}
822+
823+
// Reject dangerous characters
824+
if (host.Contains('\r') || host.Contains('\n') || host.Contains(' ') ||
825+
host.Contains('<') || host.Contains('>') || host.Contains('@'))
826+
{
827+
return false;
828+
}
829+
830+
// Validate host part (exclude port if present)
831+
string hostnamePart = host.Split(':')[0];
832+
833+
if (Uri.CheckHostName(hostnamePart) == UriHostNameType.Unknown)
834+
{
835+
return false;
836+
}
837+
838+
// Final sanity check: ensure it parses into a full URI
839+
return Uri.TryCreate($"http://{host}", UriKind.Absolute, out _);
840+
}
698841
}
699842
}

src/Core/Resolvers/SqlResponseHelpers.cs

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -89,29 +89,18 @@ public static OkObjectResult FormatFindResult(
8989
tableName: context.DatabaseObject.Name,
9090
sqlMetadataProvider: sqlMetadataProvider);
9191

92-
// nextLink is the URL needed to get the next page of records using the same query options
93-
// with $after base64 encoded for opaqueness
94-
string path = UriHelper.GetEncodedUrl(httpContext!.Request).Split('?')[0];
92+
string basePaginationUri = SqlPaginationUtil.ConstructBaseUriForPagination(httpContext, runtimeConfig.Runtime?.BaseRoute);
9593

96-
// If the base route is not empty, we need to insert it into the URI before the rest path.
97-
string? baseRoute = runtimeConfig.Runtime?.BaseRoute;
98-
if (!string.IsNullOrWhiteSpace(baseRoute))
99-
{
100-
HttpRequest request = httpContext!.Request;
101-
102-
// Path is of the form ....restPath/pathNameForEntity. We want to insert the base route before the restPath.
103-
// Finally, it will be of the form: .../baseRoute/restPath/pathNameForEntity.
104-
path = UriHelper.BuildAbsolute(
105-
scheme: request.Scheme,
106-
host: request.Host,
107-
pathBase: baseRoute,
108-
path: request.Path);
109-
}
94+
// Build the query string with the $after token.
95+
string queryString = SqlPaginationUtil.BuildQueryStringWithAfterToken(
96+
queryStringParameters: context!.ParsedQueryString,
97+
newAfterPayload: after);
11098

111-
JsonElement nextLink = SqlPaginationUtil.CreateNextLink(
112-
path,
113-
queryStringParameters: context!.ParsedQueryString,
114-
after);
99+
// Get the final consolidated nextLink for the pagination.
100+
JsonElement nextLink = SqlPaginationUtil.GetConsolidatedNextLinkForPagination(
101+
baseUri: basePaginationUri,
102+
queryString: queryString,
103+
isNextLinkRelative: runtimeConfig.NextLinkRelative());
115104

116105
// When there are extra fields present, they are removed before returning the response.
117106
if (extraFieldsInResponse.Count > 0)
@@ -424,6 +413,5 @@ public static OkObjectResult OkMutationResponse(JsonElement jsonResult)
424413
value = resultEnumerated
425414
});
426415
}
427-
428416
}
429417
}

0 commit comments

Comments
 (0)