Skip to content

Commit e92c2d0

Browse files
feat: restrict backend Container App to private access in WAF deployment
When enablePrivateNetworking (WAF mode) is active: Infrastructure: - Set Container App Environment to internal with public access disabled - Create private DNS zone for the CAE default domain linked to VNet - Add wildcard A record pointing to CAE static IP for DNS resolution - Frontend App Service gets PROXY_API_REQUESTS=true env var Frontend: - FastAPI server proxies /api/* requests to backend via httpx over VNet - /config endpoint returns same-origin /api URL in WAF mode - Added httpx dependency Post-deploy scripts: - Detect internal ingress / IP restrictions / PROXY_API_REQUESTS - Route API calls through frontend App Service proxy Non-WAF deployments remain unchanged. Resolves AB#39249 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0d2132a commit e92c2d0

6 files changed

Lines changed: 154 additions & 10 deletions

File tree

infra/main.bicep

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1142,8 +1142,8 @@ module containerAppEnvironment 'br/public:avm/res/app/managed-environment:0.11.2
11421142
tags: tags
11431143
enableTelemetry: enableTelemetry
11441144
// WAF aligned configuration for Private Networking
1145-
publicNetworkAccess: 'Enabled' // Always enabling the publicNetworkAccess for Container App Environment
1146-
internal: false // Must be false when publicNetworkAccess is'Enabled'
1145+
publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled'
1146+
internal: enablePrivateNetworking ? true : false
11471147
infrastructureSubnetResourceId: enablePrivateNetworking ? virtualNetwork.?outputs.?containerSubnetResourceId : null
11481148
// WAF aligned configuration for Monitoring
11491149
appLogsConfiguration: enableMonitoring
@@ -1177,6 +1177,32 @@ module containerAppEnvironment 'br/public:avm/res/app/managed-environment:0.11.2
11771177
}
11781178
}
11791179

1180+
// ========== Private DNS Zone for internal Container App Environment ========== //
1181+
// When the CAE is internal, its FQDN is only resolvable within the VNet via this DNS zone.
1182+
module caeDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (enablePrivateNetworking) {
1183+
name: 'avm.res.network.private-dns-zone.cae'
1184+
params: {
1185+
name: containerAppEnvironment.outputs.defaultDomain
1186+
tags: tags
1187+
enableTelemetry: enableTelemetry
1188+
a: [
1189+
{
1190+
name: '*'
1191+
aRecords: [
1192+
{ ipv4Address: containerAppEnvironment.outputs.staticIp }
1193+
]
1194+
ttl: 300
1195+
}
1196+
]
1197+
virtualNetworkLinks: [
1198+
{
1199+
name: take('vnetlink-${virtualNetworkResourceName}-cae', 80)
1200+
virtualNetworkResourceId: virtualNetwork!.outputs.resourceId
1201+
}
1202+
]
1203+
}
1204+
}
1205+
11801206
// ========== Backend Container App Service ========== //
11811207
// WAF best practices for container apps: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/azure-container-apps
11821208
// PSRule for Container App: https://azure.github.io/PSRule.Rules.Azure/en/rules/resource/#container-app
@@ -1535,6 +1561,7 @@ module webSite 'modules/web-sites.bicep' = {
15351561
WEBSITES_CONTAINER_START_TIME_LIMIT: '1800' // 30 minutes, adjust as needed
15361562
BACKEND_API_URL: 'https://${containerApp.outputs.fqdn}'
15371563
AUTH_ENABLED: 'false'
1564+
PROXY_API_REQUESTS: enablePrivateNetworking ? 'true' : 'false'
15381565
}
15391566
// WAF aligned configuration for Monitoring
15401567
applicationInsightResourceId: enableMonitoring ? applicationInsights!.outputs.resourceId : null

infra/main_custom.bicep

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1140,8 +1140,8 @@ module containerAppEnvironment 'br/public:avm/res/app/managed-environment:0.11.2
11401140
tags: tags
11411141
enableTelemetry: enableTelemetry
11421142
// WAF aligned configuration for Private Networking
1143-
publicNetworkAccess: 'Enabled' // Always enabling the publicNetworkAccess for Container App Environment
1144-
internal: false // Must be false when publicNetworkAccess is'Enabled'
1143+
publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled'
1144+
internal: enablePrivateNetworking ? true : false
11451145
infrastructureSubnetResourceId: enablePrivateNetworking ? virtualNetwork.?outputs.?containerSubnetResourceId : null
11461146
// WAF aligned configuration for Monitoring
11471147
appLogsConfiguration: enableMonitoring
@@ -1175,6 +1175,32 @@ module containerAppEnvironment 'br/public:avm/res/app/managed-environment:0.11.2
11751175
}
11761176
}
11771177

1178+
// ========== Private DNS Zone for internal Container App Environment ========== //
1179+
// When the CAE is internal, its FQDN is only resolvable within the VNet via this DNS zone.
1180+
module caeDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (enablePrivateNetworking) {
1181+
name: 'avm.res.network.private-dns-zone.cae'
1182+
params: {
1183+
name: containerAppEnvironment.outputs.defaultDomain
1184+
tags: tags
1185+
enableTelemetry: enableTelemetry
1186+
a: [
1187+
{
1188+
name: '*'
1189+
aRecords: [
1190+
{ ipv4Address: containerAppEnvironment.outputs.staticIp }
1191+
]
1192+
ttl: 300
1193+
}
1194+
]
1195+
virtualNetworkLinks: [
1196+
{
1197+
name: take('vnetlink-${virtualNetworkResourceName}-cae', 80)
1198+
virtualNetworkResourceId: virtualNetwork!.outputs.resourceId
1199+
}
1200+
]
1201+
}
1202+
}
1203+
11781204
// ========== Container Registry ========== //
11791205
module containerRegistry 'br/public:avm/res/container-registry/registry:0.9.1' = {
11801206
name: 'registryDeployment'
@@ -1587,6 +1613,7 @@ module webSite 'modules/web-sites.bicep' = {
15871613
BACKEND_API_URL: 'https://${containerApp.outputs.fqdn}'
15881614
AUTH_ENABLED: 'false'
15891615
ENABLE_ORYX_BUILD: 'True'
1616+
PROXY_API_REQUESTS: enablePrivateNetworking ? 'true' : 'false'
15901617
}
15911618
// WAF aligned configuration for Monitoring
15921619
applicationInsightResourceId: enableMonitoring ? applicationInsights!.outputs.resourceId : null

infra/scripts/Selecting-Team-Config-And-Data.ps1

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,27 @@ do {
445445
}
446446
} while (-not $useCaseValid)
447447

448+
# WAF/Private Networking: If the Container App has IP restrictions or internal ingress,
449+
# the backendUrl is not reachable from the developer's machine. Route through the frontend
450+
# App Service proxy instead, which is public and forwards /api/* to the private backend over VNet.
451+
$solutionSuffix = az group show --name $ResourceGroup --query "tags.SolutionSuffix" -o tsv 2>$null
452+
if ($solutionSuffix) {
453+
$containerAppName = "ca-$solutionSuffix"
454+
$isExternal = az containerapp show --name $containerAppName --resource-group $ResourceGroup `
455+
--query "properties.configuration.ingress.external" -o tsv 2>$null
456+
$hasIpRestrictions = az containerapp show --name $containerAppName --resource-group $ResourceGroup `
457+
--query "length(properties.configuration.ingress.ipSecurityRestrictions || ``[]``)" -o tsv 2>$null
458+
$proxyEnabled = az webapp config appsettings list --name "app-$solutionSuffix" --resource-group $ResourceGroup `
459+
--query "[?name=='PROXY_API_REQUESTS'].value" -o tsv 2>$null
460+
if ($isExternal -eq "false" -or [int]$hasIpRestrictions -gt 0 -or $proxyEnabled -eq "true") {
461+
$frontendHostname = "app-$solutionSuffix"
462+
$frontendUrl = "https://${frontendHostname}.azurewebsites.net"
463+
Write-Host "Private networking detected: Container App has restricted access."
464+
Write-Host "Routing API calls through frontend App Service: $frontendUrl"
465+
$script:backendUrl = $frontendUrl
466+
}
467+
}
468+
448469
Write-Host ""
449470
Write-Host "==============================================="
450471
Write-Host "Values to be used:"

infra/scripts/selecting_team_config_and_data.sh

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,27 @@ while [[ "$useCaseValid" != true ]]; do
453453
fi
454454
done
455455

456+
# WAF/Private Networking: If the Container App has IP restrictions or internal ingress,
457+
# the backendUrl is not reachable from the developer's machine. Route through the frontend
458+
# App Service proxy instead, which is public and forwards /api/* to the private backend over VNet.
459+
solutionSuffix=$(az group show --name "$ResourceGroup" --query "tags.SolutionSuffix" -o tsv 2>/dev/null)
460+
if [[ -n "$solutionSuffix" ]]; then
461+
containerAppName="ca-${solutionSuffix}"
462+
isExternal=$(az containerapp show --name "$containerAppName" --resource-group "$ResourceGroup" \
463+
--query "properties.configuration.ingress.external" -o tsv 2>/dev/null)
464+
hasIpRestrictions=$(az containerapp show --name "$containerAppName" --resource-group "$ResourceGroup" \
465+
--query "length(properties.configuration.ingress.ipSecurityRestrictions || \`[]\`)" -o tsv 2>/dev/null)
466+
proxyEnabled=$(az webapp config appsettings list --name "app-${solutionSuffix}" --resource-group "$ResourceGroup" \
467+
--query "[?name=='PROXY_API_REQUESTS'].value" -o tsv 2>/dev/null)
468+
if [[ "$isExternal" == "false" ]] || [[ "$hasIpRestrictions" -gt 0 ]] || [[ "$proxyEnabled" == "true" ]]; then
469+
frontendHostname="app-${solutionSuffix}"
470+
frontendUrl="https://${frontendHostname}.azurewebsites.net"
471+
echo "Private networking detected: Container App has restricted access."
472+
echo "Routing API calls through frontend App Service: $frontendUrl"
473+
backendUrl="$frontendUrl"
474+
fi
475+
fi
476+
456477
echo ""
457478
echo "==============================================="
458479
echo "Values to be used:"

src/App/frontend_server.py

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import os
22

3+
import httpx
34
import uvicorn
45
from dotenv import load_dotenv
5-
from fastapi import FastAPI
6+
from fastapi import FastAPI, Request
67
from fastapi.middleware.cors import CORSMiddleware
7-
from fastapi.responses import FileResponse
8+
from fastapi.responses import FileResponse, StreamingResponse
89
from fastapi.staticfiles import StaticFiles
910

1011
# Load environment variables from .env file
@@ -23,6 +24,10 @@
2324
BUILD_DIR = os.path.join(os.path.dirname(__file__), "build")
2425
INDEX_HTML = os.path.join(BUILD_DIR, "index.html")
2526

27+
# Proxy configuration for WAF/private networking deployments
28+
PROXY_API_REQUESTS = os.getenv("PROXY_API_REQUESTS", "false").lower() == "true"
29+
BACKEND_API_URL = os.getenv("BACKEND_API_URL", "http://localhost:8000")
30+
2631
# Serve static files from build directory
2732
app.mount(
2833
"/assets", StaticFiles(directory=os.path.join(BUILD_DIR, "assets")), name="assets"
@@ -36,17 +41,59 @@ async def serve_index():
3641

3742
@app.get("/config")
3843
async def get_config():
39-
backend_url = os.getenv("BACKEND_API_URL", "http://localhost:8000")
4044
auth_enabled = os.getenv("AUTH_ENABLED", "false")
41-
backend_url = backend_url + "/api"
45+
46+
if PROXY_API_REQUESTS:
47+
# WAF mode: frontend proxies API calls, so tell browser to use same origin
48+
api_url = "/api"
49+
else:
50+
# Non-WAF mode: browser calls backend directly
51+
backend_url = os.getenv("BACKEND_API_URL", "http://localhost:8000")
52+
api_url = backend_url + "/api"
4253

4354
config = {
44-
"API_URL": backend_url,
55+
"API_URL": api_url,
4556
"ENABLE_AUTH": auth_enabled,
4657
}
4758
return config
4859

4960

61+
@app.get("/health")
62+
async def health():
63+
return {"status": "healthy"}
64+
65+
66+
# API proxy routes for WAF/private networking deployments
67+
if PROXY_API_REQUESTS:
68+
69+
@app.api_route("/api/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
70+
async def proxy_api(request: Request, path: str):
71+
"""Proxy API requests to the private backend over VNet."""
72+
target_url = f"{BACKEND_API_URL}/api/{path}"
73+
query_string = str(request.query_params)
74+
if query_string:
75+
target_url = f"{target_url}?{query_string}"
76+
77+
headers = dict(request.headers)
78+
headers.pop("host", None)
79+
80+
body = await request.body()
81+
82+
async with httpx.AsyncClient(timeout=300.0) as client:
83+
response = await client.request(
84+
method=request.method,
85+
url=target_url,
86+
headers=headers,
87+
content=body,
88+
)
89+
90+
return StreamingResponse(
91+
iter([response.content]),
92+
status_code=response.status_code,
93+
headers=dict(response.headers),
94+
)
95+
96+
5097
@app.get("/{full_path:path}")
5198
async def serve_app(full_path: str):
5299
# Remediation: normalize and check containment before serving

src/App/requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ uvicorn[standard]
44
jinja2
55
azure-identity
66
python-dotenv
7-
python-multipart
7+
python-multipart
8+
httpx

0 commit comments

Comments
 (0)