Azure Static Web Apps and Content-Security-Policy

Dec 12, 2022

Intro

I’ve been working with Azure Static Web Apps . This hosting model is neat because it’s a hosting service with built-in CDN, DNS, staging environments and deployment pipes, and much more. All packed in a free tier!

It’s really neat.

The Config

One of the features I’m excited about is the ability to configure a static site with a file that lives with your code .

I’ve got a bunch of sites that live behind reverse proxies or WAFs that handle this stuff, but they need to be individually configured, and that can sometimes be a nightmare .

SWAs solution is a nice little JSON that configures the onboard proxy that looks something like this:

{
    "route": "/img/#{somename}#.{png,jpg,gif}",
    "headers": {
        "Cache-Control": "public, max-age=#{somename}##{somename}##{somename}#, immutable"
    },
    "navigationFallback": {
        "rewrite": "/home.html"
    },
    "globalHeaders": {
        "X-Content-Type-Options": "nosniff",        
        "Referrer-Policy": "strict-origin-when-cross-origin",
        "Permissions-Policy": "geolocation=(), camera=(), microphone=(), payment=()",
        "Expect-CT": "Expect-CT: max-age=12, report-uri=\"https://somereport.some-uri.com\"",
        "Content-Security-Policy": "..."
    }
}   

Neat! Let’s save this as staticwebapp.config.json

The Problem with CSPs

