Generating CSP and HPKP headers in Ansible template

in

Content Security Policy headers can grow very long and as such are error prone if edited manually. One way to resolve that is generating them using a template language from a clean YAML structure, for example using Ansible.

The Ansible setup for CSP and HPKP headers is relatively simple, especially if you compare it with the nightmarish IPSec configuration generator I wrote (but which works in production nonetheless). The whole CSP configuration sits in group variable file in YAML format (group_vars/all). Note how special CSP origins ('none') are placed in double quotes to ensure proper rendering by Ansible, and how the single keyword option (upgrade-insecure-requests) is assigned an empty list to render as the target keyword only:

csp:
  "default-src":
    - "'none'"
  "img-src":
    - https://webcookies-20c4.kxcdn.com
    - https://*.facebook.com
    - https://*.twitter.com
    - https://pagead2.googlesyndication.com
  "script-src":
    - https://cdnjs.cloudflare.com
    - https://maxcdn.bootstrapcdn.com
    - https://pagead2.googlesyndication.com
    - https://connect.facebook.net
    - https://*.google.com
    - https://*.twitter.com
    - "'unsafe-inline'"
  "style-src":
    - https://maxcdn.bootstrapcdn.com
    - https://webcookies-20c4.kxcdn.com
    - "'unsafe-inline'"
  "font-src":
    - https://maxcdn.bootstrapcdn.com
  "frame-src":
    -  https://*.google.com
    - https://*.facebook.com
    - https://*.twitter.com
    - https://*.doubleclick.net
   "upgrade-insecure-requests":
    -

Then the template to output the Nginx header is quite simple:

add_header Content-Security-Policy "{% for res_type in csp %} {{res_type}}{% for origin in csp[res_type] %} {{origin}}{% endfor %};{% endfor %}";

Same story for HTTP Public Key Pins (HPKP) header. First, the YAML configuration in the same group_vars/all file:

pkp_hashes:
  - G5Yh5Mo/24pSh64SB3fhj0L5FZpnp4xjEg/INNDt9t8= # templates/backup.key.pem
  - TRi1sP2dt38aFrLNgr+zmllBN3tlzm0B/Hb4JZxrGvk= # templates/backup2.key.pem
  - YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg= # Let's Encrypt Authority X3
  - sRHdihwgkaib1P1gxX8HFszlD+7/gTfNvuAybgLPNis= # Let's Encrypt Authority X4 
  - C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M= # ISRG Root X1 
  - Vjs8r4z+80wjNcr1YKepWQboSIRi63WsWXhIMN+eWys= # DST Root CA X3

And the template (actually, the same file as above, just split for clarity):

add_header Public-Key-Pins '{% for h in pkp_hashes %}pin-sha256="{{h}}"; {% endfor %}; max-age=3600';

The final output after Ansible task is run will be these machine-but-not-human-readable headers:

add_header Content-Security-Policy " script-src https://cdnjs.cloudflare.com https://maxcdn.bootstrapcdn.com https://pagead2.googlesyndication.com https://connect.facebook.net https://*.google.com https://*.twitter.com 'unsafe-inline'; img-src https://webcookies-20c4.kxcdn.com https://*.facebook.com https://*.twitter.com https://pagead2.googlesyndication.com; default-src 'none'; frame-src https://*.google.com https://*.facebook.com https://*.twitter.com https://*.doubleclick.net; style-src https://maxcdn.bootstrapcdn.com https://webcookies-20c4.kxcdn.com 'unsafe-inline'; upgrade-insecure-requests ;  font-src https://maxcdn.bootstrapcdn.com;";
 
add_header Public-Key-Pins 'pin-sha256="G5Yh5Mo/24pSh64SB3fhj0L5FZpnp4xjEg/INNDt9t8="; pin-sha256="TRi1sP2dt38aFrLNgr+zmllBN3tlzm0B/Hb4JZxrGvk="; pin-sha256="YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg="; pin-sha256="sRHdihwgkaib1P1gxX8HFszlD+7/gTfNvuAybgLPNis="; pin-sha256="C5+lpZ7tcVwmwQIMcRtPbsQtWLABXhQzejna0wHFr8M="; pin-sha256="Vjs8r4z+80wjNcr1YKepWQboSIRi63WsWXhIMN+eWys="; ; max-age=3600';