From 86c7c5760f74868339404f068ccd8b8def2e8527 Mon Sep 17 00:00:00 2001 From: Jay Burgess Date: Thu, 6 Aug 2026 14:30:59 -0600 Subject: [PATCH 1/3] fix(apigatewayv2): resolve HTTP API region across regions like REST APIs do Unsigned requests (and requests carrying a non-SigV4 Authorization header, e.g. a Cognito bearer JWT) resolve to RegionResolver's configured default region rather than a region the caller specified. For v1 REST APIs, resolveRestApiRegion already falls back to scanning all regions for the apiId when that happens. HTTP API (v2) dispatch had no equivalent, so any HTTP API deployed outside the default region returned 404 "Invalid API id specified" for every unsigned or bearer-token-authenticated request - which is effectively every request a browser, curl, or non-SigV4 client sends. Adds ApiGatewayV2Service#resolveHttpApiRegion, mirroring ApiGatewayService#resolveRestApiRegion exactly (same region::apiId key shape), and RegionResolver#isRegionUnresolved to give dispatch() a single correct signal for "region resolution silently fell back to the default" that covers both the blank-header and non-SigV4-header cases the previous blank-check missed. --- .../floci/core/common/RegionResolver.java | 11 +++++++++++ .../apigateway/ApiGatewayExecuteController.java | 14 +++++++++----- .../apigatewayv2/ApiGatewayV2Service.java | 17 +++++++++++++++++ 3 files changed, 37 insertions(+), 5 deletions(-) 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)); From 9d7b6b2054807968aa90c1751101d92d51f9799a Mon Sep 17 00:00:00 2001 From: Jay Burgess Date: Thu, 6 Aug 2026 14:47:29 -0600 Subject: [PATCH 2/3] test(apigateway): cover HTTP API cross-region resolution and non-SigV4 fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds unit coverage for the dispatch() region-resolution fix: - An unsigned request for a v2 HTTP API deployed outside the default region must fall back to resolveHttpApiRegion and find it there. - A correctly-resolved (signed) request must not pay for the fallback scan. - A non-SigV4 Authorization header (e.g. a Cognito bearer JWT) on a v1 REST API must also trigger the region scan, not just a blank/missing header — using a real RegionResolver rather than a mock so the test exercises the actual header-parsing logic in isRegionUnresolved. Verified each test fails when the corresponding fix is reverted: the v2 test and the bearer-JWT test both fail (2 of 12) when dispatch()'s region-fallback logic is rolled back to the pre-fix version, confirming they aren't vacuously passing. --- .../ApiGatewayExecuteControllerTest.java | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) 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"); + } } From 679294332ccc2534b29e9f1984c326052727fac8 Mon Sep 17 00:00:00 2001 From: Jay Burgess Date: Fri, 7 Aug 2026 09:38:39 -0600 Subject: [PATCH 3/3] chore: trigger CI