From d628bb00709865574862b0af30ccf915c3399821 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 13:59:36 +0200 Subject: [PATCH 1/4] Document deploying AuthProxy on Azure App Service and Container Apps Add a deployment guide covering the Microsoft Entra registration, the settings every Azure deployment needs, platform specific setup, and how to keep backends reachable only through AuthProxy, with a test that fails when a backend answers directly. --- Documentation/azure/app-service.md | 205 +++++++++++++++++++ Documentation/azure/container-apps.md | 175 +++++++++++++++++ Documentation/azure/index.md | 273 ++++++++++++++++++++++++++ Documentation/azure/toc.yml | 6 + Documentation/index.mdx | 3 + Documentation/toc.yml | 2 + 6 files changed, 664 insertions(+) create mode 100644 Documentation/azure/app-service.md create mode 100644 Documentation/azure/container-apps.md create mode 100644 Documentation/azure/index.md create mode 100644 Documentation/azure/toc.yml diff --git a/Documentation/azure/app-service.md b/Documentation/azure/app-service.md new file mode 100644 index 00000000..f6230da4 --- /dev/null +++ b/Documentation/azure/app-service.md @@ -0,0 +1,205 @@ +# AuthProxy on Azure App Service + +AuthProxy runs on App Service as a Linux custom container, with your backends and frontends as separate App +Service apps (or any other private workload) that only AuthProxy can reach. Read the +[overview](index.md) first: it covers the Entra registration and the settings both platforms share. + +--- + +## Create the AuthProxy app + +Create a Linux App Service plan (Basic or higher; a private endpoint, used below, needs one) and a web app +that runs the `cratis/authproxy` image from Docker Hub. Pin a version tag rather than `latest`. + +```bash +az appservice plan create --resource-group $rg --name $plan --is-linux --sku P1V3 + +az webapp create --resource-group $rg --plan $plan --name $authproxy \ + --deployment-container-image-name docker.io/cratis/authproxy: + +az webapp identity assign --resource-group $rg --name $authproxy +az webapp update --resource-group $rg --name $authproxy --https-only true +az webapp config set --resource-group $rg --name $authproxy --web-sockets-enabled true --always-on true +``` + +- **`WEBSITES_PORT=8080`.** App Service assumes a container listens on port 80. The AuthProxy image listens on + 8080, so tell the platform. +- **HTTPS only**, so plain-HTTP requests are redirected before they reach AuthProxy. +- **Web sockets on**, if any service behind AuthProxy uses WebSockets. +- **Always On**, so the app is not unloaded after 20 minutes without traffic. The platform's keep-alive request + to `/` is answered `401` by AuthProxy, which does it no harm. +- **Managed identity**, for the storage account and Key Vault key used for + [Data Protection keys](index.md#data-protection-keys). +- **Leave Authentication off**, as the overview explains. + +### App settings + +Everything below is an app setting, which App Service passes to the container as an environment variable. +`__` in a name is the configuration separator. Keep the client secret out of plain settings by +referencing a Key Vault secret, which the app's managed identity must be allowed to read: + +```bash +az webapp config appsettings set --resource-group $rg --name $authproxy --settings \ + WEBSITES_PORT=8080 \ + Cratis__AuthProxy__Authentication__OidcProviders__0__Name=Microsoft \ + Cratis__AuthProxy__Authentication__OidcProviders__0__Type=Microsoft \ + Cratis__AuthProxy__Authentication__OidcProviders__0__Authority=https://login.microsoftonline.com/$directoryTenantId/v2.0 \ + Cratis__AuthProxy__Authentication__OidcProviders__0__ClientId=$clientId \ + "Cratis__AuthProxy__Authentication__OidcProviders__0__ClientSecret=@Microsoft.KeyVault(SecretUri=$secretUri)" \ + Cratis__AuthProxy__Authentication__OidcProviders__0__CanonicalIdentity__ProviderKey=entra-workforce \ + Cratis__AuthProxy__Authentication__OidcProviders__0__CanonicalIdentity__SubjectClaimType=oid \ + Cratis__AuthProxy__Services__portal__Backend__BaseUrl=https://$backend.azurewebsites.net/ \ + Cratis__AuthProxy__Services__portal__Frontend__BaseUrl=https://$frontend.azurewebsites.net/ \ + Cratis__AuthProxy__Services__portal__AnonymousPaths__0=/api/health \ + Cratis__AuthProxy__Ingress__Mode=Configured \ + Cratis__AuthProxy__Ingress__TrustedProxies__0=10.0.0.0/8 \ + Cratis__AuthProxy__Ingress__TrustedProxies__1=172.16.0.0/12 \ + Cratis__AuthProxy__Ingress__TrustedProxies__2=192.168.0.0/16 \ + Cratis__AuthProxy__Ingress__ForwardLimit=1 \ + Cratis__AuthProxy__DataProtection__Store=AzureBlob \ + Cratis__AuthProxy__DataProtection__AzureBlob__BlobUri=https://$account.blob.core.windows.net/dataprotection/authproxy-keys.xml \ + Cratis__AuthProxy__DataProtection__KeyVault__KeyIdentifier=https://$vault.vault.azure.net/keys/authproxy-dataprotection +``` + +Add the `Authorization` settings from the overview if you require an app role. The trusted-proxy and +`AnonymousPaths` settings are explained next. + +### Trusted proxies + +App Service terminates TLS at its front ends and reaches your container from a private address. Microsoft's +own ASP.NET Core guidance for App Service trusts the three private ranges, `10.0.0.0/8`, `172.16.0.0/12` and +`192.168.0.0/16`, because the platform does not publish anything narrower. The settings above do the same, +with a `ForwardLimit` of `1` for the App Service front end alone. + +Two consequences: + +- Those ranges include your own virtual network. A resource in it that can reach AuthProxy directly can + therefore also set `X-Forwarded-For`. With the access restrictions below, only the front ends can. +- Put Azure Front Door or Application Gateway in front of the app and you have a second hop. Raise + `ForwardLimit` to `2` and add that service's own address ranges to `TrustedProxies`, because every hop + that is consumed has to be trusted. For Front Door, also allow only your instance to reach AuthProxy, with an + access restriction on the `AzureFrontDoor.Backend` service tag and your `X-Azure-FDID` header value; + otherwise anyone can bypass it and talk to the app's public name. + +After the first sign-in, check what AuthProxy recorded. The `ipAddress` your +[sign-in notification](../configuration/sign-in.md) receives should be the user's, not an address inside +Azure. [Trusted proxies](../configuration/trusted-proxies.md#checking-your-work) shows how to read it. + +### Health check + +App Service's health check requests a path on the site and takes an instance out of rotation when it does not +answer `200` to `299`. It does not follow redirects, and a site with its own authentication must let the +path through anonymously. AuthProxy's own endpoints are no use to it: `/` answers `401`, and the +[management listener](../configuration/management-listener.md) is on a second port, while App Service +sends traffic to the one port named by `WEBSITES_PORT`. + +Point the check at a health endpoint of your **backend**, made reachable without a session by declaring it +anonymous. That is the `AnonymousPaths__0=/api/health` setting above; a path under `/api` goes to the +service's backend. It tests AuthProxy and the backend end to end, so an unhealthy backend takes the instance +out of rotation. Declare the narrowest path and make sure it returns nothing sensitive; in a deployment with several services, +a declared prefix is [claimed for the whole proxy](../configuration/services.md#what-it-does-and-does-not-change). Enable **Health +check** under **Monitoring** on the AuthProxy app, give it that path, and run at least two instances, because +App Service does not take its only instance out of rotation. + +### Timeouts and streaming + +AuthProxy cancels a request that is idle for five minutes by default (`Cratis__AuthProxy__ActivityTimeout`). +App Service's front end also applies an idle limit of its own to a connection; look up its current value in +the App Service documentation, because AuthProxy cannot raise it. Whichever limit is shorter ends a quiet +WebSocket or Server-Sent Events stream, so send a heartbeat every 15 to 30 seconds and treat a dropped +stream as normal on the client. See +[Timeouts and streaming](../configuration/services.md#timeouts-and-streaming). + +--- + +## Isolating backends + +Each backend and frontend is its own App Service app, and **each one must refuse everything except +AuthProxy.** There are two ways; pick one per app. The first is the stronger. + +### Private endpoint (recommended) + +The backend gets a private IP in your virtual network and no public address. AuthProxy reaches it over its +own virtual network integration. + +1. Put AuthProxy on a **virtual network integration subnet**. This is outbound-only; it does not make + AuthProxy private. + + ```bash + az webapp vnet-integration add --resource-group $rg --name $authproxy \ + --vnet $vnet --subnet integration-subnet + ``` + + The subnet must be delegated to `Microsoft.Web/serverFarms` (the command applies the delegation if it + is missing) and cannot be the subnet that holds the private endpoint. +2. Create a **private endpoint** for each backend in a separate subnet, and a private DNS zone + `privatelink.azurewebsites.net` linked to the virtual network, so that + `$backend.azurewebsites.net` resolves to the private address from inside the network: + + ```bash + az network private-dns zone create --resource-group $rg --name privatelink.azurewebsites.net + az network private-dns link vnet create --resource-group $rg --zone-name privatelink.azurewebsites.net \ + --name authproxy-link --virtual-network $vnet --registration-enabled false + + az network private-endpoint create --resource-group $rg --name $backend-pe \ + --vnet-name $vnet --subnet private-endpoint-subnet \ + --private-connection-resource-id $(az webapp show --resource-group $rg --name $backend --query id -o tsv) \ + --group-id sites --connection-name $backend-pe-connection + + az network private-endpoint dns-zone-group create --resource-group $rg \ + --endpoint-name $backend-pe --name default \ + --private-dns-zone privatelink.azurewebsites.net --zone-name privatelink.azurewebsites.net + ``` +3. **Disable public network access** on the backend. This is the step that makes it isolated; without it the + backend keeps answering on its public name as well. + + ```bash + az resource update --resource-group $rg --name $backend --resource-type Microsoft.Web/sites \ + --set properties.publicNetworkAccess=Disabled + ``` + + On Linux, changing this property restarts the app. + +AuthProxy's `BaseUrl` stays `https://$backend.azurewebsites.net/`: the name must match the app's own, and +inside the virtual network it now resolves to the private endpoint. + +Remember what a private endpoint does not do: **access restriction rules are not evaluated for traffic +through it.** Anything that can route to the endpoint's address, including other workloads in the network +and peered networks, reaches the backend. Either keep the network to workloads you trust as much as +AuthProxy, or restrict who may connect to the endpoint with a network security group on its subnet (private +endpoint network policies must be enabled on the subnet for the group to apply). Then run the second +[isolation test](index.md#test-that-a-backend-cannot-be-reached-directly) from another workload in the network +to see which of those you actually have. + +### Access restrictions with a service endpoint + +If a private endpoint is not an option, keep the public name but allow only AuthProxy's integration subnet. + +```bash +# Service endpoints for Microsoft.Web must be enabled on the integration subnet. +az network vnet subnet update --resource-group $rg --vnet-name $vnet --name integration-subnet \ + --service-endpoints Microsoft.Web + +az webapp config access-restriction add --resource-group $rg --name $backend \ + --rule-name allow-authproxy --action Allow --priority 100 \ + --vnet-name $vnet --subnet integration-subnet + +az webapp config access-restriction add --resource-group $rg --name $backend \ + --rule-name allow-authproxy --action Allow --priority 100 \ + --vnet-name $vnet --subnet integration-subnet --scm-site true +``` + +Adding any rule makes the end of the list an implicit **deny all**, so nothing else is needed. Do the same +for the advanced tools (`scm`) site as shown, because it has its own rule list. Use the subnet rule rather +than an IP address: in the multitenant App Service an IP rule cannot name a virtual network range, and +AuthProxy's public outbound addresses are shared with other apps on the same workers. Any app integrated with +that subnet is allowed through, so keep untrusted apps off it. + +### Prove it + +Run the [direct-access test](index.md#test-that-a-backend-cannot-be-reached-directly) against +`https://$backend.azurewebsites.net/.cratis/me` from a machine outside the network: it must be refused. Run it +again from a virtual machine or app in the virtual network that is not AuthProxy. With access restrictions it +is refused. With a private endpoint it is refused only if you restricted the endpoint as described above; +otherwise it is answered, and so would be an attacker's request from that network. Then sign in through +AuthProxy and confirm the application works. diff --git a/Documentation/azure/container-apps.md b/Documentation/azure/container-apps.md new file mode 100644 index 00000000..a40b90c1 --- /dev/null +++ b/Documentation/azure/container-apps.md @@ -0,0 +1,175 @@ +# AuthProxy on Azure Container Apps + +AuthProxy runs on Container Apps as one container app with **external** ingress, in front of backends and +frontends that are container apps with **internal** ingress. Read the [overview](index.md) first: it covers +the Entra registration and the settings both platforms share. + +The decision that matters here is how much you trust the other apps in the environment. Internal ingress does +not mean "reachable only by AuthProxy". See [Isolating backends](#isolating-backends). + +--- + +## Create the apps + +Use a **user-assigned managed identity** for AuthProxy. Its Key Vault secret reference and its storage and +vault role assignments all have to exist before the app starts, and a system-assigned identity does not exist +until the app has been created. + +```bash +az identity create --resource-group $rg --name id-authproxy +identityId=$(az identity show --resource-group $rg --name id-authproxy --query id -o tsv) +identityClientId=$(az identity show --resource-group $rg --name id-authproxy --query clientId -o tsv) +``` + +Give the identity the `Key Vault Secrets User` role on the client secret, the `Key Vault Crypto User` role on +the Data Protection key, and `Storage Blob Data Contributor` on the blob container. Then create AuthProxy: + +```bash +az containerapp create --resource-group $rg --name authproxy --environment $env \ + --image docker.io/cratis/authproxy: \ + --user-assigned $identityId \ + --ingress external --target-port 8080 \ + --min-replicas 2 \ + --secrets "entra-client-secret=keyvaultref:$secretUri,identityref:$identityId" \ + --env-vars \ + Cratis__AuthProxy__Authentication__OidcProviders__0__Name=Microsoft \ + Cratis__AuthProxy__Authentication__OidcProviders__0__Type=Microsoft \ + Cratis__AuthProxy__Authentication__OidcProviders__0__Authority=https://login.microsoftonline.com/$directoryTenantId/v2.0 \ + Cratis__AuthProxy__Authentication__OidcProviders__0__ClientId=$clientId \ + Cratis__AuthProxy__Authentication__OidcProviders__0__ClientSecret=secretref:entra-client-secret \ + Cratis__AuthProxy__Authentication__OidcProviders__0__CanonicalIdentity__ProviderKey=entra-workforce \ + Cratis__AuthProxy__Authentication__OidcProviders__0__CanonicalIdentity__SubjectClaimType=oid \ + Cratis__AuthProxy__Services__portal__Backend__BaseUrl=https://$backendFqdn/ \ + Cratis__AuthProxy__Services__portal__Frontend__BaseUrl=https://$frontendFqdn/ \ + Cratis__AuthProxy__Ingress__Mode=TrustAny \ + Cratis__AuthProxy__Ingress__ForwardLimit=1 \ + Cratis__AuthProxy__DataProtection__Store=AzureBlob \ + Cratis__AuthProxy__DataProtection__AzureBlob__BlobUri=https://$account.blob.core.windows.net/dataprotection/authproxy-keys.xml \ + Cratis__AuthProxy__DataProtection__KeyVault__KeyIdentifier=https://$vault.vault.azure.net/keys/authproxy-dataprotection \ + Cratis__AuthProxy__DataProtection__ManagedIdentityClientId=$identityClientId \ + Cratis__AuthProxy__Management__Port=9110 \ + Cratis__AuthProxy__Management__BindAddress=0.0.0.0 +``` + +- **Target port 8080.** The AuthProxy image listens on 8080. Ingress publishes it on 443 and redirects port 80 + to it. +- **At least two replicas**, and a shared key store, so a restart or scale-out does not sign users out. With + the shared [Data Protection keys](index.md#data-protection-keys), any replica can read any session. Leave + [session affinity](https://learn.microsoft.com/azure/container-apps/sticky-sessions) off. +- **The client secret** is a Key Vault reference resolved by the managed identity and passed to the container + as `secretref:`. It never appears in the app's settings. +- **Leave Authentication off** on the container app, as the overview explains. + +### Service addresses + +Use the backend's **internal FQDN**, which is `.internal...azurecontainerapps.io`: + +```bash +backendFqdn=$(az containerapp show --resource-group $rg --name portal-api \ + --query properties.configuration.ingress.fqdn -o tsv) +``` + +Container Apps also documents `http://` for calls inside an environment. With the default ingress +setting (`allowInsecure` is `false`) plain HTTP is redirected to HTTPS, and AuthProxy passes a redirect from a +backend on to the caller rather than following it, so use the `https` FQDN and keep the traffic encrypted. + +### Trusted proxies + +Container Apps ingress overwrites `X-Forwarded-Proto` and adds the client's address to `X-Forwarded-For`, +and Microsoft's documentation states that only the rightmost address is provided by the platform. AuthProxy +takes only that one with a `ForwardLimit` of `1`, so a value a caller wrote further left in the header is never +read. + +The address AuthProxy sees as the peer is the environment's ingress proxy, and the platform publishes no +range for it. The settings above therefore use `Mode=TrustAny`, which is right **only because nothing other +than the platform's ingress can connect to the container's port 8080**: the platform routes every request +between apps through its proxy layer, not directly to a replica. If you also run +something that can open a connection straight to the replica, name the real peers with `TrustedProxies` +instead. See [Trusted proxies](../configuration/trusted-proxies.md#modes). + +### Health probes + +The management listener is a second port that the platform can probe, so use it: + +```yaml +containers: + - name: authproxy + image: docker.io/cratis/authproxy: + probes: + - type: Liveness + httpGet: + path: /health/live + port: 9110 + - type: Readiness + httpGet: + path: /health/ready + port: 9110 +``` + +Apply it by updating the app with a YAML definition that carries this `probes` list. Readiness runs the Data +Protection round trip, so a replica that cannot load the key ring from storage is kept out of rotation. +Probe port 9110 matches `Management__Port` above. + +`Management__BindAddress=0.0.0.0` is needed because the default is loopback, which a probe sent to the +replica from outside the container cannot reach. The management port is not the ingress target port, so +ingress does not publish it. It answers only `/health/live` and `/health/ready`, with a fixed short body. See +[Management listener](../configuration/management-listener.md). If a replica never becomes ready, check this +bind address first. + +### Timeouts and streaming + +Ingress ends a request that is idle for **four minutes** by default (240 seconds). A Container Apps environment +on the premium ingress can raise that to up to 30 minutes in its ingress settings. AuthProxy's own +`ActivityTimeout` defaults to five minutes, so on a default environment the platform cuts a quiet WebSocket +or Server-Sent Events stream first. Send a heartbeat every 15 to 30 seconds, and raise both limits together if +a stream has to be quiet for longer. See +[Timeouts and streaming](../configuration/services.md#timeouts-and-streaming). + +--- + +## Isolating backends + +Create each backend and frontend container app with **internal** ingress: + +```bash +az containerapp create --resource-group $rg --name portal-api --environment $env \ + --image --ingress internal --target-port 8080 +``` + +From outside the environment, an internal app's name resolves and the TLS handshake succeeds, but the +environment's proxy answers `404`. That settles the internet, and for a VNet-integrated environment it +settles your own network too: requests from your virtual network count as outside the environment. + +**It does not settle the other apps in the environment.** Container Apps documents internal ingress as +reachable from other container apps in the same environment, and an *external* app is also reachable from them. +Any app in the environment can therefore call `https://portal-api.internal...` with a forged +`x-ms-client-principal` header and be believed. Treat the environment as the trust boundary: + +1. **Run AuthProxy and the apps it protects in an environment of their own**, in which every app is one you + would trust with every user's identity. Put anything else, such as other teams' apps, jobs and + experiments, in a different environment. +2. **Keep the backends out of any environment-level HTTP route configuration.** An app with internal ingress + can still receive outside traffic when a route configuration names it as a target. +3. **For an environment that only your network should reach**, create it as an internal environment, and + publish AuthProxy with external ingress, which then listens on the environment's internal load balancer. + Container Apps documents the two settings together: the environment decides whether there is a public + endpoint, and the app's ingress decides whether it is published at that boundary. +4. **Do not rely on IP restrictions** on a backend to separate it from apps in the same environment. They + filter by caller address, and in-environment traffic arrives through the platform's proxy layer. + +Container Apps client certificates (`clientCertificateMode`) would tie a backend to AuthProxy by +certificate, but AuthProxy does not present a client certificate to the services it proxies to, so they cannot +be used for this today. Per-request signing of the forwarded identity, which would make environment +membership irrelevant, is tracked in [Cratis/AuthProxy#139](https://github.com/Cratis/AuthProxy/issues/139). + +### Prove it + +Run the [direct-access test](index.md#test-that-a-backend-cannot-be-reached-directly) twice: + +- Against `https://portal-api.internal...azurecontainerapps.io/.cratis/me` from outside + the environment, such as your pipeline runner. It must be refused (`404`). +- Against the same address from a **container app in the same environment that is not AuthProxy**. It will be + answered. That is not a bug in your configuration; it is the boundary described above. If that second app + is not supposed to be able to impersonate users, it belongs in another environment. + +Then sign in through AuthProxy and confirm the application works. diff --git a/Documentation/azure/index.md b/Documentation/azure/index.md new file mode 100644 index 00000000..d4455eb1 --- /dev/null +++ b/Documentation/azure/index.md @@ -0,0 +1,273 @@ +# Deploying on Azure + +This guide takes AuthProxy from nothing to a working deployment on Azure, signing users in with Microsoft +Entra ID, and keeping your backends reachable **only** through the proxy. The platform-specific steps are in +[App Service](app-service.md) and [Container Apps](container-apps.md); everything that is the same on both is +here. + +```mermaid +flowchart LR + User["Browser"] -->|HTTPS| AP["AuthProxy
(public)"] + AP -->|sign-in| Entra["Microsoft Entra ID"] + AP -->|"identity headers"| BE["Backend
(private)"] + AP --> FE["Frontend
(private)"] + Direct["Anyone else"] -.->|must not reach| BE +``` + +--- + +## Backends trust AuthProxy, so only AuthProxy may reach them + +This is the one decision in the whole deployment that cannot be fixed afterwards. + +AuthProxy strips the identity headers a caller sends, authenticates the caller, and writes +`x-ms-client-principal`, `x-ms-client-principal-id` and `x-ms-client-principal-name` itself. Your backend +believes them. They are **not signed**: a backend cannot tell a request that went through AuthProxy from one +that did not, except by the network it arrived on. See +[Forwarded identity headers](../configuration/authentication.md#forwarded-identity-headers). + +So a backend that anyone other than AuthProxy can open a connection to is a backend anyone can impersonate any +user on, by sending the header. The platform features that prevent this differ, and getting them wrong is +easy because the failure is silent: the application works perfectly, for everybody, including the person +forging the header. Each platform page ends with an isolation recipe and the [direct-access +test](#test-that-a-backend-cannot-be-reached-directly) below proves it. + +--- + +## Register the application in Microsoft Entra ID + +1. In the Microsoft Entra admin center, **App registrations** > **New registration**. Choose **Accounts in + this organizational directory only** (single tenant) unless you really do accept other organizations. +2. Under **Authentication**, add a **Web** platform with two redirect URIs, using the public host name users + type (`auth.example.com` below): + + | Redirect URI | Why | + |--------------|-----| + | `https://auth.example.com/signin-microsoft` | The sign-in callback. The last segment is `signin-` plus the provider `Name` lowercased with spaces replaced by hyphens, so a provider named `Microsoft` gives `signin-microsoft`. | + | `https://auth.example.com/.cratis/logout/callback` | The post-logout callback. Microsoft requires `post_logout_redirect_uri` to match a registered redirect URI. See [Logout](../configuration/logout.md#full-chain-logout). | + + Entra accepts `http` only for `localhost`, so register the public `https` address, not the platform's + internal one. +3. Under **Certificates & secrets**, create a **client secret**. See [the credential](#the-credential) + below before you pick a lifetime. +4. Copy the **Application (client) ID** and the **Directory (tenant) ID**. + +### The credential + +Microsoft recommends certificate or federated (managed identity) credentials for confidential clients, and +advises against secrets in production. AuthProxy's OIDC provider takes **a client secret only** +(`ClientSecret`); certificate and federated credentials are not supported yet and are tracked in +[Cratis/AuthProxy#149](https://github.com/Cratis/AuthProxy/issues/149). Until then: + +- Keep the secret out of source control and out of plain app settings. Hold it in Key Vault and reference it + from the platform (the platform pages show how). +- Choose a short lifetime and put the expiry in a calendar or an alert. When it expires, **every sign-in + fails** until the setting is replaced. +- Rotate with overlap: Entra lets two secrets be valid at once, so add the new one, update the setting, + restart, then delete the old one. + +### Who may sign in + +A single-tenant authority already refuses accounts from other directories, but every account in **your** +directory can sign in. Narrow it in this order, using as many as you need: + +- On the **enterprise application**, turn on **Assignment required**. Only users you assign can then sign in. + Grant tenant-wide admin consent to the application first, because user consent is not allowed once + assignment is required. Assigning groups rather than individual users needs Microsoft Entra ID P1 or P2. +- Define **app roles** on the app registration, assign them to users or groups, and require one in AuthProxy + with [`Authorization`](../configuration/authorization.md). Entra puts assigned roles in the `roles` claim of + the ID token. +- Do both for anything sensitive. Assignment keeps strangers out; the AuthProxy requirement keeps a + mistakenly-assigned or newly-added user without the role out of services that need more. + +### The AuthProxy provider + +```json +{ + "Cratis": { + "AuthProxy": { + "Authentication": { + "OidcProviders": [ + { + "Name": "Microsoft", + "Type": "Microsoft", + "Authority": "https://login.microsoftonline.com//v2.0", + "ClientId": "", + "ClientSecret": "", + "CanonicalIdentity": { + "ProviderKey": "entra-workforce", + "SubjectClaimType": "oid" + } + } + ] + }, + "Authorization": { + "RequiredClaims": [ + { "Claim": "roles", "AnyOf": [ "AuthProxy.User" ] } + ] + } + } + } +} +``` + +- **Use the tenant-specific authority** shown, not `common` or `organizations`. A multi-tenant authority + accepts any Entra directory. +- **`CanonicalIdentity` with `SubjectClaimType: oid`** makes the stable object ID the user's subject, instead + of a value Entra can change or reassign. See + [Canonical federated identity](../configuration/authentication.md#canonical-federated-identity). + It also keeps Entra's claim names as they are in the token, which is why the requirement above says + `roles`. **Without** `CanonicalIdentity` the .NET handler renames that claim, and the requirement would + have to name `http://schemas.microsoft.com/ws/2008/06/identity/claims/role` instead. +- Drop the `Authorization` section if you rely on **Assignment required** alone. +- Changing `CanonicalIdentity` later signs every existing user out, so decide before go-live. + +On both platforms these are set as environment variables; `__` replaces `:` and array positions are +indexes: + +```bash +Cratis__AuthProxy__Authentication__OidcProviders__0__Name=Microsoft +Cratis__AuthProxy__Authentication__OidcProviders__0__Type=Microsoft +Cratis__AuthProxy__Authentication__OidcProviders__0__Authority=https://login.microsoftonline.com//v2.0 +Cratis__AuthProxy__Authentication__OidcProviders__0__ClientId= +Cratis__AuthProxy__Authentication__OidcProviders__0__ClientSecret= +Cratis__AuthProxy__Authentication__OidcProviders__0__CanonicalIdentity__ProviderKey=entra-workforce +Cratis__AuthProxy__Authentication__OidcProviders__0__CanonicalIdentity__SubjectClaimType=oid +Cratis__AuthProxy__Authorization__RequiredClaims__0__Claim=roles +Cratis__AuthProxy__Authorization__RequiredClaims__0__AnyOf__0=AuthProxy.User +``` + +--- + +## Point AuthProxy at your services + +Name each backend and frontend by the address AuthProxy reaches it on. These are the private addresses of +the [isolation recipe](#test-that-a-backend-cannot-be-reached-directly), not public ones: + +```bash +Cratis__AuthProxy__Services__portal__Backend__BaseUrl=https://portal-api./ +Cratis__AuthProxy__Services__portal__Frontend__BaseUrl=https://portal-web./ +``` + +With one service, `/api/**` goes to the backend and everything else to the frontend. With several, a +caller names the service on each request. See [Services](../configuration/services.md). + +--- + +## Settings every Azure deployment needs + +### Trusted proxies + +The platform terminates TLS and forwards the request over plain HTTP, so AuthProxy only knows the request was +HTTPS, and who the client is, from `X-Forwarded-Proto` and `X-Forwarded-For`. If those are not believed, +**session cookies are issued without `Secure`** and sign-in notifications record the platform's address as the +client. Tell AuthProxy which peers to believe: + +```bash +Cratis__AuthProxy__Ingress__Mode=Configured +Cratis__AuthProxy__Ingress__TrustedProxies__0=... +Cratis__AuthProxy__Ingress__ForwardLimit=1 +``` + +The values differ per platform and are given on each platform's page. Add one to `ForwardLimit`, and trust +the extra hop, for every proxy you put in front of the platform's own ingress, such as Azure Front Door or +Application Gateway. See [Trusted proxies](../configuration/trusted-proxies.md). Do **not** set +`ASPNETCORE_FORWARDEDHEADERS_ENABLED`. + +### Data Protection keys + +AuthProxy encrypts its session cookie and tokens with Data Protection keys. Local disk is not shared between +replicas and is not kept across restarts on either platform, so **a deployment with more than one replica +will sign users out at random** until the keys are shared. Use Azure Blob Storage, encrypted with a Key Vault +key, with a managed identity: + +```bash +Cratis__AuthProxy__DataProtection__Store=AzureBlob +Cratis__AuthProxy__DataProtection__AzureBlob__BlobUri=https://.blob.core.windows.net/dataprotection/authproxy-keys.xml +Cratis__AuthProxy__DataProtection__KeyVault__KeyIdentifier=https://.vault.azure.net/keys/authproxy-dataprotection +``` + +Give AuthProxy's managed identity the `Storage Blob Data Contributor` role on the container and the +`Key Vault Crypto User` role on the key, and create the container and an RSA key first. If the identity is +user-assigned, also set `Cratis__AuthProxy__DataProtection__ManagedIdentityClientId`. Every detail, and the +Redis alternative, is in [Data protection keys](../configuration/data-protection.md). + +### Timeouts and streaming + +Frontends that stream (WebSockets, Server-Sent Events) are cut when they go quiet for longer than the +shortest idle limit on the path: AuthProxy's `ActivityTimeout` (five minutes by default) or the platform +ingress's own. Send a heartbeat comfortably inside the shortest of them, and raise AuthProxy's only if the +platform's is longer. The platform limits are on the platform pages; +[Timeouts and streaming](../configuration/services.md#timeouts-and-streaming) covers AuthProxy's side. + +### Health probes + +AuthProxy answers nothing about itself on the public port, and an HTTP probe pointed at `/` gets `401`. Use the +[management listener](../configuration/management-listener.md) where the platform can probe a second port, and +a declared [anonymous path](../configuration/services.md#anonymous-paths) where it cannot. The platform pages +say which applies. + +### Do not turn on the platform's own sign-in + +App Service **Authentication** and Container Apps **Authentication** put a second sign-in in front of +AuthProxy. AuthProxy does the sign-in; leave the platform feature off for the AuthProxy app. + +--- + +## Test that a backend cannot be reached directly + +The recipes on the platform pages are configuration. This is the proof, and it belongs in your pipeline so a +later change that opens a backend fails the build. + +It sends a request that **claims to be a signed-in user** straight to the backend's own address, and fails if +the backend serves it. Aim it at an endpoint that answers `200` when it trusts a principal. `/.cratis/me` is +the one AuthProxy itself calls on a backend: + +```bash +#!/usr/bin/env bash +# Usage: ./assert-backend-isolated.sh https://portal-api.example.net/.cratis/me [...] +# Exits non-zero if any backend answers a request that did not come through AuthProxy. +fail=0 +principal=$(printf '{"userId":"forged","userDetails":"forged","claims":[]}' | base64 | tr -d '\n') + +for url in "$@"; do + code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 \ + -H "x-ms-client-principal: ${principal}" \ + -H "x-ms-client-principal-id: forged" \ + -H "x-ms-client-principal-name: forged" \ + "$url" 2>/dev/null) || code=000 + + case "$code" in + 2??|3??) echo "FAIL $url answered $code to a forged identity"; fail=1 ;; + *) echo "ok $url -> $code" ;; + esac +done + +exit $fail +``` + +`000` means the request never connected: DNS does not resolve, the address is private, or the connection was +refused. That is a pass. A `401`, `403` or `404` is a pass **only** if it is the platform refusing the +caller; a `404` for a path that does not exist proves nothing, which is why the script needs an address that +exists. + +Run it from two places: + +1. **From outside the network**, such as the pipeline runner or a laptop, against the backend's **public** + name. This catches a backend left on the internet. +2. **From a resource inside your network that is not AuthProxy**, against the backend's **private** name. This + catches a backend that other workloads can reach. On Container Apps this is the check that matters most; + see [Container Apps](container-apps.md#isolating-backends). + +Finally, prove the positive path too: sign in through AuthProxy and confirm the application sees you. A +backend that nothing can reach passes the test above trivially. + +--- + +## Order of work + +1. Register the Entra application and create the secret. +2. Create the network, the storage account and container, and the Key Vault with its key. +3. Deploy the backends and frontends **private** first. +4. Deploy AuthProxy with the settings above and the managed identity's role assignments. +5. Run the [isolation test](#test-that-a-backend-cannot-be-reached-directly), then sign in. diff --git a/Documentation/azure/toc.yml b/Documentation/azure/toc.yml new file mode 100644 index 00000000..52de24b5 --- /dev/null +++ b/Documentation/azure/toc.yml @@ -0,0 +1,6 @@ +- name: Overview + href: index.md +- name: App Service + href: app-service.md +- name: Container Apps + href: container-apps.md diff --git a/Documentation/index.mdx b/Documentation/index.mdx index 58758cad..22bcfe9f 100644 --- a/Documentation/index.mdx +++ b/Documentation/index.mdx @@ -72,6 +72,9 @@ Start by putting the proxy in front of a service — the configuration reference Wire AuthProxy into a .NET Aspire AppHost alongside your other services. + + Run AuthProxy on App Service or Container Apps with Microsoft Entra ID sign-in, and keep your backends reachable only through the proxy. + How Arc models identity and authorization on commands and queries — the `/.cratis/me` endpoint AuthProxy enriches from. diff --git a/Documentation/toc.yml b/Documentation/toc.yml index 9d78419f..8c1d3400 100644 --- a/Documentation/toc.yml +++ b/Documentation/toc.yml @@ -4,3 +4,5 @@ href: configuration/toc.yml - name: Aspire Hosting href: aspire/toc.yml +- name: Deploying on Azure + href: azure/toc.yml From 2cc82c8b75c6f5e8478ce925fe0536882dfc2ab7 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 16:46:20 +0200 Subject: [PATCH 2/4] Make Azure backend isolation guidance fail closed --- Documentation/azure/app-service.md | 46 +++++--- Documentation/azure/container-apps.md | 12 +- Documentation/azure/index.md | 99 ++++++++++++---- .../azure/specs/test_backend_isolation.py | 111 ++++++++++++++++++ 4 files changed, 219 insertions(+), 49 deletions(-) create mode 100644 Documentation/azure/specs/test_backend_isolation.py diff --git a/Documentation/azure/app-service.md b/Documentation/azure/app-service.md index f6230da4..476769e9 100644 --- a/Documentation/azure/app-service.md +++ b/Documentation/azure/app-service.md @@ -73,8 +73,12 @@ with a `ForwardLimit` of `1` for the App Service front end alone. Two consequences: -- Those ranges include your own virtual network. A resource in it that can reach AuthProxy directly can - therefore also set `X-Forwarded-For`. With the access restrictions below, only the front ends can. +- In multitenant App Service, only the platform front ends connect to the container. The private ranges + also include your own virtual network, so do not assume that boundary in an App Service Environment or + when AuthProxy has a private endpoint: a peer that can reach the worker directly may supply trusted + forwarded headers. Restrict access to the **AuthProxy app**, not just its backends. Use access restrictions + for its public ingress; private-endpoint traffic bypasses those rules and needs network controls such as + a subnet network security group with private-endpoint network policies enabled. - Put Azure Front Door or Application Gateway in front of the app and you have a second hop. Raise `ForwardLimit` to `2` and add that service's own address ranges to `TrustedProxies`, because every hop that is consumed has to be trusted. For Front Door, also allow only your instance to reach AuthProxy, with an @@ -117,22 +121,23 @@ stream as normal on the client. See Each backend and frontend is its own App Service app, and **each one must refuse everything except AuthProxy.** There are two ways; pick one per app. The first is the stronger. +**Prerequisite for both alternatives:** put AuthProxy on a **virtual network integration subnet** before +configuring any backend. This is outbound-only; it does not make AuthProxy private. + +```bash +az webapp vnet-integration add --resource-group $rg --name $authproxy \ + --vnet $vnet --subnet integration-subnet +``` + +The subnet must be delegated to `Microsoft.Web/serverFarms` (the command applies the delegation if it is +missing) and cannot be the subnet that holds a private endpoint. + ### Private endpoint (recommended) The backend gets a private IP in your virtual network and no public address. AuthProxy reaches it over its own virtual network integration. -1. Put AuthProxy on a **virtual network integration subnet**. This is outbound-only; it does not make - AuthProxy private. - - ```bash - az webapp vnet-integration add --resource-group $rg --name $authproxy \ - --vnet $vnet --subnet integration-subnet - ``` - - The subnet must be delegated to `Microsoft.Web/serverFarms` (the command applies the delegation if it - is missing) and cannot be the subnet that holds the private endpoint. -2. Create a **private endpoint** for each backend in a separate subnet, and a private DNS zone +1. Create a **private endpoint** for each backend in a separate subnet, and a private DNS zone `privatelink.azurewebsites.net` linked to the virtual network, so that `$backend.azurewebsites.net` resolves to the private address from inside the network: @@ -150,7 +155,7 @@ own virtual network integration. --endpoint-name $backend-pe --name default \ --private-dns-zone privatelink.azurewebsites.net --zone-name privatelink.azurewebsites.net ``` -3. **Disable public network access** on the backend. This is the step that makes it isolated; without it the +2. **Disable public network access** on the backend. This is the step that makes it isolated; without it the backend keeps answering on its public name as well. ```bash @@ -197,9 +202,12 @@ that subnet is allowed through, so keep untrusted apps off it. ### Prove it -Run the [direct-access test](index.md#test-that-a-backend-cannot-be-reached-directly) against -`https://$backend.azurewebsites.net/.cratis/me` from a machine outside the network: it must be refused. Run it -again from a virtual machine or app in the virtual network that is not AuthProxy. With access restrictions it -is refused. With a private endpoint it is refused only if you restricted the endpoint as described above; -otherwise it is answered, and so would be an attacker's request from that network. Then sign in through +First confirm that your chosen endpoint returns `2xx` for the probe's forged principal from an allowed +location, as the [direct-access test](index.md#test-that-a-backend-cannot-be-reached-directly) explains. Then +run it against that endpoint at `https://$backend.azurewebsites.net/` from a machine outside the network: +it must be refused. Identify any expected platform HTTP refusal with both its status and distinctive body +marker; an application `403` or `404` is not a pass. Run it again from a virtual machine or app in the virtual +network that is not AuthProxy. With access restrictions it is refused. With a private endpoint it is refused +only if you restricted the endpoint as described above; otherwise it is answered, and so would be an +attacker's request from that network. Then sign in through AuthProxy and confirm the application works. diff --git a/Documentation/azure/container-apps.md b/Documentation/azure/container-apps.md index a40b90c1..1eca6cec 100644 --- a/Documentation/azure/container-apps.md +++ b/Documentation/azure/container-apps.md @@ -164,10 +164,14 @@ membership irrelevant, is tracked in [Cratis/AuthProxy#139](https://github.com/C ### Prove it -Run the [direct-access test](index.md#test-that-a-backend-cannot-be-reached-directly) twice: - -- Against `https://portal-api.internal...azurecontainerapps.io/.cratis/me` from outside - the environment, such as your pipeline runner. It must be refused (`404`). +First confirm that your chosen endpoint returns `2xx` for the probe's forged principal from an allowed +location, as the [direct-access test](index.md#test-that-a-backend-cannot-be-reached-directly) explains. Then +run it twice: + +- Against that endpoint at `https://portal-api.internal...azurecontainerapps.io/` + from outside the environment, such as your pipeline runner. It must be refused. To accept the platform's + `404`, configure both that status and a distinctive platform-refusal body marker; an application's own + `404` must not pass. - Against the same address from a **container app in the same environment that is not AuthProxy**. It will be answered. That is not a bug in your configuration; it is the boundary described above. If that second app is not supposed to be able to impersonate users, it belongs in another environment. diff --git a/Documentation/azure/index.md b/Documentation/azure/index.md index d4455eb1..efcc8f35 100644 --- a/Documentation/azure/index.md +++ b/Documentation/azure/index.md @@ -29,8 +29,9 @@ that did not, except by the network it arrived on. See So a backend that anyone other than AuthProxy can open a connection to is a backend anyone can impersonate any user on, by sending the header. The platform features that prevent this differ, and getting them wrong is easy because the failure is silent: the application works perfectly, for everybody, including the person -forging the header. Each platform page ends with an isolation recipe and the [direct-access -test](#test-that-a-backend-cannot-be-reached-directly) below proves it. +forging the header. Each platform page ends with an isolation recipe. The [direct-access +test](#test-that-a-backend-cannot-be-reached-directly) below checks the routes you probe; it does not prove +that no other route to the backend exists. --- @@ -141,12 +142,14 @@ Cratis__AuthProxy__Authorization__RequiredClaims__0__AnyOf__0=AuthProxy.User ## Point AuthProxy at your services -Name each backend and frontend by the address AuthProxy reaches it on. These are the private addresses of -the [isolation recipe](#test-that-a-backend-cannot-be-reached-directly), not public ones: +Name each backend and frontend by the address AuthProxy reaches it on from inside your network, following +the isolation recipe for [App Service](app-service.md#isolating-backends) or +[Container Apps](container-apps.md#isolating-backends). On App Service this can be the normal +`*.azurewebsites.net` name, resolved privately or protected by service-endpoint access restrictions: ```bash -Cratis__AuthProxy__Services__portal__Backend__BaseUrl=https://portal-api./ -Cratis__AuthProxy__Services__portal__Frontend__BaseUrl=https://portal-web./ +Cratis__AuthProxy__Services__portal__Backend__BaseUrl=https:/// +Cratis__AuthProxy__Services__portal__Frontend__BaseUrl=https:/// ``` With one service, `/api/**` goes to the backend and everything else to the frontend. With several, a @@ -216,40 +219,84 @@ AuthProxy. AuthProxy does the sign-in; leave the platform feature off for the Au ## Test that a backend cannot be reached directly -The recipes on the platform pages are configuration. This is the proof, and it belongs in your pipeline so a -later change that opens a backend fails the build. +Put this check in your pipeline to detect unexpected responses on the routes you probe. It sends a request +that **claims to be a signed-in user** straight to the backend's own address. It fails on every HTTP response +unless you explicitly identify an expected platform refusal, and on indeterminate transport failures. -It sends a request that **claims to be a signed-in user** straight to the backend's own address, and fails if -the backend serves it. Aim it at an endpoint that answers `200` when it trusts a principal. `/.cratis/me` is -the one AuthProxy itself calls on a backend: +Choose an endpoint that returns `2xx` for **any authenticated principal**, including the exact `forged` +fixture below. Arc's `/.cratis/me` qualifies with its default identity-details provider, or a custom provider +that accepts any user. A custom provider that denies the forged user returns an application `403`; an app +without that endpoint returns `404`. Neither proves isolation. Before using the probe, send the same three +headers and principal below from an allowed location inside the trust boundary and confirm that the chosen +endpoint returns `2xx`. Also check the endpoint through AuthProxy with a signed-in user. + +Save this as `assert-backend-isolated.sh` (requires Bash, curl, base64 and grep): ```bash #!/usr/bin/env bash -# Usage: ./assert-backend-isolated.sh https://portal-api.example.net/.cratis/me [...] -# Exits non-zero if any backend answers a request that did not come through AuthProxy. +# Usage: ./assert-backend-isolated.sh URL ['URL|403|platform-specific-body-marker'] [...] +# Only DNS/connection refusal, or an explicitly matched platform refusal, passes. +set -u +if [ "$#" -eq 0 ]; then + echo "Usage: $0 URL ['URL|403|platform-specific-body-marker'] [...]" >&2 + exit 1 +fi +body=$(mktemp) || exit 1 +trap 'rm -f "$body"' EXIT fail=0 principal=$(printf '{"userId":"forged","userDetails":"forged","claims":[]}' | base64 | tr -d '\n') -for url in "$@"; do - code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 \ +for target in "$@"; do + url=$target + expected=000 + marker= + if [[ "$target" == *'|'* ]]; then + url=${target%%|*} + refusal=${target#*|} + expected=${refusal%%|*} + marker=${refusal#*|} + if [[ "$refusal" != *'|'* || -z "$marker" || "$marker" == *'|'* ]] || + [[ "$expected" != 401 && "$expected" != 403 && "$expected" != 404 ]]; then + echo "FAIL invalid target: $target" >&2 + fail=1 + continue + fi + fi + + curl_exit=0 + code=$(curl -sS -o "$body" -w '%{http_code}' --max-time 15 \ -H "x-ms-client-principal: ${principal}" \ -H "x-ms-client-principal-id: forged" \ -H "x-ms-client-principal-name: forged" \ - "$url" 2>/dev/null) || code=000 - - case "$code" in - 2??|3??) echo "FAIL $url answered $code to a forged identity"; fail=1 ;; - *) echo "ok $url -> $code" ;; - esac + -- "$url") || curl_exit=$? + + if [[ "$code" == 000 && ( "$curl_exit" == 6 || "$curl_exit" == 7 ) ]]; then + echo "ok $url -> no connection (curl $curl_exit)" + elif [[ "$curl_exit" == 0 && "$code" == "$expected" && "$expected" != 000 ]] && + grep -Fq -- "$marker" "$body"; then + echo "ok $url -> expected platform refusal $code" + else + echo "FAIL $url -> HTTP $code, curl $curl_exit; no expected platform refusal" >&2 + fail=1 + fi done -exit $fail +exit "$fail" ``` -`000` means the request never connected: DNS does not resolve, the address is private, or the connection was -refused. That is a pass. A `401`, `403` or `404` is a pass **only** if it is the platform refusing the -caller; a `404` for a path that does not exist proves nothing, which is why the script needs an address that -exists. +A plain URL passes only when curl reports `000` with DNS resolution failure (`6`) or connection failure +(`7`). `000` alone is not proof that a request never connected: timeouts, TLS failures and other transport +errors fail the check. If curl received an HTTP status before a transfer failed, that status is preserved and +the check fails. + +To accept a platform-generated `401`, `403` or `404`, pass a quoted argument such as +`'https://portal-api.example.net/.cratis/me|403|'`. Replace the marker with a +literal, distinctive substring of the platform's refusal body, established on your deployment; do not use a +generic word such as `Forbidden` or `Not Found`, or an application error message. Both the status and marker +must match, and the transfer must complete. There is no universal marker across Azure platforms. If you cannot +distinguish the platform refusal from an application response, leave that response failing and investigate; +do not whitelist the status alone. A matching response checks only that observed refusal, not every possible +route or identity. Run it from two places: diff --git a/Documentation/azure/specs/test_backend_isolation.py b/Documentation/azure/specs/test_backend_isolation.py new file mode 100644 index 00000000..fc6c0c64 --- /dev/null +++ b/Documentation/azure/specs/test_backend_isolation.py @@ -0,0 +1,111 @@ +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. + +"""Regression specs for the actual Bash probe displayed in the Azure guide. + +Run: python3 -B -m unittest discover -s Documentation/azure/specs -v +""" + +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import unittest + + +class BackendIsolationSpecs(unittest.TestCase): + def setUp(self): + self.workspace = tempfile.TemporaryDirectory() + self.addCleanup(self.workspace.cleanup) + self.root = Path(self.workspace.name) + guide = (Path(__file__).parents[1] / "index.md").read_text() + script = re.search(r"```bash\n(#!/usr/bin/env bash\n.*?)\n```", guide, re.S) + self.assertIsNotNone(script) + self.probe = self.root / "probe.sh" + self.probe.write_text(script.group(1) + "\n") + curl = self.root / "curl" + curl.write_text( + f"#!{sys.executable}\n" + "import os, pathlib, sys\n" + "args = sys.argv[1:]\n" + "pathlib.Path(args[args.index('-o') + 1]).write_text(os.environ['PROBE_BODY'])\n" + "sys.stdout.write(os.environ['PROBE_STATUS'])\n" + "sys.exit(int(os.environ['PROBE_EXIT']))\n" + ) + curl.chmod(0o700) + + def run_probe(self, targets=None, status="000", exit_code=0, body=""): + if targets is None: + targets = ["https://backend.example/probe"] + env = dict(os.environ, PATH=f"{self.root}:{os.environ['PATH']}", + PROBE_STATUS=status, PROBE_EXIT=str(exit_code), PROBE_BODY=body) + return subprocess.run(["bash", str(self.probe), *targets], env=env, + text=True, capture_output=True, timeout=5) + + def test_no_targets_fails(self): + self.assertNotEqual(self.run_probe(targets=[]).returncode, 0) + + def test_dns_and_connection_failures_pass_without_http_response(self): + for exit_code in (6, 7): + with self.subTest(exit_code=exit_code): + self.assertEqual(self.run_probe(exit_code=exit_code).returncode, 0) + + def test_indeterminate_transport_failures_fail(self): + for exit_code in (0, 5, 18, 28, 35, 60): + with self.subTest(exit_code=exit_code): + self.assertNotEqual(self.run_probe(exit_code=exit_code).returncode, 0) + + def test_every_http_response_fails_by_default(self): + for status in ("200", "204", "302", "401", "403", "404", "500"): + with self.subTest(status=status): + self.assertNotEqual(self.run_probe(status=status).returncode, 0) + + def test_status_is_preserved_when_body_times_out(self): + result = self.run_probe(status="200", exit_code=28) + self.assertNotEqual(result.returncode, 0) + self.assertIn("HTTP 200, curl 28", result.stderr) + + def test_http_response_cannot_pass_as_connection_failure(self): + self.assertNotEqual(self.run_probe(status="200", exit_code=7).returncode, 0) + + def test_expected_status_and_literal_platform_marker_pass(self): + for status in ("401", "403", "404"): + with self.subTest(status=status): + target = f"https://backend.example/probe|{status}|platform [denial]" + result = self.run_probe([target], status=status, body="