CSPs can really get out of hand. Especially when you are trying to [avoid wild cards , and you have to deal with yet another third party overlay .

One of my projects has a csp with more than 50 directives:

"Content-Security-Policy": "connect-src https://somename.appcues.com https://somename.appcues.net wss://api.appcues.net https://#{somename}#.somedomain.com wss://somename.service.signalr.net somename.service.signalr.net https://dc.services.visualstudio.com/v2/track https://cdn.segment.com https://api.segment.io https://app.satismeter.com; font-src 'self' data:; frame-src https://somename.appcues.com; img-src vulpix.appcues.com res.cloudinary.com 'self' data:; script-src 'self' https://cdn.crowdin.com https://somename.appcues.com https://cdn.segment.com https://static.hotjar.com https://app.satismeter.com 'unsafe-inline'; style-src 'unsafe-inline' 'self' https://somename.appcues.com stackpath.bootstrapcdn.com; report-uri https://somename.some-uri.com/; frame-ancestors none; form-action 'self'; base-uri 'self'; default-src 'none';"

Find the typo!

Notice that these can be dynamic. Sometimes you want a specific CSP in a particular environment. You can’t push this to the repo and coast. This needs to be written during deployment!

Laying out the CSP

First, let’s lay out our CSP in a nice JSON format so we can read it. Let’s leave some wildcards to replace during deployment. The wildcards are in the format: #{keyname}#

{
    "connect-src": [
      "https://#{somename}#.appcues.com",
      "https://#{somename}#.appcues.net",
      "wss://api.appcues.net",
      "https://#{somename}#.#{domain}#",
      "wss://#{prefix}#-signalr.service.signalr.net",
      "#{prefix}#-signalr.service.signalr.net",
      "https://dc.services.visualstudio.com/v2/track",
      "https://cdn.segment.com",
      "https://api.segment.io",
      "https://app.satismeter.com"
    ],
    "font-src": [
      "'self'",
      "data:"
    ],
    "frame-src": [
      "https://#{somename}#.appcues.com"
    ],
    "img-src": [
      "vulpix.appcues.com",
      "res.cloudinary.com",
      "'self'",
      "data:"
    ],
    "script-src": [
      "'self'",
      "https://cdn.crowdin.com",
      "https://#{somename}#.appcues.com",
      "https://cdn.segment.com",
      "https://static.hotjar.com",
      "https://app.satismeter.com",
      "'unsafe-inline'"
    ],
    "style-src": [
      "'unsafe-inline'",
      "'self'",
      "https://#{somename}#.appcues.com",
      "stackpath.bootstrapcdn.com"
    ],
    "report-uri": [
      "https://#{name}#.report-uri.com/r/d/csp/enforce"
    ],
    "frame-ancestors": [
      "none"
    ],
    "form-action": [
      "'self'"
    ],
    "base-uri": [
      "'self'"
    ],
    "default-src": [
      "'none'"
    ]
  }

Way better! Save it as csp.template.json

Scripting

I wrote this little guy that reads a CSP template, replaces some parameters, and injects the CSP property to a target staticwebapp.config.json

#Build-CSP.ps1
param(
    [string]$CSPJsonPath,
    [string]$ConfigPath = "staticwebapp.config.json",
    [string]$OutputPath = $ConfigTemplate,
    [string[]]$Variables 
)

# Read the CspContentString
$CspContentString = Get-Content -Path $CSPJsonPath -Raw 
# ReplaceVariables
foreach($variable in $variables)
{
    $k,$v = $variable -split '='
    $CspContentString = $CspContentString.replace("#{$k}#",$v)
}
# Deserialize CSP
$CspContent = $CspContentString | ConvertFrom-Json

# Flatten Directives
$directives = @()
foreach ($directive in $CspContent.psobject.properties){
    $directives += ("$($directive.Name) $($directive.Value -join " ");")
}
$FlatDirectives = $directives -join " "


# Read Config
if(Test-Path $ConfigPath){
    $ConfigTemplate = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Json
}
else{
    #Make Empty Object
    $ConfigTemplate = "{globalHeaders:{}}"| ConvertFrom-Json
}

# Add CSP to global headers
$ConfigTemplate.globalHeaders | Add-Member -MemberType NoteProperty -Name  "Content-Security-Policy" -Value $FlatDirectives
#Write new config
$ConfigTemplate | ConvertTo-Json | Set-Content -Path $OutputPath

Usage

Drop the script in the directory your staticwebapp.config.json is supposed to be and place the csp.template.json there as well.

During deployment, have your deployment tool (Github actions/Azure Pipeline) run the script before the update has

Attention!

Powershell 5.1 does some funky stuff with Unicode escaping and formatting JSON. Please upgrade to PS 7+

1. CSP Only

    Build-CSP.ps1 -CSPJsonPath "csp.template.json"

outputs a staticwebapp.config.json containing:

    {
         "Content-Security-Policy":"..."
    }

2. CSP with variables

    Build-CSP.ps1 -CSPJsonPath "csp.template.json" -Variables "prefix=someprefix,domain=somedomain,name=somename"

outputs a staticwebapp.config.json with all variables replaced

3. Augmenting existing config

If a staticwebapp.config.json already exists (with a globalHeaders property)

    Build-CSP.ps1 -CSPJsonPath "csp.template.json"

adds "Content-Security-Policy" header added to the globalHeaders of the existing staticwebapp.config.json with all the.

{
    "route": "/img/#{somename}#.{png,jpg,gif}",
    "headers": {
        "Cache-Control": "public, max-age=#{somename}##{somename}##{somename}#, immutable"
    },
    "navigationFallback": {
        "rewrite": "/home.html"
    },
    "globalHeaders": {
        "X-Content-Type-Options": "nosniff",        
        "Referrer-Policy": "strict-origin-when-cross-origin",
        "Permissions-Policy": "geolocation=(), camera=(), microphone=(), payment=()",
        "Expect-CT": "Expect-CT: max-age=12, report-uri=\"https://somereport.some-uri.com\"",
        "Content-Security-Policy": "..."
    }
} 

4. Building from a template config

If a staticwebapp.config.template.json already exists

    Build-CSP.ps1 -CSPJsonPath "csp.template.json" -ConfigPath "staticwebapp.config.template.json" -Variables "prefix=someprefix,domain=somedomain,name=somename"

This outputs a staticwebapp.config.json with all the values of staticwebapp.config.template.json and the addition of the "Content-Security-Policy" header added to the globalHeaders. The value will have all its variables replaced.

Why?

The finicky problems caused by CSPs are at risk of typos and shortcuts. If you are running Azure Static Web Apps this removes the excuses for using wildcards or shipping wide-open CSPs.

azurepowershell
Creative

Yasen Dinkov

Syncing Feature App Configuration Flags

Patrols: an extention to Sprints