|
40 | 40 | using Azure.DataApiBuilder.Service.Tests.SqlTests; |
41 | 41 | using HotChocolate; |
42 | 42 | using Microsoft.AspNetCore.Authorization; |
| 43 | +using Microsoft.AspNetCore.Hosting; |
| 44 | +using Microsoft.AspNetCore.Hosting.Server.Features; |
43 | 45 | using Microsoft.AspNetCore.TestHost; |
| 46 | +using Microsoft.Data.SqlClient; |
44 | 47 | using Microsoft.Extensions.DependencyInjection; |
45 | 48 | using Microsoft.Extensions.Logging; |
46 | 49 | using Microsoft.IdentityModel.Tokens; |
@@ -3448,6 +3451,133 @@ public async Task ValidateLocationHeaderWhenBaseRouteIsConfigured( |
3448 | 3451 | } |
3449 | 3452 | } |
3450 | 3453 |
|
| 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 | + |
3451 | 3581 | /// <summary> |
3452 | 3582 | /// 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. |
3453 | 3583 | /// In strict mode, presence of extra fields in the request body is not permitted and leads to HTTP 400 - BadRequest error. |
|
0 commit comments