platform [denial]

") + self.assertEqual(result.returncode, 0, result.stderr) + + def test_application_denial_does_not_match_platform_marker(self): + target = "https://backend.example/probe|403|platform denial" + result = self.run_probe([target], status="403", body="Application: Forbidden") + self.assertNotEqual(result.returncode, 0) + + def test_platform_marker_with_wrong_status_fails(self): + target = "https://backend.example/probe|403|platform denial" + self.assertNotEqual(self.run_probe([target], status="404", body="platform denial").returncode, 0) + + def test_matching_denial_with_incomplete_transfer_fails(self): + target = "https://backend.example/probe|403|platform denial" + result = self.run_probe([target], status="403", exit_code=28, body="platform denial") + self.assertNotEqual(result.returncode, 0) + self.assertIn("HTTP 403, curl 28", result.stderr) + + def test_malformed_or_unsafe_expectations_fail(self): + for suffix in ("403", "403|", "000|marker", "200|marker", "500|marker", "403|a|b"): + with self.subTest(suffix=suffix): + result = self.run_probe([f"https://backend.example/probe|{suffix}"], exit_code=7) + self.assertNotEqual(result.returncode, 0) + + def test_one_failing_target_fails_the_whole_probe(self): + targets = ["https://first.example/probe|403|platform denial", "https://second.example/probe"] + self.assertNotEqual(self.run_probe(targets, status="403", body="platform denial").returncode, 0) + + def test_one_invalid_target_is_not_hidden_by_a_later_pass(self): + targets = ["https://first.example/probe|403", "https://second.example/probe"] + self.assertNotEqual(self.run_probe(targets, exit_code=7).returncode, 0) + + +if __name__ == "__main__": + unittest.main() From 80e1f4773ed9ab54ede4d7e0311211dcad3614c0 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 18:58:28 +0200 Subject: [PATCH 3/4] Run Azure documentation regression specs in CI --- .github/workflows/azure-docs-specs.yml | 34 ++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/azure-docs-specs.yml diff --git a/.github/workflows/azure-docs-specs.yml b/.github/workflows/azure-docs-specs.yml new file mode 100644 index 00000000..ff0e3c33 --- /dev/null +++ b/.github/workflows/azure-docs-specs.yml @@ -0,0 +1,34 @@ +name: Azure Documentation Specs + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + workflow_dispatch: + pull_request: + paths: + - "Documentation/azure/**" + - ".github/workflows/azure-docs-specs.yml" + push: + branches: + - main + paths: + - "Documentation/azure/**" + - ".github/workflows/azure-docs-specs.yml" + +jobs: + azure-docs-specs: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Run Azure documentation specs + run: python3 -B -m unittest discover -s Documentation/azure/specs -v From e0ad1f0b11b367c1c1c79fbe673e7a680180822d Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 1 Oct 2026 20:28:04 +0200 Subject: [PATCH 4/4] Qualify Container Apps forwarded-header trust guidance --- Documentation/azure/container-apps.md | 32 ++++++++++- Documentation/azure/index.md | 14 +++-- .../specs/test_trusted_proxy_guidance.py | 53 +++++++++++++++++++ 3 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 Documentation/azure/specs/test_trusted_proxy_guidance.py diff --git a/Documentation/azure/container-apps.md b/Documentation/azure/container-apps.md index 1eca6cec..a2c45d03 100644 --- a/Documentation/azure/container-apps.md +++ b/Documentation/azure/container-apps.md @@ -84,8 +84,36 @@ The address AuthProxy sees as the peer is the environment's ingress proxy, and t range for it. The settings above therefore use `Mode=TrustAny`, which is right **only because nothing other than the platform's ingress can connect to the container's port 8080**: the platform routes every request between apps through its proxy layer, not directly to a replica. If you also run -something that can open a connection straight to the replica, name the real peers with `TrustedProxies` -instead. See [Trusted proxies](../configuration/trusted-proxies.md#modes). +something that can open a connection straight to the replica, switch to `Mode=Configured` and name the real +peers with `TrustedProxies` instead. `TrustAny` ignores `TrustedProxies` even when you populate it. +See [Trusted proxies](../configuration/trusted-proxies.md#modes). + +### Adding an upstream proxy + +Putting Azure Front Door or Application Gateway in front of AuthProxy does not make the Container Apps +origin private. With external ingress, a caller can still bypass that upstream proxy and call the ACA origin +hostname directly. **Keep `ForwardLimit=1` while that origin is publicly reachable in `Mode=TrustAny`.** +If you raise it to `2`, a direct caller can send `X-Forwarded-For: 203.0.113.123`; Container Apps appends the +real caller address, and AuthProxy consumes both entries and records the forged address in sign-in +notifications. Adding upstream ranges to `TrustedProxies` cannot prevent this in `TrustAny` mode. + +Before increasing the limit, choose one of these boundaries: + +- Restrict access to AuthProxy's origin to the intended upstream proxy, including any routes from other apps + in the environment. Use a network restriction that prevents bypass; an upstream hostname alone is not an + access restriction. Then set the limit to the number of verified forwarded hops. +- Use `Mode=Configured` with verified trusted hops in `TrustedProxies`, including the actual Container Apps + ingress peer and the upstream proxy. Container Apps publishes no ingress peer range, so do not guess one + or use a catch-all range. If you cannot verify every trusted hop, keep `ForwardLimit=1`. + +**Require a direct-origin spoofed-header check before increasing `ForwardLimit`:** from outside the intended +upstream proxy, send `X-Forwarded-For: 203.0.113.123` directly to AuthProxy's ACA origin hostname, not the +Front Door or Application Gateway hostname. With an origin restriction, the platform must refuse that request +before it reaches AuthProxy. With `Configured` mode, complete a test sign-in carrying the spoofed header and +confirm that the sign-in notification records the actual caller address, never `203.0.113.123`. Also confirm +that a normal sign-in through the upstream proxy records the real client address. Test the proposed higher +limit in a non-production deployment with the same boundary before applying it in production; do not increase +it if the check fails or the recorded address cannot be observed. ### Health probes diff --git a/Documentation/azure/index.md b/Documentation/azure/index.md index efcc8f35..b5cad4e3 100644 --- a/Documentation/azure/index.md +++ b/Documentation/azure/index.md @@ -172,9 +172,17 @@ Cratis__AuthProxy__Ingress__TrustedProxies__0=... Cratis__AuthProxy__Ingress__ForwardLimit=1 ``` -The values differ per platform and are given on each platform's page. Add one to `ForwardLimit`, and trust -the extra hop, for every proxy you put in front of the platform's own ingress, such as Azure Front Door or -Application Gateway. See [Trusted proxies](../configuration/trusted-proxies.md). Do **not** set +The values differ per platform and are given on each platform's page. In `Configured` mode, verify and trust +every hop before adding one to `ForwardLimit` for a proxy in front of the platform's own ingress, such as +Azure Front Door or Application Gateway. Restrict the origin to the intended upstream proxy so callers cannot +bypass it. + +**For the Container Apps recipe's `Mode=TrustAny`, keep `ForwardLimit=1` unless access to AuthProxy's origin +is restricted to the intended upstream proxy.** Adding upstream ranges to `TrustedProxies` does not help: +`TrustAny` ignores that list. Alternatively, use `Configured` mode with verified trusted hops, including the +platform ingress peer, before increasing the limit. In either case, complete the +[direct-origin spoofed-header check](container-apps.md#adding-an-upstream-proxy) before increasing it. +See [Trusted proxies](../configuration/trusted-proxies.md). Do **not** set `ASPNETCORE_FORWARDEDHEADERS_ENABLED`. ### Data Protection keys diff --git a/Documentation/azure/specs/test_trusted_proxy_guidance.py b/Documentation/azure/specs/test_trusted_proxy_guidance.py new file mode 100644 index 00000000..b38785a0 --- /dev/null +++ b/Documentation/azure/specs/test_trusted_proxy_guidance.py @@ -0,0 +1,53 @@ +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. + +"""Guard the Container Apps recipe's documented forwarded-header trust boundary.""" + +from pathlib import Path +import re +import unittest + + +class TrustedProxyGuidanceSpecs(unittest.TestCase): + @classmethod + def setUpClass(cls): + root = Path(__file__).parents[1] + cls.overview = (root / "index.md").read_text() + cls.container_apps = (root / "container-apps.md").read_text() + cls.upstream = cls.container_apps.split("### Adding an upstream proxy\n", 1)[1].split("### Health probes", 1)[0] + cls.upstream = " ".join(cls.upstream.split()) + + def test_public_container_apps_recipe_consumes_only_the_platform_supplied_address(self): + recipe = re.search(r"```bash\n(az containerapp create.*?)\n```", self.container_apps, re.S) + self.assertIsNotNone(recipe) + self.assertIn("--ingress external", recipe.group(1)) + self.assertIn("Cratis__AuthProxy__Ingress__Mode=TrustAny", recipe.group(1)) + self.assertIn("Cratis__AuthProxy__Ingress__ForwardLimit=1", recipe.group(1)) + + def test_overview_qualifies_extra_hops_for_the_trust_any_recipe(self): + guidance = " ".join(self.overview.split()) + self.assertIn("keep `ForwardLimit=1` unless access to AuthProxy's origin is restricted to the intended upstream proxy", guidance) + self.assertIn("`TrustAny` ignores that list", guidance) + self.assertIn("use `Configured` mode with verified trusted hops", guidance) + self.assertIn("[direct-origin spoofed-header check](container-apps.md#adding-an-upstream-proxy)", guidance) + + def test_increasing_the_limit_requires_an_origin_boundary_or_verified_configured_hops(self): + self.assertIn("Keep `ForwardLimit=1` while that origin is publicly reachable in `Mode=TrustAny`", self.upstream) + self.assertIn("Restrict access to AuthProxy's origin to the intended upstream proxy", self.upstream) + self.assertIn("including any routes from other apps in the environment", self.upstream) + self.assertIn("Use `Mode=Configured` with verified trusted hops in `TrustedProxies`", self.upstream) + self.assertIn("including the actual Container Apps ingress peer and the upstream proxy", self.upstream) + self.assertIn("Adding upstream ranges to `TrustedProxies` cannot prevent this in `TrustAny` mode", self.upstream) + + def test_direct_origin_spoof_check_is_required_before_a_higher_limit_is_deployed(self): + self.assertIn("Require a direct-origin spoofed-header check before increasing `ForwardLimit`", self.upstream) + self.assertIn("send `X-Forwarded-For: 203.0.113.123` directly to AuthProxy's ACA origin hostname", self.upstream) + self.assertIn("the platform must refuse that request before it reaches AuthProxy", self.upstream) + self.assertIn("records the actual caller address, never `203.0.113.123`", self.upstream) + self.assertIn("a normal sign-in through the upstream proxy records the real client address", self.upstream) + self.assertIn("Test the proposed higher limit in a non-production deployment with the same boundary", self.upstream) + self.assertIn("do not increase it if the check fails or the recorded address cannot be observed", self.upstream) + + +if __name__ == "__main__": + unittest.main()