Skip to content

Commit 041a722

Browse files
CopilotJerryNixonanushakolan
authored
Fix Location header to respect X-Forwarded-Proto and X-Forwarded-Host headers (#3095)
## Why make this change? POST to stored procedure/entity endpoints returns Location header with `http://` scheme even when original request is HTTPS. This occurs behind reverse proxies (Azure API Management, Container Apps) where internal traffic uses HTTP but the `X-Forwarded-Proto: https` header is set. ## What is this change? Reuses existing `SqlPaginationUtil.ResolveRequestScheme` and `ResolveRequestHost` helpers (already used for pagination `nextLink`) in mutation handlers: - **SqlPaginationUtil.cs**: Changed `private` → `internal` for `ResolveRequestScheme`, `ResolveRequestHost`, `IsValidScheme`, `IsValidHost` - **SqlMutationEngine.cs**: Use forwarded headers for stored procedure Location header - **SqlResponseHelpers.cs**: Use forwarded headers for entity Location header Before: ``` POST https://my-app.azurecontainerapps.io/rest/SessionVote → Location: http://my-app.azurecontainerapps.io/rest/SessionVote ❌ ``` After: ``` POST https://my-app.azurecontainerapps.io/rest/SessionVote → Location: https://my-app.azurecontainerapps.io/rest/SessionVote ✓ ``` ## How was this tested? - [ ] Integration Tests - [ ] Unit Tests Existing validation logic from pagination already handles header validation (scheme must be `http`/`https`, host validated for dangerous characters). ## Sample Request(s) ```http POST /rest/MyStoredProcedure HTTP/1.1 Host: my-proxy.azure-api.net X-Forwarded-Proto: https X-Forwarded-Host: my-app.azurecontainerapps.io Content-Type: application/json {"param1": "value"} ``` Response now correctly includes: ``` HTTP/1.1 201 Created Location: https://my-app.azurecontainerapps.io/rest/MyStoredProcedure ``` <!-- START COPILOT ORIGINAL PROMPT --> <details> <summary>Original prompt</summary> ---- *This section details on the original issue you should resolve* <issue_title>[Bug]: POST to rest endpoint on https for stored proc returns a location header with insecure http scheme</issue_title> <issue_description>### What happened? A bug happened! Posting to a stored procedure endpoint returns a location header that doesn't match the request incoming scheme. This results in errors on clients that don't allow insecure redirects. The returned scheme should match the incoming scheme ``` POST https://<url>-api-stgwe.yellowfield-2df9f307.westeurope.azurecontainerapps.io/rest/SessionVote 201 259 ms POST /dataapi-stg/rest/SessionVote HTTP/1.1 Content-Type: application/json Authorization: Bearer User-Agent: PostmanRuntime/7.51.0 Accept: */* Cache-Control: no-cache Postman-Token: a2fadac6-067d-4a0b-8a5f-ba3b90d46ee8 Host: apim-sqlbits-api-management.azure-api.net Accept-Encoding: gzip, deflate, br Connection: keep-alive Content-Length: 65 Cookie: acaAffinity="6ea9edfe43f98424" {"SessionId":1232131, "Email":"[email protected]", "Vote":true } HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 Date: Mon, 22 Dec 2025 16:32:18 GMT Cache-Control: private Location: http://<url>-api-stgwe.yellowfield-2df9f307.westeurope.azurecontainerapps.io/rest/SessionVote Transfer-Encoding: chunked Strict-Transport-Security: max-age=31536000; includeSubDomains x-ms-middleware-request-id: 42421e73-1ba6-496f-8dd7-88beee5a0489 x-ms-correlation-id: 5f147408-1cb2-40e9-b406-7dcac4fae555 Request-Context: appId=cid-v1:2845ea60-a09f-4c5c-9500-47f887b00b3a {"value":[{"SessionId":123131},{"SessionId":1231231},{"SessionId":1232131}]} ``` ### Version 1.6.84 ### What database are you using? Azure SQL ### What hosting model are you using? Container Apps ### Which API approach are you accessing DAB through? REST ### Relevant log output ```Text ``` ### Code of Conduct - [x] I agree to follow this project's Code of Conduct</issue_description> <agent_instructions>Resolve this bug with as little code change as possible.</agent_instructions> ## Comments on the Issue (you are @copilot in this section) <comments> <comment_new><author>@JerryNixon</author><body> ## Root Cause The issue is in how DAB constructs the `Location` header for stored procedure POST requests. When building the location header URL, **DAB is not properly respecting the incoming request's HTTPS scheme when behind a reverse proxy**. ### The Problem Code Looking at `SqlMutationEngine.cs` for stored procedure handling: ```csharp name=src/Core/Resolvers/SqlMutationEngine.cs url=https://github.com/Azure/data-api-builder/blob/b11ab1a812d404ae8f176bc0cc4e483eac640133/src/Core/Resolvers/SqlMutationEngine.cs#L396-L404 HttpContext httpContext = GetHttpContext(); string locationHeaderURL = UriHelper.BuildAbsolute( scheme: httpContext.Request.Scheme, host: httpContext.Request.Host, pathBase: GetBaseRouteFromConfig(_runtimeConfigProvider.GetConfig()), path: httpContext.Request.Path); ``` The problem is that **`httpContext.Request.Scheme` returns the scheme of the connection between the reverse proxy (Azure API Management) and the container app (HTTP), not the original client request scheme (HTTPS)**. ### Why Regular Entity Inserts Work Better Interestingly, for regular table/view POST operations, DAB uses `SqlResponseHelpers.ConstructCreatedResultResponse()`, which has the same issue: ```csharp name=src/Core/Resolvers/SqlResponseHelpers.cs url=https://github.com/Azure/data-api-builder/blob/b11ab1a812d404ae8f176bc0cc4e483eac640133/src/Core/Resolvers/SqlResponseHelpers.cs#L380-L391 locationHeaderURL = UriHelper.BuildAbsolute( scheme: httpContext.Request.Scheme, host: httpContext.Request.Host, pathBase: baseRoute, path: httpContext.Request.Path); ``` ### The Solution That Exists for Pagination DAB **already has the correct implementation** for handling forwarded headers in the pagination utility (`SqlPaginationUtil.cs`): ```csharp name=src/Core/Resolvers/SqlPaginationUtil.cs url=https://github.com/Azure/data-api-builder/blob/b11ab1a812d404ae8f176bc0cc4e483eac640133/src/Core/Resolvers/SqlPaginationUtil.cs#L590-L603 public static string ConstructBaseUriForPagination(HttpContext httpContext, string? baseRoute = null) { HttpRequest req = httpContext.Request; // use scheme from X-Forwarded-Proto or fallback to request scheme string scheme = ResolveRequestScheme(req); // Use host from X-Forwarded-Host or fallback to request host string host = ResolveRequestHost(req); return UriHelper.BuildAbsolute( scheme: scheme, host: new HostString(host), ... ``` The helper methods check for `X-Forwarded-Proto` and `X-Forwarded-Host` headers: ```csharp name=src/Core/Resolvers/SqlPaginationUtil.cs url=https://github.com/Azure/data-api-builder/blob/b11ab1a812d404ae8f176bc0cc4e483eac640133/src/Cor... </details> <!-- START COPILOT CODING AGENT SUFFIX --> - Fixes #3032 <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs. --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: JerryNixon <[email protected]> Co-authored-by: Anusha Kolan <[email protected]>
1 parent e128eb7 commit 041a722

4 files changed

Lines changed: 145 additions & 9 deletions

File tree

src/Core/Resolvers/SqlMutationEngine.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -397,9 +397,12 @@ await queryExecutor.ExecuteQueryAsync(
397397
case EntityActionOperation.Insert:
398398

399399
HttpContext httpContext = GetHttpContext();
400+
// Use scheme/host from X-Forwarded-* headers if present, else fallback to request values
401+
string scheme = SqlPaginationUtil.ResolveRequestScheme(httpContext.Request);
402+
string host = SqlPaginationUtil.ResolveRequestHost(httpContext.Request);
400403
string locationHeaderURL = UriHelper.BuildAbsolute(
401-
scheme: httpContext.Request.Scheme,
402-
host: httpContext.Request.Host,
404+
scheme: scheme,
405+
host: new HostString(host),
403406
pathBase: GetBaseRouteFromConfig(_runtimeConfigProvider.GetConfig()),
404407
path: httpContext.Request.Path);
405408

src/Core/Resolvers/SqlPaginationUtil.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -751,12 +751,12 @@ public static string FormatQueryString(NameValueCollection? queryStringParameter
751751
}
752752

753753
/// <summary>
754-
/// Extracts and request scheme from "X-Forwarded-Proto" or falls back to the request scheme.
754+
/// Extracts the request scheme from "X-Forwarded-Proto" or falls back to the request scheme.
755+
/// Invalid forwarded values are ignored.
755756
/// </summary>
756757
/// <param name="req">The HTTP request.</param>
757758
/// <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)
759+
internal static string ResolveRequestScheme(HttpRequest req)
760760
{
761761
string? rawScheme = req.Headers["X-Forwarded-Proto"].FirstOrDefault();
762762
string? normalized = rawScheme?.Trim().ToLowerInvariant();
@@ -776,11 +776,11 @@ private static string ResolveRequestScheme(HttpRequest req)
776776

777777
/// <summary>
778778
/// Extracts the request host from "X-Forwarded-Host" or falls back to the request host.
779+
/// Invalid forwarded values are ignored.
779780
/// </summary>
780781
/// <param name="req">The HTTP request.</param>
781782
/// <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)
783+
internal static string ResolveRequestHost(HttpRequest req)
784784
{
785785
string? rawHost = req.Headers["X-Forwarded-Host"].FirstOrDefault();
786786
string? trimmed = rawHost?.Trim();

src/Core/Resolvers/SqlResponseHelpers.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -381,9 +381,12 @@ HttpContext httpContext
381381
// The third part is the computed primary key route.
382382
if (operationType is EntityActionOperation.Insert && !string.IsNullOrEmpty(primaryKeyRoute))
383383
{
384+
// Use scheme/host from X-Forwarded-* headers if present, else fallback to request values
385+
string scheme = SqlPaginationUtil.ResolveRequestScheme(httpContext.Request);
386+
string host = SqlPaginationUtil.ResolveRequestHost(httpContext.Request);
384387
locationHeaderURL = UriHelper.BuildAbsolute(
385-
scheme: httpContext.Request.Scheme,
386-
host: httpContext.Request.Host,
388+
scheme: scheme,
389+
host: new HostString(host),
387390
pathBase: baseRoute,
388391
path: httpContext.Request.Path);
389392

src/Service.Tests/Configuration/ConfigurationTests.cs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@
4040
using Azure.DataApiBuilder.Service.Tests.SqlTests;
4141
using HotChocolate;
4242
using Microsoft.AspNetCore.Authorization;
43+
using Microsoft.AspNetCore.Hosting;
44+
using Microsoft.AspNetCore.Hosting.Server.Features;
4345
using Microsoft.AspNetCore.TestHost;
46+
using Microsoft.Data.SqlClient;
4447
using Microsoft.Extensions.DependencyInjection;
4548
using Microsoft.Extensions.Logging;
4649
using Microsoft.IdentityModel.Tokens;
@@ -3448,6 +3451,133 @@ public async Task ValidateLocationHeaderWhenBaseRouteIsConfigured(
34483451
}
34493452
}
34503453

3454+
/// <summary>
3455+
/// Validates that the Location header returned for POST requests respects X-Forwarded-Host and X-Forwarded-Proto.
3456+
/// This covers both table and stored procedure insert endpoints.
3457+
/// </summary>
3458+
/// <param name="entityType">Type of entity under test.</param>
3459+
/// <param name="requestPath">REST endpoint path for POST request.</param>
3460+
/// <param name="forwardedHost">Value for X-Forwarded-Host header.</param>
3461+
/// <param name="forwardedProto">Value for X-Forwarded-Proto header.</param>
3462+
/// <param name="expectedScheme">Expected scheme in Location header.</param>
3463+
[DataTestMethod]
3464+
[TestCategory(TestCategory.MSSQL)]
3465+
[DataRow(EntitySourceType.Table, "/api/Book", null, null, "http", DisplayName = "Location header uses local http scheme when no forwarded headers are present for table POST")]
3466+
[DataRow(EntitySourceType.StoredProcedure, "/api/GetBooks", null, null, "http", DisplayName = "Location header uses local http scheme when no forwarded headers are present for stored procedure POST")]
3467+
[DataRow(EntitySourceType.Table, "/api/Book", "api.contoso.com", "http", "http", DisplayName = "Location header uses forwarded http scheme for table POST")]
3468+
[DataRow(EntitySourceType.StoredProcedure, "/api/GetBooks", "api.contoso.com", "http", "http", DisplayName = "Location header uses forwarded http scheme for stored procedure POST")]
3469+
[DataRow(EntitySourceType.Table, "/api/Book", "api.contoso.com", "https", "https", DisplayName = "Location header uses forwarded https scheme/host for table POST")]
3470+
[DataRow(EntitySourceType.StoredProcedure, "/api/GetBooks", "api.contoso.com", "https", "https", DisplayName = "Location header uses forwarded https scheme/host for stored procedure POST")]
3471+
public async Task ValidateLocationHeaderRespectsXForwardedHostAndProto(
3472+
EntitySourceType entityType,
3473+
string requestPath,
3474+
string forwardedHost,
3475+
string forwardedProto,
3476+
string expectedScheme)
3477+
{
3478+
TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT);
3479+
3480+
GraphQLRuntimeOptions graphqlOptions = new(Enabled: false);
3481+
RestRuntimeOptions restRuntimeOptions = new(Enabled: true);
3482+
McpRuntimeOptions mcpRuntimeOptions = new(Enabled: false);
3483+
3484+
SqlConnectionStringBuilder connectionStringBuilder = new(GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL))
3485+
{
3486+
TrustServerCertificate = true
3487+
};
3488+
3489+
DataSource dataSource = new(DatabaseType.MSSQL,
3490+
connectionStringBuilder.ConnectionString, Options: null);
3491+
3492+
RuntimeConfig configuration;
3493+
if (entityType is EntitySourceType.StoredProcedure)
3494+
{
3495+
Entity entity = new(Source: new("get_books", EntitySourceType.StoredProcedure, null, null),
3496+
Fields: null,
3497+
Rest: new(new SupportedHttpVerb[] { SupportedHttpVerb.Get, SupportedHttpVerb.Post }),
3498+
GraphQL: null,
3499+
Permissions: new[] { GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) },
3500+
Relationships: null,
3501+
Mappings: null
3502+
);
3503+
3504+
configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions, entity, entityName: "GetBooks");
3505+
}
3506+
else
3507+
{
3508+
configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions);
3509+
}
3510+
3511+
const string CUSTOM_CONFIG = "custom-config.json";
3512+
File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson());
3513+
string[] args = new[] { $"--ConfigFileName={CUSTOM_CONFIG}" };
3514+
3515+
// Intentionally bind HTTP to simulate the proxy-to-app internal hop.
3516+
using IWebHost host = Program.CreateWebHostBuilder(args)
3517+
.UseUrls("http://127.0.0.1:0")
3518+
.Build();
3519+
await host.StartAsync();
3520+
3521+
IServerAddressesFeature addresses = host.ServerFeatures.Get<IServerAddressesFeature>();
3522+
Assert.IsNotNull(addresses);
3523+
3524+
string baseAddress = addresses.Addresses.FirstOrDefault();
3525+
Assert.IsFalse(string.IsNullOrEmpty(baseAddress));
3526+
3527+
using HttpClient client = new()
3528+
{
3529+
BaseAddress = new Uri(baseAddress)
3530+
};
3531+
3532+
HttpRequestMessage request = new(HttpMethod.Post, requestPath);
3533+
if (!string.IsNullOrEmpty(forwardedHost))
3534+
{
3535+
request.Headers.Add("X-Forwarded-Host", forwardedHost);
3536+
}
3537+
3538+
if (!string.IsNullOrEmpty(forwardedProto))
3539+
{
3540+
request.Headers.Add("X-Forwarded-Proto", forwardedProto);
3541+
}
3542+
3543+
if (entityType is EntitySourceType.Table)
3544+
{
3545+
JsonElement requestBodyElement = JsonDocument.Parse(@"{
3546+
""title"": ""Forwarded Header Location Test"",
3547+
""publisher_id"": 1234
3548+
}").RootElement.Clone();
3549+
3550+
request.Content = JsonContent.Create(requestBodyElement);
3551+
}
3552+
3553+
HttpResponseMessage response = await client.SendAsync(request);
3554+
3555+
Assert.AreEqual(HttpStatusCode.Created, response.StatusCode);
3556+
Assert.IsNotNull(response.Headers.Location, "Location header should be present for successful POST create.");
3557+
3558+
Uri location = response.Headers.Location;
3559+
Assert.AreEqual(expectedScheme, location.Scheme, $"Expected Location scheme '{expectedScheme}', got '{location.Scheme}'.");
3560+
3561+
if (!string.IsNullOrEmpty(forwardedHost))
3562+
{
3563+
Assert.AreEqual(forwardedHost, location.Host, $"Expected Location host '{forwardedHost}', got '{location.Host}'.");
3564+
}
3565+
3566+
// Since forwarded host is external, validate follow-up using local path only.
3567+
string localPathAndQuery = string.IsNullOrEmpty(location.Query) ? location.AbsolutePath : location.AbsolutePath + location.Query;
3568+
HttpRequestMessage followUpRequest = new(HttpMethod.Get, localPathAndQuery);
3569+
HttpResponseMessage followUpResponse = await client.SendAsync(followUpRequest);
3570+
Assert.AreEqual(HttpStatusCode.OK, followUpResponse.StatusCode);
3571+
3572+
if (entityType is EntitySourceType.Table)
3573+
{
3574+
HttpRequestMessage cleanupRequest = new(HttpMethod.Delete, localPathAndQuery);
3575+
await client.SendAsync(cleanupRequest);
3576+
}
3577+
3578+
await host.StopAsync();
3579+
}
3580+
34513581
/// <summary>
34523582
/// Test to validate that when the property rest.request-body-strict is absent from the rest runtime section in config file, DAB runs in strict mode.
34533583
/// In strict mode, presence of extra fields in the request body is not permitted and leads to HTTP 400 - BadRequest error.

0 commit comments

Comments
 (0)