logo科技微讯

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

作者:科技微讯
日期:2019-11-12
📜 文章

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 的变量,并给这个变量赋值 DENYx_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 均从源站获取。

max-age=0,must-revalidate 也可以写成 no-cache,作用一样。no-cache 的作用并非“不要缓存”,恰恰相反,它要求浏览器或 CDN 缓存它收到的数据,不过在使用这些缓存之前,必须先验证缓存是否还有效,例如发送带有 If-MatchIf-Modified-Since header 的请求。

如果你希望浏览器不要缓存,应该用 no-store,这样每一次请求,数据都直接来自服务器。no-store 并不能清空已经缓存下来的数据,可以加 max-age=0

如果你希望缓存设置只对浏览器有效,可以添加 private 属性,例如 Cache-Control: private,no-cache,意思是内容可以缓存,但只能在浏览器缓存,不能在 CDN 等代理服务器缓存。

当然,其实我不需要在 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 之前,需要先知道 Referer 这个 header,Referer 是在发生链接跳转时浏览器发送的 request header,用来告诉服务器我这个 request 来自哪里。

注意,Referer 是错误的英文单词,正确的写法应该是 Referrer,当人们发现这个单词写错的时候,Referer 已被广泛使用,来不及更正。Referrer-Policy 中的 Referrer 没有写错。

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

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

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

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

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

donation赞赏
thumbsup0
thumbsdown0
暂无评论