From c4e97a7b734c88432b1ab07fc2aea047f153408d Mon Sep 17 00:00:00 2001 From: Craig McConomy Date: Wed, 2 Sep 2026 12:09:27 -0400 Subject: [PATCH 1/2] fix(core): route host-style storage addressing on emulator hostnames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real Azure addresses storage host-style: the account is the first host label and the container is the first path segment. AzureRoutingFilter resolved the account only from the path — host-based routing existed solely for the production suffixes like .blob.core.windows.net — so a host-style request against an emulator hostname had its container consumed as the account and Create Container answered 501. The .NET Azure Functions host emits exactly that shape for AzureWebJobsStorage, so its azure-webjobs-hosts bootstrap failed and Durable/timer triggers never started. Derive a service-marker table from the registered host suffixes' first labels (blob, dfs, queue, table, vault, communication, servicebus) and add a routing stage that resolves {account}.{marker}.{any-host} exactly like {account}.{marker}.core.windows.net — devstoreaccount1.blob.localhost and devstoreaccount1.blob.floci-az now route to blob with account devstoreaccount1 and the full path as the resource. Path-style callers are unaffected: hosts without a service marker as their second label fall through to the account-suffix terminal unchanged, and duplicate markers across service types fail fast at startup. TableServiceHandler now registers .table.core.windows.net, so table gains both the production suffix and the marker, completing the blob/queue/table trio AzureWebJobsStorage needs. Routing tests pin the issue reproduction (host-style Create Container), host/path namespace sharing, queue and table marker hosts, the new table suffix, and that a dotted marker-less host stays path-style. Closes #267 --- .../io/floci/az/core/AzureRoutingFilter.java | 63 +++++++++++++++++ .../services/table/TableServiceHandler.java | 1 + .../floci/az/core/AzureRoutingFilterTest.java | 70 +++++++++++++++++++ .../az/core/RoutingTableAssemblyTest.java | 5 +- 4 files changed, 137 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/floci/az/core/AzureRoutingFilter.java b/src/main/java/io/floci/az/core/AzureRoutingFilter.java index d516e1d8..88b3db3b 100644 --- a/src/main/java/io/floci/az/core/AzureRoutingFilter.java +++ b/src/main/java/io/floci/az/core/AzureRoutingFilter.java @@ -54,6 +54,7 @@ public AzureRoutingFilter(AuthPipeline authPipeline, AzureServiceRegistry servic this.vertx = vertx; this.stages = List.of( this::routeByHostSuffix, + this::routeByHostServiceMarker, this::routeImds, this::routeEntra, this::routeArmMetadataEndpoints, @@ -146,6 +147,14 @@ record ResolvedProvider(String marker, String serviceType, Predicate gua /** Host-suffix → serviceType. Suffixes are mutually exclusive; DFS maps to blob. */ private volatile List hostRoutes = List.of(); + /** + * Service marker → serviceType, derived from each host suffix's first label ({@code + * .blob.core.windows.net → blob}). Lets {@code {account}.{marker}.{any-host}} route host-style on + * emulator hostnames ({@code devstoreaccount1.blob.localhost}, {@code devstoreaccount1.blob.floci-az}) + * exactly as it would on the production suffix. + */ + private volatile Map hostServiceMarkers = Map.of(); + /** * Account-name suffix → serviceType (e.g. {@code {account}-queue → queue}), sorted longest-first. * Any suffix that is a string-suffix of another is always shorter, so length-descending gives the @@ -201,6 +210,7 @@ void buildRoutingTables() { this.hostRoutes = List.copyOf(hosts); this.accountSuffixRoutes = List.copyOf(accounts); this.providerRoutes = List.copyOf(providers); + this.hostServiceMarkers = deriveHostServiceMarkers(hosts); LOGGER.infof("Routing tables: %d host, %d account-suffix, %d ARM provider routes", hostRoutes.size(), accountSuffixRoutes.size(), providerRoutes.size()); @@ -240,6 +250,28 @@ static void rejectDuplicateProviders(List providers) { } } + /** + * Derives the service-marker table from the registered host suffixes: the first label of each + * suffix names the service ({@code .blob.core.windows.net → blob}, {@code .dfs.… → blob}). Two + * suffixes producing the same marker for different service types would make + * {@code {account}.{marker}.{host}} ambiguous — fail fast like the other duplicate checks. + */ + static Map deriveHostServiceMarkers(List hostRoutes) { + Map markers = new HashMap<>(); + for (SuffixRoute route : hostRoutes) { + String suffix = route.suffix(); + int start = suffix.startsWith(".") ? 1 : 0; + int end = suffix.indexOf('.', start); + String marker = end < 0 ? suffix.substring(start) : suffix.substring(start, end); + String previous = markers.putIfAbsent(marker, route.serviceType()); + if (previous != null && !previous.equals(route.serviceType())) { + throw new IllegalStateException("Two service types claim the host service marker '" + + marker + "': " + previous + " and " + route.serviceType()); + } + } + return Map.copyOf(markers); + } + /** Returns the route whose suffix {@code value} ends with, or {@code null} if none matches. */ private static SuffixRoute matchSuffix(List routes, String value) { for (SuffixRoute route : routes) { @@ -363,6 +395,37 @@ private Outcome routeByHostSuffix(RoutingContext ctx) { return dispatchOrServiceDisabled(ctx, stripSuffix(ctx.host(), route), route.serviceType(), ctx.path()); } + /** + * Host-style (production-style) addressing on arbitrary emulator hostnames: + * {@code {account}.{service}.{rest…}} — e.g. {@code devstoreaccount1.blob.localhost} or + * {@code devstoreaccount1.blob.floci-az} — routes exactly like the production suffix + * {@code {account}.{service}.core.windows.net}. Real Azure and Azurite both address storage this + * way; without this stage a host-style request has its container consumed as the account and + * Create Container answers 501, which blocks the Functions host's AzureWebJobsStorage bootstrap + * (#267). Path-style callers are unaffected: their hosts carry no service marker as the second + * label, so they fall through to the account-suffix terminal unchanged. + */ + private Outcome routeByHostServiceMarker(RoutingContext ctx) { + String host = ctx.host(); + if (host == null) { + return Fallthrough.TO_NEXT_STAGE; + } + int firstDot = host.indexOf('.'); + if (firstDot <= 0) { + return Fallthrough.TO_NEXT_STAGE; + } + int secondDot = host.indexOf('.', firstDot + 1); + if (secondDot < 0 || secondDot == host.length() - 1) { + return Fallthrough.TO_NEXT_STAGE; // need {account}.{marker}.{at least one more label} + } + String serviceType = hostServiceMarkers.get(host.substring(firstDot + 1, secondDot)); + if (serviceType == null) { + return Fallthrough.TO_NEXT_STAGE; + } + String account = host.substring(0, firstDot); + return dispatchOrServiceDisabled(ctx, account, serviceType, ctx.path()); + } + /** * IMDS (Instance Metadata Service) — managed identity token endpoint: * {@code metadata/identity/oauth2/token?resource=...} (header {@code Metadata: true}). diff --git a/src/main/java/io/floci/az/services/table/TableServiceHandler.java b/src/main/java/io/floci/az/services/table/TableServiceHandler.java index 9588ecaf..eb9cb7a6 100644 --- a/src/main/java/io/floci/az/services/table/TableServiceHandler.java +++ b/src/main/java/io/floci/az/services/table/TableServiceHandler.java @@ -66,6 +66,7 @@ public boolean enabled(String serviceType) { public ServiceRoutes routes() { return ServiceRoutes.builder() + .host(".table.core.windows.net") .account("-table", "table") .build(); diff --git a/src/test/java/io/floci/az/core/AzureRoutingFilterTest.java b/src/test/java/io/floci/az/core/AzureRoutingFilterTest.java index 86aa5127..469388d4 100644 --- a/src/test/java/io/floci/az/core/AzureRoutingFilterTest.java +++ b/src/test/java/io/floci/az/core/AzureRoutingFilterTest.java @@ -226,4 +226,74 @@ void managedIdentityNestedProviderFallsThroughToArm() { + "11111111-1111-1111-1111-111111111111?api-version=2022-04-01") .then().statusCode(404); } + + // ── Host-style addressing on emulator hostnames (#267) ────────────────────── + // + // Real Azure and Azurite address storage host-style: the account is the first host label and the + // container is the first path segment ({account}.{service}.{host}). Without these routes, a + // host-style request has its container consumed as the account and Create Container answers 501 — + // which blocks the Azure Functions host's AzureWebJobsStorage bootstrap. + + @Test + void hostStyleBlobCreateContainerOnEmulatorHost() { + // The exact #267 reproduction: PUT /awjh?restype=container, Host: devstoreaccount1.blob.localhost. + given().header("Host", "devstoreaccount1.blob.localhost:4577") + .queryParam("restype", "container") + .when().put("/hoststyle-awjh") + .then().statusCode(201); + } + + @Test + void hostStyleAndPathStyleShareTheAccountNamespace() { + // A blob written host-style (docker-alias-shaped hostname) must be readable path-style: + // both addressings resolve to the same account. + given().header("Host", "devstoreaccount1.blob.floci-az") + .queryParam("restype", "container") + .when().put("/hoststyle-shared") + .then().statusCode(201); + given().header("Host", "devstoreaccount1.blob.floci-az") + .header("x-ms-blob-type", "BlockBlob").body("host-style payload") + .when().put("/hoststyle-shared/blob1") + .then().statusCode(201); + + given().when().get("/devstoreaccount1/hoststyle-shared/blob1") + .then().statusCode(200) + .body(containsString("host-style payload")); + } + + @Test + void hostStyleQueueListOnEmulatorHost() { + given().header("Host", "devstoreaccount1.queue.localhost:4577") + .when().get("/?comp=list") + .then().statusCode(200) + .body(containsString("EnumerationResults")); + } + + @Test + void hostStyleTableCreateOnEmulatorHost() { + given().header("Host", "devstoreaccount1.table.localhost:4577") + .contentType("application/json") + .body("{\"TableName\":\"hostStyleTbl\"}") + .when().post("/Tables") + .then().statusCode(201); + } + + @Test + void hostTableCoreWindowsNetRoutesToTable() { + given().header("Host", "acct.table.core.windows.net") + .contentType("application/json") + .body("{\"TableName\":\"hostSuffixTbl\"}") + .when().post("/Tables") + .then().statusCode(201); + } + + @Test + void dottedHostWithoutServiceMarkerStaysPathStyle() { + // A multi-label Host that carries no service marker (an emulator served at an FQDN) must keep + // path-style resolution: the first path segment is the account, not a container. + given().header("Host", "floci-az.mycorp.local:4577") + .when().get("/devstoreaccount1/?comp=list") + .then().statusCode(200) + .body(containsString("EnumerationResults")); + } } diff --git a/src/test/java/io/floci/az/core/RoutingTableAssemblyTest.java b/src/test/java/io/floci/az/core/RoutingTableAssemblyTest.java index 2322df0a..cdb8fa9f 100644 --- a/src/test/java/io/floci/az/core/RoutingTableAssemblyTest.java +++ b/src/test/java/io/floci/az/core/RoutingTableAssemblyTest.java @@ -32,13 +32,14 @@ class RoutingTableAssemblyTest { @Inject AzureRoutingFilter filter; - /** A4's HOST_ROUTES, verbatim. */ + /** A4's HOST_ROUTES, plus {@code .table.core.windows.net} (host-style table addressing, #267). */ private static final Set> GOLDEN_HOST_ROUTES = Set.of( Map.entry(".vault.azure.net", "keyvault"), Map.entry(".communication.azure.com", "email"), Map.entry(".blob.core.windows.net", "blob"), Map.entry(".dfs.core.windows.net", "blob"), Map.entry(".queue.core.windows.net", "queue"), + Map.entry(".table.core.windows.net", "table"), Map.entry(".servicebus.windows.net", "servicebus") ); @@ -95,7 +96,7 @@ private static Set> asEntries(List routes @Test void hostRoutesMatchA4() { assertEquals(GOLDEN_HOST_ROUTES, asEntries(filter.hostRoutes())); - assertEquals(6, filter.hostRoutes().size(), "no duplicate host suffixes"); + assertEquals(7, filter.hostRoutes().size(), "no duplicate host suffixes"); } @Test From b19b0028d68ccc76b9ae86364631bb7b4bec70fb Mon Sep 17 00:00:00 2001 From: Craig McConomy Date: Wed, 2 Sep 2026 14:39:50 -0400 Subject: [PATCH 2/2] fix(core): compare hostnames case-insensitively in host routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hostnames are case-insensitive (RFC 4343), but both host-routing stages compared the raw Host header: the marker lookup missed devstoreaccount1.BLOB.localhost and fell back to path-style — recreating the bootstrap failure — and the pre-existing production-suffix stage equally missed acct.BLOB.core.windows.net. Lowercase the host once at capture (hostWithoutPort, alongside the port strip) so every host comparison sees the normalized form and the account label lands in the lowercase namespace Azure account names use. A mixed-case host-style create-container test pins the behavior, including visibility of the container in the lowercase path-style namespace. --- .../java/io/floci/az/core/AzureRoutingFilter.java | 8 +++++++- .../io/floci/az/core/AzureRoutingFilterTest.java | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/floci/az/core/AzureRoutingFilter.java b/src/main/java/io/floci/az/core/AzureRoutingFilter.java index 88b3db3b..a2cd2ae2 100644 --- a/src/main/java/io/floci/az/core/AzureRoutingFilter.java +++ b/src/main/java/io/floci/az/core/AzureRoutingFilter.java @@ -18,6 +18,7 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; +import java.util.Locale; import java.util.List; import java.util.Map; import java.util.Optional; @@ -368,12 +369,17 @@ private static boolean isEmulatorAdminPath(String path) { || path.startsWith("_floci/") || path.startsWith("_admin"); } + /** + * Strips the port and lowercases: hostnames are case-insensitive (RFC 4343), so every host + * comparison — production suffixes and service markers alike — happens on the lowercase form. + */ private static String hostWithoutPort(String capturedHost) { if (capturedHost == null) { return null; } int colon = capturedHost.indexOf(':'); - return colon < 0 ? capturedHost : capturedHost.substring(0, colon); + String host = colon < 0 ? capturedHost : capturedHost.substring(0, colon); + return host.toLowerCase(Locale.ROOT); } // ── Routing stages, in chain order ────────────────────────────────────────── diff --git a/src/test/java/io/floci/az/core/AzureRoutingFilterTest.java b/src/test/java/io/floci/az/core/AzureRoutingFilterTest.java index 469388d4..7ec0da4a 100644 --- a/src/test/java/io/floci/az/core/AzureRoutingFilterTest.java +++ b/src/test/java/io/floci/az/core/AzureRoutingFilterTest.java @@ -287,6 +287,20 @@ void hostTableCoreWindowsNetRoutesToTable() { .then().statusCode(201); } + @Test + void hostStyleAddressingIsCaseInsensitive() { + // Hostnames are case-insensitive (RFC 4343): a mixed-case Host must still route host-style, + // and the account label must land in the lowercase account namespace. + given().header("Host", "DevStoreAccount1.BLOB.Localhost:4577") + .queryParam("restype", "container") + .when().put("/hoststyle-case") + .then().statusCode(201); + + given().when().get("/devstoreaccount1/?comp=list") + .then().statusCode(200) + .body(containsString("hoststyle-case")); + } + @Test void dottedHostWithoutServiceMarkerStaysPathStyle() { // A multi-label Host that carries no service marker (an emulator served at an FQDN) must keep