Skip to content

Commit b13a2bb

Browse files
feat: restrict backend Container App access in WAF deployment
When enablePrivateNetworking (WAF mode) is active: - Frontend FastAPI server proxies /api/* requests to backend via httpx - /config endpoint returns same-origin /api URL so browser never sees the Container App URL directly - Post-deploy scripts detect WAF mode and route API calls through the frontend App Service proxy - Non-WAF deployments remain unchanged (direct backend access) Resolves AB#39249 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0d2132a commit b13a2bb

6 files changed

Lines changed: 98 additions & 6 deletions

File tree

infra/main.bicep

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1535,6 +1535,7 @@ module webSite 'modules/web-sites.bicep' = {
15351535
WEBSITES_CONTAINER_START_TIME_LIMIT: '1800' // 30 minutes, adjust as needed
15361536
BACKEND_API_URL: 'https://${containerApp.outputs.fqdn}'
15371537
AUTH_ENABLED: 'false'
1538+
PROXY_API_REQUESTS: enablePrivateNetworking ? 'true' : 'false'
15381539
}
15391540
// WAF aligned configuration for Monitoring
15401541
applicationInsightResourceId: enableMonitoring ? applicationInsights!.outputs.resourceId : null

infra/main_custom.bicep

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1587,6 +1587,7 @@ module webSite 'modules/web-sites.bicep' = {
15871587
BACKEND_API_URL: 'https://${containerApp.outputs.fqdn}'
15881588
AUTH_ENABLED: 'false'
15891589
ENABLE_ORYX_BUILD: 'True'
1590+
PROXY_API_REQUESTS: enablePrivateNetworking ? 'true' : 'false'
15901591
}
15911592
// WAF aligned configuration for Monitoring
15921593
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)