如何通过 nginx 给 gatsby 网站设置 CSP 等安全性 header

作者: 科技微讯

日期:

nginx 配置 header

HTTP Header 有一大类叫 security header, 即专门用来提高网站安全性的 header, 都是 response header. 你可以使用 mozilla observatorysecurity headers 检测自己网站的 header 有哪些需要改善的地方.

安全性 header 主要有以下几个, kejiweixun.com 已经设置了除 feature policy 之外的五个:

security headers

kejiweixun.com 是用 gatsby.js 生成的静态网站, 用 nginx 作为 web server, 使用腾讯云 CDN 提高网站访问速度, nginx 和 CDN 都可以对 response header 进行设置.

这篇文章主要讨论如何在 nginx 设置这些安全性 header, 会顺带分享如何在 CDN 进行相对应的设置.

nginx.conf

在 nginx 设置, 其实就是修改 nginx 的配置文件, github 的 h5bp/server-configs-nginx 很有参考价值, 根据这个 repo, 我把我的 nginx.conf 配置文件修改为:

//nginx.conf文件

user nginx;	
worker_processes  auto;	

error_log  /var/log/nginx/error.log warn;	
pid        /var/run/nginx.pid;	

events {	
    worker_connections  1024;	
}	

http {	
    server_tokens off;
    include       /etc/nginx/mime.types;	
    default_type  application/octet-stream;	

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '	
                      '$status $body_bytes_sent "$http_referer" '	
                      '"$http_user_agent" "$http_x_forwarded_for"';	

    access_log  /var/log/nginx/access.log  main;	

    sendfile        on;	
    tcp_nopush      on;	

    keepalive_timeout  65s;	

    gzip  on;
    gzip_comp_level 5;
    gzip_types
        application/javascript
        application/x-javascript
        application/json
        application/xml
        font/eot
        font/otf
        font/ttf
        text/css
        text/javascript
        text/markdown
        text/plain;

    map $sent_http_content_type $x_frame_options {
        ~*text/html DENY;
    }

    map $sent_http_content_type $content_security_policy {
        ~*text/html "default-src 'self' *.kejiweixun.com; base-uri 'none'; form-action 'self' *.kejiweixun.com; frame-ancestors 'self' *.kejiweixun.com; script-src 'self' 'unsafe-inline' *.kejiweixun.com https://www.google-analytics.com; style-src 'self' *.kejiweixun.com 'unsafe-inline'; img-src 'self' *.kejiweixun.com data: https://www.google-analytics.com; object-src 'self'; connect-src 'self' *.kejiweixun.com https://www.google-analytics.com";
    }

    map $sent_http_content_type $referrer_policy {
        ~*text/html "same-origin";
    }

    include /etc/nginx/conf.d/*.conf;	
} 

nginx 的主配置文件叫 nginx.conf, 通常位于 /usr/local/nginx/conf/etc/nginx/usr/local/etc/nginx. 主配置文件中的一条条配置信息被称作 directive, directive 有两种, 分别是 simple directive 和 block directive.

simple directive 的格式是 directive 的名称 + 空格 + 参数 + 分号标点, 例如 include /etc/nginx/conf.d/*.conf; 就是一个 simple directive, 意思是把 /etc/nginx/conf.d/*.conf 这个配置文件添加到 nginx.conf 中.

block directive 的格式也差不多, 区别是分号标点 ; 换成了 {}, {} 里可能有多个 simple directive. 如果一个 block directive 包含有其他 directive, 那这个 block directive 也被称作 context, 例如 http {} 就是一个 context.

events 是一个和 http 同级的 directive, 它们两个所在的 context 被称作 main context, http 这个 context 通常包含一个或多个叫 server 的 context, server 可能包含一个或多个叫 location 的 context, 形成如下结构:

main
  events
  http
     server
        location

当然除此之外还有其他 context, 例如 upstream 等.

在我的 nginx.conf 配置文件中, 关于 header 的部分就是几个位于 http 里的 map directive, 例如其中一个是:

map  $sent_http_content_type  $x_frame_options {
      ~*text/html             DENY;
}

它的意思是, 当 sent_http_content_type 这个变量的值是 ~*text/html 时, 就创建一个叫 x_frame_options 的变量, 并给这个变量赋值 DENY. x_frame_options 这个变量接着将被 server context 引用.

/conf.d/default.conf

前面我说过了, nginx.conf 可以通过 include 这个 directive 引入其他配置文件的配置信息, 通常我们把这些配置文件放在与 nginx.conf 位于同一路径下的 conf.d 文件夹中, 在这个例子中, 这个其他配置文件就是 /conf.d/default.conf:

server {
  listen [::]:80;
  listen 80;
  server_name www.kejiweixun.com;
  return 301 $scheme://kejiweixun.com$request_uri;
}

server {	
    listen [::]:80;
    listen 80;	
    server_name  kejiweixun.com;
    root         /usr/share/nginx/html;
    
    charset utf-8;

    location / {	
        index  index.html index.htm;	
        add_header cache-control "public, max-age=31536000, immutable" always;	
        add_header X-Content-Type-Options nosniff always;
        add_header X-Frame-Options $x_frame_options always;
        add_header Content-Security-Policy $content_security_policy always;
        add_header Referrer-Policy $referrer_policy always;
        add_header Access-Control-Allow-Origin "https://kejiweixun.com" always;
        add_header Strict-Transport-Security "max-age=31536000" always;
    }	
    location /sw.js {	
        add_header cache-control "public, max-age=0, must-revalidate" always;	
        add_header X-Content-Type-Options nosniff always;
        add_header Access-Control-Allow-Origin "https://kejiweixun.com" always;
        add_header Strict-Transport-Security "max-age=31536000" always;
    }	
    location ~* \.(html|htm)$  {	
        add_header cache-control "public, max-age=0, must-revalidate" always;	
        add_header X-Content-Type-Options nosniff always;
        add_header X-Frame-Options $x_frame_options always;
        add_header Content-Security-Policy $content_security_policy always;
        add_header Referrer-Policy $referrer_policy always;
        add_header Access-Control-Allow-Origin "https://kejiweixun.com" always;
        add_header Strict-Transport-Security "max-age=31536000" always;
    }	
    location /page-data/ {	
        add_header cache-control "public, max-age=0, must-revalidate" always;	
        add_header X-Content-Type-Options nosniff always;
        add_header Access-Control-Allow-Origin "https://kejiweixun.com" always;
        add_header Strict-Transport-Security "max-age=31536000" always;
    }	

    error_page 404 /404.html;	
} 

我没有在 nginx.conf 文件的 http context 中直接定义 server context, 而是把它写在 /conf.d/default.conf 这个文件中, 很多人都喜欢把 server context 从 http context 分离出来.

一个 http context 可以包含多个 server directive, 我这里就包含了两个, 不同 server 之间通过 listen 端口以及 server_name 的不同进行区分. 我这里两个 server 都 listen 80 端口, 但其中一个 server_name 是 www.kejiweixun.com, 另一个是 kejiweixun.com.

第一个 server 的作用是把所有带 www 的链接 301 跳转到没有 www 的链接, 所以第二个 server 才是最终起作用的 server.

第二个 server context 包含了 4 个 location context, 我之所以写了 4 个 location, 是因为我希望给这 4 个 location 分别单独设置 cache-control header. 关于为什么要这样做, 可以参考 gatsby.js 的官方文档.

cache-control header 不仅影响浏览器的缓存方式, 也影响 CDN 的缓存方式. gatsby.js 部署到 CDN 时, 可以让整个网站的文件都通过 CDN 提供, 但这样做的话, 以后每次对网站进行修改, 例如添加一篇新的文章等等, 都要手动进行缓存刷新, 否则浏览器可能无法马上看到更新后的内容, 因为如果不刷新, CDN 会继续向浏览器提供旧的缓存. 所以 gatsby 建议我们把 html 文件, sw.js, 和所有位于 page-data 文件夹中的 json 文件, 都设置为每次 request 均从源站获取.

当然, 其实我不需要在 nginx 设置这个 cache-control header, 因为也可以在 CDN 设置, 如下图所示:

cdn cache control

但我希望对 header 拥有更充分的控制, 而且我觉得源站才是真相的源头, 所以自己设置了 cache-control header, 于是有了四个 location context.

如果所有 add_header 都写在 location 之外且位于 server 之内, 那这些 add_header 会自动应用于所有 location, 但如果 location 内部同时也写了自己的 add_header, 那 location 之外的 add_header 就会被这个 location 忽略, 所以我需要在每个 location 之中分别写 add_header.

HTTP 安全性 Header

接下来分别说说以下 5 个安全性 header:

  • X-Content-Type-Options
  • X-Frame-Options
  • Referrer-Policy
  • Strict-Transport-Security
  • Feature-Policy
  • Content-Security-Policy

X-Content-Type-Options

我把 X-Content-Type-Options 的值设置为 nosniff, 意思是 no sniff, 作用是告诉浏览器不要进行 MIME sniffing (MIME 嗅探), 即不要自作主张地更改 Content-Type 的值, 而是完全按照开发者设定的 Content-Type 的值处理收到的文件. 关于 MIME 嗅探是什么, 以及它可能造成的安全问题, 可以看看蔡同学的文章.

X-Frame-Options

X-Frame-Options 的值可以是 sameorigin 和 DENY. sameorigin 的意思是, 如果我在 kejiweixun.com 中的某个页面中, 通过 <frame><iframe><embed><object> 插入 kejiweixun.com 的另一个页面, 这个被插入的页面可以正常显示出来.

当值是 sameorigin 或 DENY 时, 如果其他网站, 比如 baidu.com 想通过 <frame><iframe><embed><object> 这些元素在它的页面中嵌入我的页面, 那我的页面将无法正常显示, 如下所示:

x frame options

X-Frame-Options 可以用来防御 clickjacking, 假设 kejiweixun.com 是一个银行网站, 如果我没有设置 X-Frame-Options, 那坏人就可以搭建一个领取免费奖品的网站, 并用 <iframe> 等元素把银行网站的付款页面嵌入这个欺诈网站, 但是这个付款页面用户是看不到的, 这个 <iframe> 被领取奖品的页面遮挡在下面了, 当用户点击他能看到的领取按钮时, 实际可能是点了隐藏着的银行页面的转账按钮.

clickjacking 另一个用途是骗点赞, 骗关注, 例如把银行付款按钮换成微博点赞按钮等等.

Referrer-Policy

在说 Referrer-Policy 这个 header 之前, 需要先知道 Referrer 这个 header, Referrer 是在发生链接跳转时浏览器发送的 request header, 用来告诉服务器我这个 request 来自哪里.

例如从 kejiweixun.com 首页点击链接跳转到 https://kejiweixun.com/ios-beta-profile/, 那浏览器会发送一个带有 Referrer header 的请求, 这个 Referrer 的值就是 kejiweixun.com, 因为是从科技微讯的首页 kejiweixun.com 跳转过去的.

如果从 kejiweixun.com 跳转到 google.com, 那 google.com 的服务器也可以获得 Referrer: kejiweixun.com 这样的 header.

这个功能让 google analytic 能统计 A 网站有多少访客来自 B 网站, 但因为 url 中可能包含一些敏感信息, 不加思索地把 url 传给任何一个其他网站可能会泄漏用户的隐私信息.

Referrer 是浏览器自动添加到 request 中的, 但开发者可以通过 Referrer-Policy 这个 response header 要求浏览器不要乱来. Referrer-Policy 有 8 种值, 具体可以参考 MDN.

科技微讯的 url 其实没有什么敏感信息, 所以我设置为 strict-origin-when-cross-origin, 表示从 kejiweixun.com 这个网站的任何页面跳转到 kejiweixun.com 的任何其他页面时, 浏览器会发送一个带上 Referrer header 的 request, 其值是完整的 url, 而跳转到其他通过 https 访问的域名时, 也会带上 Referrer header, 但其值永远是 https://kejiweixun.com, 不是完整的 url, 另外, 如果跳转的 url 是非 https, 而是 http 域名, 那不带上 Referrer header.

Strict-Transport-Security

简称 HSTS, 用来告诉浏览器, 喂, 以后你访问我这个网站, 麻烦你总是通过 https 访问, 不要用 http.

所以如果用户输入的是 kejiweixun.com 或 http://kejiweixun.com 这样的网址, 浏览器会自动转换成 https://kejiweixun.com, 然后再向科技微讯的服务器发起请求.

所以如果你通过 kejiweixun.com 访问科技微讯, 可能会看到 307 内部跳转:

hsts redirect

腾讯云 CDN 有一个 "强制跳转 HTTPS" 的开关, 其本质就是在 response header 中添加 Strict-Transport-Security 这个 header, 这意味着在我的 nginx 服务器设置了这个 header, 那腾讯云 CDN 即使没有启用这个功能, 浏览器也会自动跳转 HTTPS.

HSTS 的值可能是:

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

其中 max-age=31536000 告诉浏览器在接下来的 1 年时间内, 都自动使用 https 访问我的网站, 这是必需的值, 当然时间你可以自定义. includeSubDomains 和 preload 是选填值, 前者告诉浏览器 kejiweixun.com 及其所有子域名都启用 HSTS, 后者告诉浏览器把我这个域名登记在一个目录中. 这个目录就是所谓的 preload list, 在这个 list 的域名都只能通过 https 访问. preload 要谨慎使用, 因为上了 preload 名单再移除的话, 可能要等很久.

Feature-Policy

这个 header 挺有趣, 假如 kejiweixun.com 添加了 Feature-Policy: display-capture 'none' 这样的 header, 那用户在手机上访问我这个网站时, 就不能对我的内容截屏了.

Feature-Policy 顾名思义, 就是设置这个网站可以使用浏览器的什么功能, 不可以使用什么功能. 现在浏览器功能非常丰富, 可以调用相机, 麦克风, 支付, 录屏, 定位, 震动等等, 通过 Feature-Policy, 我们可以告诉浏览器在访问我的这个网站时, 禁用某些功能.

Content-Security-Policy

在这么多个安全性 header 中, CSP header 是最复杂的 header, 它的值有无数种.

这是科技微讯的 CSP header:

content-security-policy: default-src 'self' https://*.kejiweixun.com; base-uri 'none'; form-action 'self' https://*.kejiweixun.com; frame-ancestors 'none'; script-src 'self' 'unsafe-inline' https://*.kejiweixun.com https://www.google-analytics.com; style-src 'self' https://*.kejiweixun.com 'unsafe-inline'; img-src 'self' https://*.kejiweixun.com data: https://www.google-analytics.com; object-src 'self'; connect-src 'self' https://*.kejiweixun.com https://www.google-analytics.com

为方便查看:

default-src 'self' https://*.kejiweixun.com; 

base-uri 'none'; 

form-action 'self' https://*.kejiweixun.com; 

frame-ancestors 'none'; 

style-src 'self' https://*.kejiweixun.com 'unsafe-inline'; 

script-src 'self' https://*.kejiweixun.com 'unsafe-inline' https://www.google-analytics.com; 

img-src 'self' https://*.kejiweixun.com data: https://www.google-analytics.com; 

connect-src 'self' https://*.kejiweixun.com https://www.google-analytics.com;

object-src 'self'; 

写法上注意以下几个特点:

  • CSP 的值由多个 directive 组成, directive 之间用分号隔开, 每个 directive 可能都有多个值, 这些值用空格分开;
  • directive 的值如果是 url 的话不用加冒号, 其他, 例如 self, unsafe-inline 等等要加冒号;
  • directive 的值被称为 source, 定义哪些来源可以被浏览器执行;
  • 大多数 direcitve 的名称都带有 src 后缀, 这些 directive 又叫 fetch directive;

directive 有很多个, 其中比较值得关注的有:

default-src
作为 fetch directive 的 fallback, 
即如果某些 fetch directive 没有声明, 
就把这些 direcitve 的值视为等于 default-src 的值.

script-src
告诉浏览器可以执行来自哪些地方的 script

style-src
告诉浏览器可以处理来自哪些地方的 style

img-src
告诉浏览器可以显示来自哪些地方的图片

object-src
告诉浏览器可以显示来自哪些 url 的 <object> 
或 <embed> 或 <applet> 等元素的内容

connect-src
告诉浏览器可以 fetch 或 XMLHttpRequest 哪些 url, 等;

base-uri
限制 <base> 元素的 href 属性的值;

form-action
限制 <form> 元素的 action 属性的值;

frame-src
告诉浏览器可以显示来自哪些地方的 <frame>, <iframe> 等元素的内容

frame-ancestors
限制 <frame> 或 <iframe> 或 <object> 或 <embed> 或 <applet>
这些元素的父元素可以是什么,
作用涵盖了 x-frame-options,
但旧浏览器可能不支持 CSP, 
所以建议同时设置 x-frame-options.

由于 fetch directive 有 default-src 作为 fallback, 所以不需要定义每一个 fetch directive 的值, 但如果某个 fetch directive 的值不能等于 default-src 的值, 那要单独声明, 当某个 fetch directive 单独声明之后, 它会忽略 default-src 的值.

对于其他 directive, 例如 form-action 和 frame-ancestors, 不声明就代表允许所有 url.

需要注意 frame-ancestors 和 x-frame-options 的区别. 科技微讯同时设置了这两个 header, 当某个第三方域名通过 <iframe> 等元素嵌套科技微讯的某个网页时, chrome 会显示:

frame ancestor

而 directive 的值就更多了, 有无数种可能, 主要分为以下几类:

https://*.kejiweixun.com
科技微讯的所有子域名, 但不包括 https://kejiweixun.com

https://kejiweixun.com
精确匹配, 只匹配这一个域名的 url

kejiweixun.com:443
可以规定端口

https: 或 http: 或 data: 或 blob: 等等
可以只设定协议, 匹配所有这种协议的 url

*
匹配所有 url

'none'
屏蔽所有 url

'self'
匹配当前页面的域名元素, 会同时检测域名, 端口, 协议是否匹配, 
都匹配才是匹配

'unsafe-inline'
详见下文

'unsafe-eval'
匹配 eval(), 也是不安全

'strict-dynamic'
要撘配 hash 或 nonce 使用, 具体看 mdn.

'<hash-algorithm>-<base64-value>'
详见下文

'nonce-<base64-value>'
详见下文

'unsafe-inline'

匹配 inline 的 script 或 style, 因为 inline 的 stript 或 stype 不安全, 所以在前面加了 unsafe, 提醒开发者如果你这样设置, 不够安全.

注意这里的 inline style 不仅仅指 html 元素的 style 属性:

<h1 style='color: red'>科技微讯</h1>

还包括 <style> 元素等:

<style>
    { background: red; }
</style>

这里的 inline script 包括 <script> 元素内部的 script:

<script> 
  var inline = 1; 
</script>

也包括 inline event handler 等:

<button onclick="alert('科技微讯')">
    点我!
</button>

'<hash-algorithm>-<base64-value>'

这是一个 hash source 的例子:

'sha256-U53QO64URPPK0Fh7tsq0QACAno68LrPc5G6Avyy07xs='

这个 hash source 表示 alert('Hello world!');. 对于 inline 的 style 或 script, 可以把这些 style 或 script 用 hash 算法加密, 然后添加到 directive 的值中, 一个 hash source 匹配一个 inline script 或 style, 如果你有无数个 inline script 或 style 恐怕不可行.

网上有 CSP hash source 生成器, 把你的 inline style 或 inline script 复制过去, 会自动生成一个 base64-encoded 的 hash 值.

如果你想手动生成, 可以用这个 node 程序:

const crypto = require('crypto');
const result = crypto.createHash('sha256')
.update("alert('Hello world!');", 'utf-8')
.digest('base64');
console.log(result);

'nonce-<base64-value>'

这是一个 nonce source 的例子:

 <script nonce="这里替换成一个随机数">alert('Hi!');</script>

nonce 在密码学语境下指的是一个只使用一次的数字. nonce source 类似 hash source, 区别是 nonce source 不是对 inline style 或 script 进行加密, 而是给这些包含 inline style 或 script 的 html 元素添加一个 nonce 属性, 然后把这个属性添加到 directive 的值中. 随机数只能用一次, 响应浏览器的每次请求都要使用一个新的随机数.

避免使用 unsafe-inline

'unsafe-inline' 是一个不安全的 source, 但科技微讯网站使用了很多 inline 的 style 或 script, 所以我还是选择用 unsafe-inline 了.

不过正如前面所说过的, inline 的 style 或 script 可以用 hash source 或 nonce source 代替. 有人给 gatsby 开发了一款名为 gatsby-plugin-csp 的插件, 可以自动为这些 inline style 或 script 生成 hash, 并添加到 <meta> 元素中, 例如:

<meta http-equiv="Content-Security-Policy"
content="base-uri &#x27;self&#x27;; default-src &#x27;self&#x27;; script-src &#x27;self&#x27; &#x27;sha256-ctSi8cL8mD4Z+B6mC6NGy8pBg8EubnSdgcor00NYCtU=&#x27;;style-src &#x27;self&#x27; &#x27;sha256-MtxTLcyxVEJFNLEIqbVTaqR4WWr0+lYSZ78AzGmNsuA=&#x27;; object-src &#x27;none&#x27;; form-action &#x27;self&#x27;; font-src &#x27;self&#x27; data:; connect-src &#x27;self&#x27;; img-src &#x27;self&#x27; data:;" />

把 CSP 作为属性添加到 <meta> 元素是实现 CSP 的另一种方法, 这样就不用在 header 定义了, 但用 header 定义通常是更好的方法.

需要注意的是, hash source 和 nonce source 都只适用于 <style></style> <script></script> 这种 inline style 或 script, 不适用于 style 属性和 inline event handler. 而我猜几乎每位 gatsby 用户都会使用的 gatsby-image 恰恰使用了 style 属性处理图片, 所以 gatsby-plugin-csp 这个插件并不能应对所有情况, 还是要用 'unsafe-inline'.

如果你希望严格遵守 CSP, 在写代码的时候就不要用 style 属性, 也不要写 inline event handler. 例如 event handler 本来是这样写的:

<button onclick="myFunction()">点我</button>

要改成这样:

<!--html-->
<button id="myButton">点我</button>
<script src="script.js"></script>

和:

//script.js
document.getElementById("myButton").addEventListener("click", myFunction);
function myFunction(){
  console.log('asd');
}

这款插件并不适合我, 所以我选择使用 unsafe-inline. gatsby.js 用户已经在讨论怎么让 gatsby 更好地实现 CSP, 但至少到目前为止还没有很好的方法.

总结

对于 web 开发而言, HTTP header 是非常重要的知识点, 一个 header 看起来很简单, 却可能对网站产生重要影响. 我很早就开始使用腾讯云 CDN, 但直到现在才大体明白原来 CDN 里的各项设置其实是通过 header 影响网站的行为, 终于明白这些设置选项都是什么意思, 有什么用.

最后关于 HTTP header, 可以再看看 Scott HelmeAndrew Betts 写的文章.