Skip to content

Commit 22a8688

Browse files
Merge pull request #939 from microsoft/feature/39249-waf-api-private-access-clean
feat: Restrict backend Container App access in WAF deployment
2 parents 90bc18d + 3d59f09 commit 22a8688

7 files changed

Lines changed: 195 additions & 12 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/Dockerfile

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@ WORKDIR /app
3434
COPY pyproject.toml requirements.txt* uv.lock* ./
3535

3636
# Install Python dependencies using UV
37-
RUN --mount=type=cache,target=/root/.cache/uv \
38-
if [ -f "requirements.txt" ]; then \
37+
RUN if [ -f "requirements.txt" ]; then \
3938
uv pip install --system -r requirements.txt && uv pip install --system "uvicorn[standard]"; \
4039
else \
4140
uv pip install --system pyproject.toml && uv pip install --system "uvicorn[standard]"; \

src/App/frontend_server.py

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import asyncio
12
import os
23

4+
import httpx
35
import uvicorn
6+
import websockets
47
from dotenv import load_dotenv
5-
from fastapi import FastAPI
8+
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
69
from fastapi.middleware.cors import CORSMiddleware
7-
from fastapi.responses import FileResponse
10+
from fastapi.responses import FileResponse, StreamingResponse
811
from fastapi.staticfiles import StaticFiles
912

1013
# Load environment variables from .env file
@@ -23,6 +26,10 @@
2326
BUILD_DIR = os.path.join(os.path.dirname(__file__), "build")
2427
INDEX_HTML = os.path.join(BUILD_DIR, "index.html")
2528

29+
# Proxy configuration for WAF/private networking deployments
30+
PROXY_API_REQUESTS = os.getenv("PROXY_API_REQUESTS", "false").lower() == "true"
31+
BACKEND_API_URL = os.getenv("BACKEND_API_URL", "http://localhost:8000")
32+
2633
# Serve static files from build directory
2734
app.mount(
2835
"/assets", StaticFiles(directory=os.path.join(BUILD_DIR, "assets")), name="assets"
@@ -36,17 +43,96 @@ async def serve_index():
3643

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

4356
config = {
44-
"API_URL": backend_url,
57+
"API_URL": api_url,
4558
"ENABLE_AUTH": auth_enabled,
4659
}
4760
return config
4861

4962

63+
@app.get("/health")
64+
async def health():
65+
return {"status": "healthy"}
66+
67+
68+
# API proxy routes for WAF/private networking deployments
69+
if PROXY_API_REQUESTS:
70+
71+
@app.api_route("/api/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
72+
async def proxy_api(request: Request, path: str):
73+
"""Proxy API requests to the private backend over VNet."""
74+
target_url = f"{BACKEND_API_URL}/api/{path}"
75+
query_string = str(request.query_params)
76+
if query_string:
77+
target_url = f"{target_url}?{query_string}"
78+
79+
headers = dict(request.headers)
80+
headers.pop("host", None)
81+
82+
body = await request.body()
83+
84+
async with httpx.AsyncClient(timeout=300.0) as client:
85+
response = await client.request(
86+
method=request.method,
87+
url=target_url,
88+
headers=headers,
89+
content=body,
90+
)
91+
92+
return StreamingResponse(
93+
iter([response.content]),
94+
status_code=response.status_code,
95+
headers=dict(response.headers),
96+
)
97+
98+
@app.websocket("/api/{path:path}")
99+
async def proxy_websocket(websocket: WebSocket, path: str):
100+
"""Proxy WebSocket connections to the private backend over VNet."""
101+
await websocket.accept()
102+
103+
# Build the backend WebSocket URL
104+
backend_ws_url = BACKEND_API_URL.replace("https://", "wss://").replace("http://", "ws://")
105+
query_string = str(websocket.query_params)
106+
target_url = f"{backend_ws_url}/api/{path}"
107+
if query_string:
108+
target_url = f"{target_url}?{query_string}"
109+
110+
try:
111+
async with websockets.connect(target_url) as backend_ws:
112+
113+
async def forward_to_backend():
114+
try:
115+
while True:
116+
data = await websocket.receive_text()
117+
await backend_ws.send(data)
118+
except WebSocketDisconnect:
119+
await backend_ws.close()
120+
121+
async def forward_to_client():
122+
try:
123+
async for message in backend_ws:
124+
await websocket.send_text(message)
125+
except websockets.exceptions.ConnectionClosed:
126+
await websocket.close()
127+
128+
await asyncio.gather(forward_to_backend(), forward_to_client())
129+
except Exception:
130+
try:
131+
await websocket.close()
132+
except Exception:
133+
pass
134+
135+
50136
@app.get("/{full_path:path}")
51137
async def serve_app(full_path: str):
52138
# Remediation: normalize and check containment before serving

src/App/requirements.txt

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

0 commit comments

Comments
 (0)