diff --git a/src/main/java/io/github/hectorvent/floci/core/common/RegionResolver.java b/src/main/java/io/github/hectorvent/floci/core/common/RegionResolver.java index 49952f25ad..b051d9d58d 100644 --- a/src/main/java/io/github/hectorvent/floci/core/common/RegionResolver.java +++ b/src/main/java/io/github/hectorvent/floci/core/common/RegionResolver.java @@ -49,6 +49,17 @@ public String resolveRegionFromAuth(String authorizationHeader) { return matcher.find() ? matcher.group(1) : defaultRegion; } + /** + * True when the Authorization header is missing/blank, or present but not a SigV4 + * "Credential=.../region/..." value (e.g. a bearer JWT) - in both cases resolveRegion + * silently returned defaultRegion rather than a region the caller actually specified, + * so callers should treat the resolved region as a guess and fall back on lookup miss. + */ + public boolean isRegionUnresolved(HttpHeaders headers) { + String auth = headers == null ? null : headers.getHeaderString("Authorization"); + return auth == null || auth.isEmpty() || !CREDENTIAL_REGION_PATTERN.matcher(auth).find(); + } + /** * Resolves the region from an X-Amz-Credential value found in * presigned URL query parameters. diff --git a/src/main/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayExecuteController.java b/src/main/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayExecuteController.java index 4949f70649..4804ada907 100644 --- a/src/main/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayExecuteController.java +++ b/src/main/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayExecuteController.java @@ -232,23 +232,27 @@ public Response handlePatch(@Context HttpHeaders headers, @Context UriInfo uriIn Response dispatch(String httpMethod, String apiId, String stageName, String proxy, HttpHeaders headers, UriInfo uriInfo, byte[] body) { String region = regionResolver.resolveRegion(headers); + // True for SigV4-unsigned requests, and also for requests whose Authorization header + // isn't a SigV4 credential at all (e.g. a Cognito bearer JWT) - resolveRegion silently + // fell back to defaultRegion in both cases, so the resolved region is a guess. + boolean regionUnresolved = regionResolver.isRegionUnresolved(headers); // Check if this is a v2 (HTTP API) or v1 (REST API) boolean isV2 = false; + String v2Region = regionUnresolved ? apiGatewayV2Service.resolveHttpApiRegion(region, apiId) : region; try { - apiGatewayV2Service.getApi(region, apiId); + apiGatewayV2Service.getApi(v2Region, apiId); isV2 = true; } catch (AwsException ignored) { // Not a v2 API — fall through to v1 handling } if (isV2) { - return dispatchV2(httpMethod, apiId, stageName, proxy, headers, uriInfo, body, region); + return dispatchV2(httpMethod, apiId, stageName, proxy, headers, uriInfo, body, v2Region); } - // Resolve region for unsigned data-plane requests - String auth = headers.getHeaderString("Authorization"); - if (auth == null || auth.isBlank()) { + // Resolve region for requests whose Authorization header didn't resolve one + if (regionUnresolved) { region = apiGatewayService.resolveRestApiRegion(region, apiId); } diff --git a/src/main/java/io/github/hectorvent/floci/services/apigatewayv2/ApiGatewayV2Service.java b/src/main/java/io/github/hectorvent/floci/services/apigatewayv2/ApiGatewayV2Service.java index fcc2bdb0dd..49a3550bd9 100644 --- a/src/main/java/io/github/hectorvent/floci/services/apigatewayv2/ApiGatewayV2Service.java +++ b/src/main/java/io/github/hectorvent/floci/services/apigatewayv2/ApiGatewayV2Service.java @@ -126,6 +126,23 @@ public Api getApi(String region, String apiId) { .orElseThrow(() -> new AwsException("NotFoundException", "Invalid API id specified", 404)); } + /** + * Mirrors ApiGatewayService#resolveRestApiRegion: unsigned data-plane requests carry no + * region, so preferredRegion is whatever RegionResolver defaults to, which need not match + * where the API was actually created. Falls back to scanning stored keys for the apiId. + */ + public String resolveHttpApiRegion(String preferredRegion, String apiId) { + if (apiStore.get(apiKey(preferredRegion, apiId)).isPresent()) { + return preferredRegion; + } + + return apiStore.keys().stream() + .filter(k -> k.endsWith("::" + apiId)) + .map(k -> k.substring(0, k.indexOf("::"))) + .findFirst() + .orElse(preferredRegion); + } + public List getApis(String region) { String prefix = region + "::"; return apiStore.scan(k -> k.startsWith(prefix)); diff --git a/src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayExecuteControllerTest.java b/src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayExecuteControllerTest.java index 6bf58f1686..0528449114 100644 --- a/src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayExecuteControllerTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/apigateway/ApiGatewayExecuteControllerTest.java @@ -2,10 +2,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import io.github.hectorvent.floci.core.common.AwsException; import io.github.hectorvent.floci.core.common.RegionResolver; +import io.github.hectorvent.floci.services.apigatewayv2.ApiGatewayV2Service; +import io.github.hectorvent.floci.services.apigatewayv2.model.Api; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MultivaluedHashMap; import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; import org.junit.jupiter.api.Test; import java.util.List; @@ -13,7 +17,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** Unit coverage for API Gateway request-event construction and HTTP API v2 route matching. */ @@ -109,4 +116,96 @@ void duplicateRequestHeaderUsesLastSingleValueAndPreservesAllMultiValues() { objectMapper.valueToTree(List.of("first", "second", "third")), event.path("multiValueHeaders").path("X-Dup")); } + + // ── HTTP API (v2) region resolution for unsigned / non-SigV4 requests ────── + + @Test + void unsignedRequestFindsV2ApiDeployedOutsideDefaultRegion() { + RegionResolver regionResolver = mock(RegionResolver.class); + ApiGatewayV2Service apiGatewayV2Service = mock(ApiGatewayV2Service.class); + ApiGatewayService apiGatewayService = mock(ApiGatewayService.class); + HttpHeaders headers = mock(HttpHeaders.class); + + when(regionResolver.resolveRegion(headers)).thenReturn("us-east-1"); + when(regionResolver.isRegionUnresolved(headers)).thenReturn(true); + // The API was actually created in eu-west-1; the default-region lookup must miss... + when(apiGatewayV2Service.getApi("us-east-1", "abc123")).thenThrow( + new AwsException("NotFoundException", "Invalid API id specified", 404)); + // ...so resolveHttpApiRegion is consulted and finds the real region. + when(apiGatewayV2Service.resolveHttpApiRegion("us-east-1", "abc123")).thenReturn("eu-west-1"); + when(apiGatewayV2Service.getApi("eu-west-1", "abc123")).thenReturn(new Api()); + // No route configured — dispatchV2 returns 404, but that's downstream of the region fix; + // what this test asserts is which region the API/route lookups actually ran against. + when(apiGatewayV2Service.findMatchingRoute("eu-west-1", "abc123", "GET", "/hello")) + .thenReturn(null); + + ApiGatewayExecuteController controller = new ApiGatewayExecuteController( + apiGatewayService, apiGatewayV2Service, null, + regionResolver, new ObjectMapper(), null, + null, null, null, null); + + Response response = controller.dispatch("GET", "abc123", "prod", "hello", headers, null, null); + + assertEquals(404, response.getStatus()); + verify(apiGatewayV2Service).findMatchingRoute("eu-west-1", "abc123", "GET", "/hello"); + verify(apiGatewayService, never()).resolveRestApiRegion(anyString(), anyString()); + } + + @Test + void signedV2RequestDoesNotConsultRegionFallback() { + RegionResolver regionResolver = mock(RegionResolver.class); + ApiGatewayV2Service apiGatewayV2Service = mock(ApiGatewayV2Service.class); + ApiGatewayService apiGatewayService = mock(ApiGatewayService.class); + HttpHeaders headers = mock(HttpHeaders.class); + + when(regionResolver.resolveRegion(headers)).thenReturn("us-east-1"); + when(regionResolver.isRegionUnresolved(headers)).thenReturn(false); + when(apiGatewayV2Service.getApi("us-east-1", "abc123")).thenReturn(new Api()); + when(apiGatewayV2Service.findMatchingRoute("us-east-1", "abc123", "GET", "/hello")) + .thenReturn(null); + + ApiGatewayExecuteController controller = new ApiGatewayExecuteController( + apiGatewayService, apiGatewayV2Service, null, + regionResolver, new ObjectMapper(), null, + null, null, null, null); + + controller.dispatch("GET", "abc123", "prod", "hello", headers, null, null); + + // A correctly-signed (or otherwise resolved) request must not pay for the fallback + // scan at all — resolveHttpApiRegion is only for the "region is a guess" case. + verify(apiGatewayV2Service, never()).resolveHttpApiRegion(anyString(), anyString()); + verify(apiGatewayV2Service).getApi("us-east-1", "abc123"); + } + + @Test + void nonSigV4AuthorizationHeaderFallsBackToRestApiRegionScan() { + // A Cognito bearer JWT (or any Authorization header without a SigV4 Credential=...) + // must be treated the same as a missing header: resolveRegion silently defaulted, + // so the v1 REST path also needs to fall back to scanning for the real region. Uses + // the real RegionResolver (not a mock) so the test exercises the actual header-parsing + // logic in isRegionUnresolved, not just a stubbed answer. + RegionResolver regionResolver = new RegionResolver("us-east-1", "000000000000"); + ApiGatewayV2Service apiGatewayV2Service = mock(ApiGatewayV2Service.class); + ApiGatewayService apiGatewayService = mock(ApiGatewayService.class); + HttpHeaders headers = mock(HttpHeaders.class); + when(headers.getHeaderString("Authorization")).thenReturn("Bearer eyJhbGciOiJIUzI1NiJ9.fake.jwt"); + + when(apiGatewayV2Service.resolveHttpApiRegion("us-east-1", "restapi1")).thenReturn("us-east-1"); + // Not a v2 API at the guessed region — falls through to v1, which must also scan. + when(apiGatewayV2Service.getApi("us-east-1", "restapi1")).thenThrow( + new AwsException("NotFoundException", "Invalid API id specified", 404)); + when(apiGatewayService.resolveRestApiRegion("us-east-1", "restapi1")).thenReturn("ap-southeast-2"); + when(apiGatewayService.getRestApi("ap-southeast-2", "restapi1")).thenThrow( + new AwsException("NotFoundException", "Invalid REST API id specified", 404)); + + ApiGatewayExecuteController controller = new ApiGatewayExecuteController( + apiGatewayService, apiGatewayV2Service, null, + regionResolver, new ObjectMapper(), null, + null, null, null, null); + + controller.dispatch("GET", "restapi1", "prod", "hello", headers, null, null); + + verify(apiGatewayService).resolveRestApiRegion("us-east-1", "restapi1"); + verify(apiGatewayService).getRestApi("ap-southeast-2", "restapi1"); + } }