0%

你做过最坑爹的事情是什么?小时候总要和我妈睡

技术点

  • redis缓存
  • 智能DNS
  • 分布式缓存
  • 分布式session
  • 多线路接入
  • 负载均衡 lvs
  • mysql主从
  • 单例模式
  • 页面静态化
  • gzip压缩
  • 减少http请求
  • 压缩js,css文件
  • 分离原站部署
  • 监控
  • 队列,排队处理
  • 异步操作
  • 抢购资质

思维导图



哪个更痛苦,努力还是后悔?
系统默认安装的python是2.6.6的, 我们希望的是多版本的共存, 切记是不能将系统自带的版本卸载掉的,否则会引起很多的意想不到的问题;

安装

1
2
3
4
5
6
7
8
wget https://www.python.org/ftp/python/3.5.2/Python-3.5.2.tgz
tar -xvf Python-3.5.2.tgz
./configure --prefix=/usr/local/python3.5.2
make
make install

mv /usr/bin/python /usr/bin/python2.6.6
ln -s /usr/local/python3.5.2/bin/python3.5 /usr/bin/python

配置

1
vim /usr/bin/yum

将第一行的#!/usr/bin/python修改成#!/usr/bin/python2.6.6

查看版本

1
2
python -V
pip -V

身无饥寒,父母未曾亏我;我无长进,我以何待父母;愿你离去时,还有归途,愿你归来时,还有守候;

说明

今天我们要说的内容就主要会围绕下面这张图片来讲

一般左连接

1
SELECT * FROM tablea a LEFT JOIN tableb b ON a.val = b.val

说明:
在内连接的基础上, 还包含左表中所有不符合条件的数据行, 并在其中的右表列补充值为 null, 右表中没有被匹配到列不会出现在结果集当中
对于table1中的每一条记录对应的记录如果在table2中也恰好存在而且刚好只有一条,那么就会在返回的结果中形成一条新的记录。
对于table1中的每一条记录对应的记录如果在table2中也恰好存在而且有N条,那么就会在返回的结果中形成 N条新的记录(数据可能会有重复)。
对于table1中的每一条记录对应的记录如果在table2中不存在,那么就会在返回的结果中形成一条条新的记录,且该记录的右边全部NULL。

一般右连接

1
SELECT * FROM tablea a RIGHT JOIN tableb b ON a.val = b.val

交集

1
SELECT * FROM tablea a INNER JOIN tableb b ON a.val = b.val

说明:
如果两张表不加任何条件的做一个内连接, 结果将是两站表的笛卡尔积
如果关联的时候有条件, 使用关键字 on, 查询的结果是完全复合这个条件的结果, 不会有重复的数据
在内连接的查询条件, 使用where和on 的条件是一样的, 但在外连接中就不能代替

并集

1
SELECT * FROM tablea a FULL OUTER JOIN tableb ON a.val = b.val

差集A-B

1
SELECT * FROM tablea a LEFT JOIN tableb b ON a.val=b.val WHERE b.val IS NULL

差集B-A

1
SELECT * FROM tablea a RIGHT JOIN tableb b ON a.val=b.val WHERE a.val IS NULL

去掉两集合的公共元素

1
SELECT * FROM tablea a FULL OUTER JOIN tableb b ON a.val=b.val WHERE a.val IS NULL OR b.val IS NULL

夏酷暑,冬严寒,春也不死吾心,心所向,将所成

编译安装

1
2
3
4
5
wget http://download.redis.io/releases/redis-4.0.2.tar.gz
tar -xvf redis-4.0.2.tar.gz
mv redis-4.0.2 /usr/local
cd /usr/local/redis-4.0.2/
make

修改配置文件

redis.conf

1
2
3
4
bind 127.0.0.1
port 6379
daemonize yes
pidfile /var/run/redis_6379.pid

启动

需要指定配置文件

1
./redis-server ../redis.conf

查看

1
ps -ef|grep redis

配置另外一个redis实例

只要端口, 配置文件不一样的就可以
复制原配置文件并重命名

1
2
3
4
5
cp redis.conf redis-1.conf
vi redis-1.conf
port 6380
slaveof 127.0.0.1 6379
pidfile /var/run/redis_6380.pid

启动

1
./redis-server ../redis-1.conf

我不断奔跑,就是为了追上那个曾被给予厚望的自己

关于加密

加密算法分为单向加密和双向加密。
单向加密包括MD5,SHA加密算法等等。单向加密算法是不可逆的,也就是无法将加密后的数据恢复成原始数据,除非采取碰撞攻击和穷举的方式。像是银行账户密码的存储,一般采用的就是单向加密的方式。
双向加密是可逆的,存在密文的密钥,持有密文的一方可以根据密钥解密得到原始明文,一般用于发送方和接收方都能通过密钥获取明文的情况。
双向加密包括对称加密和非对称加密。
对称加密包括DES加密,AES加密等等,本文档介绍的主要是AES加密。而非对称加密包括RSA加密,ECC加密。

AES加密

AES加密时需要统一四个参数:

  • 密钥长度 (Key Size)
  • 加密模式 (Cipher Mode)
  • 填充方式 (Padding)
  • 初始向量 (Initialization Vector)

由于前后端开发所使用的语言不统一,导致经常出现前后端之间互相不能解密的情况出现,其实,无论什么语言系统,AES的算法总是相同的,导致结果不一致的原因在于上述的四个参数不一致,下面就来了解一下这四个参数的含义
密钥长度
AES算法下,key的长度有三种:128、192、256 bits,三种不同密钥长度就需要我们传入的key传入不同长度的字符串,例如我们选择AES-128,那我们定的key需要是长度为16的字符串
加密模式
AES属于块加密,块加密中有CBC、ECB、CTR、OFB、CFB等几种工作模式,为了保持前后端统一,我们选择ECB模式
填充方式
由于块加密只能对特定长度的数据块进行加密,因此CBC、ECB模式需要在最后一数据块加密前进行数据填充
初始向量
使用除ECB以外的其他加密模式均需要传入一个初始向量,其大小与Block Size相等

laravel的加密

laravel中自带的加密算法采用AES-256-CBC的这种加密算法,源码位置Illuminate\Encryption\Encrypter, 准确的说AES才是一种加密算法,256则指密钥长度为256bit,密钥长度越长当然是越安全相应的消耗的时间也越多,但对现在的计算机而言差距并不明显,CBC则指的是AES加密工作体制中的一种

在使用 Laravel 的加密程序之前, 你必须先设置 config/app.php 配置文件中的 key 选项。运行 Artisan命令 php artisan key:generate,它会使用 PHP 的安全随机字节生成器来构建密钥。如果这个 key 值没有被正确设置,则所有由 Laravel 加密的值都将是不安全的。

1
2
3
4
5
6
7
8
9
10
// 加密
$name = 'yangguoqi';
$secret = encrypt($name);

$a = 'eyJpdiI6Im42Z3IzeVNmTDNtXC9nT0NvaGV0NjNRPT0iLCJ2YWx1ZSI6IjhlTGRGYyt6aWhubjREM1BTM0EybExpdlhuMU1FTFwvSUhzUTVnNHRwekJNPSIsIm1hYyI6IjlmZTY3MDczMDg0NzJiMDUwNTRiMGNiYWM3YTAzYzQ4Y2EwOTA4MjJkMDM2OWEyZmVhODcwODUxZWNjMDRkYWEifQ==';
$b = 'eyJpdiI6InJcL1IwcklzbFcyTUZsSVhNS1k3RXFBPT0iLCJ2YWx1ZSI6IlwvV1dCcWdXS21hbERmWnFPWDVYa1hlN0RadkJlQUt4Nkh4bzczS2JuOEFRPSIsIm1hYyI6IjhhN2E0OWE2ZTc3ZTU4YzQzOGYzYmUzZDA3NWYwNTVlOTllNzQ1ZWY5YTE0NzE4NTUzM2E2MDY4ZDVkMzNhOTYifQ==';

// 解密
dump(decrypt($a));
dump(decrypt($b));

说明

注意: 虽然每次加密的是同一个字符串, 但是每次的结果都是不一样的, 用不一样的结果通过解密是可以拿到相同的值的
因为加密后的字符串可能在网络传输中被篡改,为了保证数据的安全性,laravel通过hmac_sha256算法使用base64编码后的iv,value计算出了MAC值,这个mac值是对于这次加密是独一无二的

成功只有一种,那就是用自己喜欢的方式度过一生

简述

系统开发中,实时消息推送是一个很常见的需求。整体过程而言也没有那么复杂,如果不考虑实时性和性能这些,更简单,客户端轮询服务器的消息表即可。建立Web实时通信和传统通信不同的是,因为浏览器和http服务器之间不能进行双向通信,所以需要借助Websocket这么一个桥梁来连接两者。用户的应用产生消息之后,首先发送给Websocket服务器,Websocket服务器收到消息,再发送给已经建立连接的客户端。
整个过程可以简化为:

  • 前端页面初始化,连接到Websocket服务器
  • 应用程序产生通知,连接Websocket服务器,发送消息
  • Websocket服务器接收到应用程序发送的消息,转发给浏览器
  • 浏览器接收到通知,进行页面响应

Websocket服务器

这里使用swoole来编写Websocket服务器。swoole是一个高性能的PHP网络通信扩展。很强大。 这里,建立一个Laravel自定义命令,来管理server。

1
$ php artisan make:command SwooleServer

服务器端值得注意的是,需要用到一个全局的数据结构来管理用户,和用户的连接,当用户刷新浏览器之后,需要更新一下用户key绑定的连接符。这样,当消息再次到达时,能够准确的发送出去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<?php

$server = new Server('0.0.0.0', 9501);

$table = new Table(1024);
$table->column('uid', Table::TYPE_INT);
$table->column('fd', Table::TYPE_INT);
$table->create();

$server->table = $table;

$server->on('open', function (Server $server, $request){
echo "connected\n";
if (isset($request->get['uid'])) {
$uid = $request->get['uid'];
$server->table->set($uid, ['uid' => $uid, 'fd' => $request->fd]);
}
});

$server->on('message', function(Server $server, $frame) {
echo "received from {$frame->fd}:{$frame->data}\n";
$msg = json_decode($frame->data, true);
$user = $server->table->get($msg['user_id']);
if ($user) {
$server->push($user['fd'], $frame->data);
}
});

$server->on('close', function($server, $fd){
echo "client {$fd} closed\n";
$server->table->del($this->user);
});

$server->start();

客户端

客户端只需要在页面加载时,连接Websocket服务器,然后,在接收到消息时,更新页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// client.js
var socket = new WebSocket('ws://localhost:9501/?uid=1');
socket.onopen = function(event) {
var badge = document.getElementById('msg-cnt');
badge.innerHTML = 0;
console.log('Connected: ' + event);
}
socket.onmessage = function(event) {
var badge = document.getElementById('msg-cnt');
var data = JSON.parse(event.data);
badge.innerHTML = data.cnt;

console.log("Received: " + data);
}
socket.onclose = function(event) {
console.log("Closed..");
}

PHP应用程序

PHP应用程序产生消息之后,需要发送给Websocket服务器。这里说个插曲,之前关于这块,看的是网上的例子,使用Redis来连接应用程序和WS服务器通信。但是他们的WS服务器使用的是Node。我在使用Redis和Swoole这个干时,错误了。因为Redis的订阅操作是阻塞的,所以Swoole不能这么干。

PHP发送消息需要用到PHP的Websocket客户端库来连接,发送消息。有些实现很简单,这里我使用的是这个库websocket-php。发送消息代码:

1
2
3
4
<?php
$cli = new WebsocketClient('ws://localhost:9501');
if (!$cli) {echo 'Connect Error!';exit;}
$cli->send(json_encode($msg->toArray(), JSON_UNESCAPED_UNICODE));

http://memosa.cn/web/2016/04/23/ws-notification.html

我今生没有别的希望,我只希望,能多**几个女人。 ——季羡林 清华园日记

试验环境

  • ThinkPHP5
  • CentOS

后端php代码

tp5\application\console\TimerConsole.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<?php
namespace App\Console;

use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\Exception;


class TimerConsole extends Command
{
protected $server;
protected $client; // 用于保存所有的客户端

protected function configure()
{
$this->setName('timer:start')->setDescription('Start TCP(Timer) Server!');
}

protected function execute(Input $input, Output $output)
{
$this->server = new \swoole_websocket_server('0.0.0.0', 9501);

$this->server->on('open', function (\swoole_websocket_server $server, $request) {
echo "服务器: 握手成功-{$request->fd}\n";
$this->client[] = $request->fd;
echo json_encode($this->client);
});
$this->server->on('message', function (\swoole_websocket_server $server, $frame) {
echo "接受到消息: {$frame->fd}:{$frame->data},opcode:{$frame->opcode},fin:{$frame->finish}\n";
foreach ($this->client as $item) {
if ($item) {
try {
$server->push($item, time());
} catch (Exception $exception) {
continue;
}
}
}
});
$this->server->on('close', function ($ser, $fd) {
echo "客户端关闭-{$fd}\n";
unset($this->client[$fd]);
});
$this->server->start();
}
}

前端html代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script>
if ("WebSocket" in window)
{
console.log("您的浏览器支持 WebSocket!");
var ws = new WebSocket("ws://vxndy.com:9501");
ws.onopen = function() {
ws.send("来自客户端的数据");
};

ws.onmessage = function (evt) {
var received_msg = evt.data;
console.log("数据已接收..."+received_msg);
document.getElementById("haha").innerHTML=received_msg;
};

ws.onclose = function() {
console.log("连接已关闭...");
};
}
else {
console.log("您的浏览器不支持 WebSocket!");
}
</script>
</head>
<body>
<h1 id="haha"></h1>
</body>
</html>

放心抄,保证全对。

serialize()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<html>
<head>
<meta charset="utf-8">
<script src="https://cdn.bootcss.com/jquery/3.2.1/jquery.min.js"></script>
<script type="text/javascript">
$(document).ready(function(){
$("button").click(function(){
x=$("form").serialize();
console.log(x);
});
});
</script>
</head>
<body>
<form action="">
First name: <input type="text" name="FirstName" value="Bill" /><br />
Last name: <input type="text" name="LastName" value="Gates" /><br />
</form>

<button>序列化表单值</button>
</body>
</html>

结果

1
FirstName=Bill&LastName=Gates

serializeArray()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<html>
<head>
<meta charset="utf-8">
<script src="https://cdn.bootcss.com/jquery/3.2.1/jquery.min.js"></script>
<script type="text/javascript">
$(document).ready(function(){
$("button").click(function(){
x=$("form").serializeArray();
$.each(x, function(i, field){
$("#results").append(field.name + ":" + field.value + " ");
});
});
});
</script>
</head>
<body>
<form action="">
First name: <input type="text" name="FirstName" value="Bill" /><br />
Last name: <input type="text" name="LastName" value="Gates" /><br />
</form>

<button>序列化表单值</button>
<div id="results"></div>
</body>
</html>

结果

1
object

后来我终于知道 , 它并不是我的花 ,我只是恰好途径了它的盛放。
### 准备 项目开始前, 我们需要安装一些必要的依赖包
1
2
3
composer require guzzlehttp/guzzle
composer require fabpot/goutte
composer require symfony/dom-crawler
并创建一个命令
1
php artisan make:command Spider

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php

namespace App\Console\Commands;

use Goutte\Client;
use Illuminate\Console\Command;
use Symfony\Component\DomCrawler\Crawler;

class Spider extends Command
{

protected $signature = 'run:dev';

protected $description = 'Command description';

protected $startUrl = 'https://laracasts.com/series/learn-vue-2-step-by-step';
protected $client;

public function __construct()
{
parent::__construct();
$this->client = new Client();
}

public function handle()
{
$crawler = $this->client->request('GET', $this->startUrl);
//file_put_contents(storage_path('haha.html'), $crawler->html());
$crawler->filter('.episode-list-title > a')->each(function ($node) use ($crawler) {
//$this->line(trim($node->text()));
$hrefText = trim($node->text());
$link = $crawler->selectLink($hrefText)->link();
$craw = $this->client->click($link);
//file_put_contents(storage_path($hrefText.'.html'), $craw->html());
$domElement = new Crawler($craw->html());
$videoSrc = $domElement->filter('video > source')->first()->attr('src');
$this->line($videoSrc);
});
$this->line('success');
}
}

https://laracasts.com/series/learn-vue-2-step-by-step
https://symfony.com/doc/current/components/dom_crawler.html
https://packagist.org/packages/fabpot/goutte

不如意事常八九,可与言者无二三。

前言

最近剛好碰到一些 CSRF 的案例,趁著這次機會好好研究了一下。深入研究之後才發現這個攻擊其實滿可怕的,因為很容易忽略它。但幸好現在有些 Framework 都有內建防禦 CSRF 的功能,可以很簡單的開啟。
但儘管如此,我認為還是有必要瞭解一下 CSRF 到底在幹嘛,是透過怎樣的手段攻擊,以及該如何防禦。就讓我們先來簡單的介紹一下它吧!
CSRF 是一種 Web 上的攻擊手法,全稱是 Cross Site Request Forgery,跨站請求偽造。不要跟 XSS 搞混了,他們兩種是不同的東西,那到底什麼是 CSRF 呢?先從我自身的一個案例談起好了。

偷懶的刪除功能

以前我有做個一個簡單的後台頁面,就想成是一個部落格吧!可以發表、刪除以及編輯文章,刪除的那個按鈕,點下去之後就可以把一篇文章刪掉。當初因為偷懶,想說如果我把這個功能做成 GET,我就可以直接用一個連結完成刪除這件事,在前端幾乎不用寫到任何程式碼:

1
<a href='/delete?id=3'>刪除</a>

很方便對吧?然後我在網頁後端那邊做一下驗證,驗證 request 有沒有帶 session id 上來,也驗證這篇文章是不是這個 id 的作者寫的,都符合的話才刪除文章。
嗯,聽起來該做的都做了啊,我都已經做到:「只有作者本人可以刪除自己的文章」了,應該很安全了,難道還有哪裡漏掉了嗎?
沒錯,的確是「只有作者本人可以刪除自己的文章」,但如果他不是自己「主動刪除」,而是在不知情的情況下刪除呢?你可能會覺得我在講什麼東西,怎麼會有這種事情發生,不是作者主動刪的還能怎麼刪?
好,我就來讓你看看還能怎麼刪!

攻击案例

今天假設小黑是一個邪惡的壞蛋,想要讓小明在不知情的情況下就把自己的文章刪掉,該怎麼做呢?

他知道小明很喜歡心理測驗,於是就做了一個心理測驗網站,並且發給小明。但這個心理測驗網站跟其他網站不同的點在於,「開始測驗」的按鈕長得像這樣:

1
<a href='https://small-min.blog.com/delete?id=3'>開始測驗</a>

小明收到網頁之後很開心,就點擊「開始測驗」。
點擊之後瀏覽器就會發送一個 GET 請求給https://small-min.blog.com/delete?id=3,並且因為瀏覽器的運行機制,一併把 small-min.blog.com 的 cookie 都一起帶上去。Server 端收到之後檢查了一下 session,發現是小明,而且這篇文章也真的是小明發的,於是就把這篇文章給刪除了。
這就是 CSRF,你現在明明在心理測驗網站,假設是 https://test.com 好了,但是卻在不知情的狀況下刪除了 https://small-min.blog.com 的文章,你說這可不可怕?超可怕!
這也是為什麼 CSRF 又稱作 one-click attack 的緣故,你只要點一下就中招了。
你可能會說:「可是這樣小明不就知道了嗎,不就連過去部落格了?不符合『不知情的狀況』啊!」

好,那如果我們改成這樣呢:

1
2
<img src='https://small-min.blog.com/delete?id=3' width='0' height='0' />
<a href='/test'>開始測驗</a>

在開啟頁面的同時,一樣發送一個刪除的 request 出去,但這次小明是真的完全不知道這件事情。這樣就符合了吧!
CSRF 就是在不同的 domain 底下卻能夠偽造出「使用者本人發出的 request」。要達成這件事也很簡單,因為瀏覽器的機制,你只要發送 request 給某個網域,就會把關聯的 cookie 一起帶上去。如果使用者是登入狀態,那這個 request 就理所當然包含了他的資訊(例如說 session id),這 request 看起來就像是使用者本人發出的。

那我把刪除改成 POST 不就好了嗎?
沒錯,聰明!我們不要那麼懶,好好把刪除的功能做成 POST,這樣不就無法透過 或是 來攻擊了嗎?除非有哪個 HTML 元素可以發送 POST request!
有,正好有一個,就叫做 form。

1
2
3
4
<form action="https://small-min.blog.com/delete" method="POST">
<input type="hidden" name="id" value="3"/>
<input type="submit" value="開始測驗"/>
</form>

小明點下去以後,照樣中招,一樣刪除了文章。你可能又會疑惑說,但是這樣小明不就知道了嗎?我跟你一樣很疑惑,於是我 Google 到了這篇:Example of silently submitting a POST FORM (CSRF)

這篇提供的範例如下,網頁的世界真是博大精深:

1
2
3
4
5
6
<iframe style="display:none" name="csrf-frame"></iframe>
<form method='POST' action='https://small-min.blog.com/delete' target="csrf-frame" id="csrf-form">
<input type='hidden' name='id' value='3'>
<input type='submit' value='submit'>
</form>
<script>document.getElementById("csrf-form").submit()</script>

開一個看不見的 iframe,讓 form submit 之後的結果出現在 iframe 裡面,而且這個 form 還可以自動 submit,完全不需要經過小明的任何操作。
到了這步,你就知道改成 POST 是沒用的。
那我後端改成只接收 json 呢?
聰明的你靈機一動:「既然在前端只有 form 可以送出 POST 的話,那我的 api 改成用 json 收資料不就可以了嗎?這樣總不能用 form 了吧!」
spring 的 document告訴你:這還是沒用的!

1
2
3
4
5
<form action="https://small-min.blog.com/delete" method="post" enctype="text/plain">
<input name='{"id":3, "ignore_me":"' value='test"}' type='hidden'>
<input type="submit"
value="delete!"/>
</form>

這樣子會產生如下的 request body:

1
{ "id": 3, "ignore_me": "=test" }

但這邊值得注意的一點是,form能夠帶的 content type 只有三種:application/x-www-form-urlencoded, multipart/form-data 跟 text/plain。在上面的攻擊中我們用的是最後一種,text/plain,如果你在你的後端 Server 有檢查這個 content type 的話,是可以避免掉上面這個攻擊的。
只是,上面這幾個攻擊我們都還沒講到一種情況:如果你的 api 接受 cross origin 的 request 呢?
意思就是,如果你的 api 的 Access-Control-Allow-Origin 設成 * 的話,代表任何 domain 都可以發送 ajax 到你的 api server,這樣無論你是改成 json,或甚至把 method 改成 PUT, DELETE 都沒有用。
我們舉的例子是刪除文章,這你可能覺得沒什麼,那如果是銀行轉帳呢?攻擊者只要在自己的網頁上寫下轉帳給自己帳號的 code,再把這個網頁散佈出去就好,就可以收到一大堆錢。
講了這麼多,來講該怎麼防禦吧!先從最簡單的「使用者」開始講。

使用者的防禦

CSRF 攻擊之所以能成立,是因為使用者在被攻擊的網頁是處於已經登入的狀態,所以才能做出一些行為。雖然說這些攻擊應該由網頁那邊負責處理,但如果你真的很怕,怕網頁會處理不好的話,你可以在每次使用完網站就登出,就可以避免掉 CSRF。
或者呢,關閉執行 js 或把上面這些 pattern 的程式碼過濾掉不要執行,也是一個方法(但應該很難判定哪些是 CSRF 攻擊的程式碼)。
所以使用者能做的其實有限,真的該做事的是 Server 那邊!

Server 的防禦

CSRF 之所以可怕是因為 CS 兩個字:Cross Site,你可以在任何一個網址底下發動攻擊。CSRF 的防禦就可以從這個方向思考,簡單來說就是:「我要怎麼擋掉從別的 domain 來的 request」
你仔細想想,CSRF 的 reuqest 跟使用者本人發出的 request 有什麼區別?區別在於 domain 的不同,前者是從任意一個 domain 發出的,後者是從同一個 domain 發出的(這邊假設你的 api 跟你的前端網站在同一個 domain)

檢查 Referer

request 的 header 裡面會帶一個欄位叫做 referer,代表這個 request 是從哪個地方過來的,可以檢查這個欄位看是不是合法的 domain,不是的話直接 reject 掉即可。
但這個方法要注意的地方有三個,第一個是有些瀏覽器可能不會帶 referer,第二個是有些使用者可能會關閉自動帶 referer 的這個功能,這時候你的 server 就會 reject 掉由真的使用者發出的 request。
第三個是你判定是不是合法 domain 的程式碼必須要保證沒有 bug,例如:

1
2
3
4
const referer = request.headers.referer;
if (referer.indexOf('small-min.blog.com') > -1) {
// pass
}

你看出上面這段的問題了嗎?如果攻擊者的網頁是small-min.blog.com.attack.com的話,你的檢查就破功了。
所以,檢查 referer 並不是一個很完善的解法
加上圖形驗證碼、簡訊驗證碼等等
就跟網路銀行轉帳的時候一樣,都會要你收簡訊驗證碼,多了這一道檢查就可以確保不會被 CSRF 攻擊。
圖形驗證碼也是,攻擊者並不知道圖形驗證碼的答案是什麼,所以就不可能攻擊了。
這是一個很完善的解決方法,但如果使用者每次刪除 blog 都要打一次圖形驗證碼,他們應該會煩死吧!

加上 CSRF token

要防止 CSRF 攻擊,我們其實只要確保有些資訊「只有使用者知道」即可。那該怎麼做呢?
我們在 form 裡面加上一個 hidden 的欄位,叫做csrftoken,這裡面填的值由 server 隨機產生,並且存在 server 的 session 中。

1
2
3
4
5
<form action="https://small-min.blog.com/delete" method="POST">
<input type="hidden" name="id" value="3"/>
<input type="hidden" name="csrftoken" value="fj1iro2jro12ijoi1"/>
<input type="submit" value="刪除文章"/>
</form>

按下 submit 之後,server 比對表單中的csrftoken與自己 session 裡面存的是不是一樣的,是的話就代表這的確是由使用者本人發出的 request。這個 csrftoken 由 server 產生,並且每一段不同的 session 就應該要更換一次。
那這個為什麼可以防禦呢?因為攻擊者並不知道 csrftoken 的值是什麼,也猜不出來,所以自然就無法進行攻擊了。
可是有另外一種狀況,假設你的 server 支持 cross origin 的 request,會發生什麼事呢?攻擊者就可以在他的頁面發起一個 request,順利拿到這個 csrf token 並且進行攻擊。不過前提是你的 server 接受這個 domain 的 request。
接著讓我們來看看另外一種解法

上一種解法需要 server 的 state,亦即 csrf token 必須被保存在 server 當中,才能驗證正確性。而現在這個解法的好處就是完全不需要 server 儲存東西。
這個解法的前半段與剛剛的相似,由 server 產生一組隨機的 token 並且加在 form 上面。但不同的點在於,除了不用把這個值寫在 session 以外,同時也讓 client side 設定一個名叫 csrftoken 的 cookie,值也是同一組 token。

1
2
3
4
5
6
Set-Cookie: csrftoken=fj1iro2jro12ijoi1
<form action="https://small-min.blog.com/delete" method="POST">
<input type="hidden" name="id" value="3"/>
<input type="hidden" name="csrftoken" value="fj1iro2jro12ijoi1"/>
<input type="submit" value="刪除文章"/>
</form>

你可以仔細思考一下 CSRF 攻擊的 request 與使用者本人發出的 request 有什麼不一樣?不一樣的點就在於,前者來自不同的 domain,後者來自相同的 domain。所以我們只要有辦法區分出這個 request 是不是從同樣的 domain 來,我們就勝利了。
而 Double Submit Cookie 這個解法正是從這個想法出發。
當使用者按下 submit 的時候,server 比對 cookie 內的 csrftoken 與 form 裡面的 csrftoken,檢查是否有值並且相等,就知道是不是使用者發的了。
為什麼呢?假設現在攻擊者想要攻擊,他可以隨便在 form 裡面寫一個 csrf token,這當然沒問題,可是因為瀏覽器的限制,他並不能在他的 domain 設定 small-min.blog.com 的 cookie 啊!所以他發上來的 request 的 cookie 裡面就沒有 csrftoken,就會被擋下來。
當然,這個方法看似好用,但也是有缺點的,可以參考:Double Submit Cookies vulnerabilities,攻擊者如果掌握了你底下任何一個 subdomain,就可以幫你來寫 cookie,並且順利攻擊了。
client side 的 Double Submit Cookie
會特別提到 client side,是因為我之前所碰到的專案是 Single Page Application,上網搜尋一下就會發現有人在問:「SPA 該如何拿到 CSRF token?」,難道要 server 再提供一個 api 嗎?這樣好像有點怪怪的。
但是呢,我認為我們可以利用 Double Submit Cookie 的精神來解決這個問題。而解決這問題的關鍵就在於:由 client side 來生 csrf token。就不用跟 server api 有任何的互動。
其他的流程都跟之前一樣,生成之後放到 form 裡面以及寫到 cookie。或者說如果你是 SPA 的話,也可以把這資訊直接放到 request header,你就不用在每一個表單都做這件事情,只要統一加一個地方就好。
事實上,我自己常用的 library axios 就有提供這樣的功能,你可以設置 header 名稱跟 cookie 名稱,設定好以後你每一個 request,它都會自動幫你把 header 填上 cookie 裡面的值。

1
2
3
4
 // `xsrfCookieName` is the name of the cookie to use as a value for xsrf token
xsrfCookieName: 'XSRF-TOKEN', // default
// `xsrfHeaderName` is the name of the http header that carries the xsrf token value
xsrfHeaderName: 'X-XSRF-TOKEN', // default

那為什麼由 client 來生這個 token 也可以呢?因為這個 token 本身的目的其實不包含任何資訊,只是為了「不讓攻擊者」猜出而已,所以由 client 還是由 server 來生成都是一樣的,只要確保不被猜出來即可。Double Submit Cookie 靠的核心概念是:「攻擊者的沒辦法讀寫目標網站的 cookie,所以 request 的 csrf token 會跟 cookie 內的不一樣」

browser 本身的防禦

我們剛剛提到了使用者自己可以做的事、網頁前後端可以做的事情,那瀏覽器呢?之所以能成立 CSRF,是因為瀏覽器的機制所導致的,有沒有可能從瀏覽器方面下手,來解決這個問題呢?
有!而且已經有了。而且啟用的方法非常非常簡單。
Google 在 Chrome 51 版的時候正式加入了這個功能:SameSite cookie,對詳細運行原理有興趣的可參考:draft-west-first-party-cookies-07。

先引一下 Google 的說明:

1
Same-site cookies (née “First-Party-Only” (née “First-Party”)) allow servers to mitigate the risk of CSRF and information leakage attacks by asserting that a particular cookie should only be sent with requests initiated from the same registrable domain.

啟用這個功能有多簡單?超級無敵簡單。
你原本設置 Cookie 的 header 長這樣:

1
Set-Cookie: session_id=ewfewjf23o1;

你只要在後面多加一個 SameSite 就好:

1
Set-Cookie: session_id=ewfewjf23o1; SameSite

但其實 SameSite 有兩種模式,Lax跟Strict,默認是後者,你也可以自己指定模式:

1
2
Set-Cookie: session_id=ewfewjf23o1; SameSite=Strict
Set-Cookie: foo=bar; SameSite=Lax

我們先來談談默認的 Strict模式
當你加上 SameSite 這個關鍵字之後,就代表說「我這個 cookie 只允許 same site 使用,不應該在任何的 cross site request 被加上去」。
意思就是你加上去之後,我們上面所講的<a href="">, <form>, new XMLHttpRequest,只要是瀏覽器驗證不是在同一個 site 底下發出的request,全部都不會帶上這個 cookie。
可是這樣其實會有個問題,連<a href="..."都不會帶上cookie 的話,當我從 Google 搜尋結果或者是朋友貼給我的連結點進某個網站的時候,因為不會帶 cookie 的關係,所以那個網站就會變成是登出狀態。這樣子的使用者體驗非常不好。
有兩種解法,第一種是跟 Amazon 一樣,準備兩組不同的cookie,第一組是讓你維持登入狀態,第二組則是做一些敏感操作的時候會需要用到的(例如說購買、設定帳戶等等)。第一組不設定 SameSite,所以無論你從哪邊來,都會是登入狀態。但攻擊者就算有第一組 cookie 也不能幹嘛,因為不能做任何操作。第二組因為設定了 SameSite的緣故,所以完全避免掉 CSRF。
但這樣子還是有點小麻煩,所以你可以考慮第二種,就是調整為 SameSite 的另一種模式:Lax。
Lax 模式放寬了一些限制,例如說<a>, <link rel="prerender">, <form method="GET"> 這些都還是會帶上 cookie。但是 POST 方法 的 form,或是只要是 POST, PUT, DELETE 這些方法,就不會帶上 cookie。
所以一方面你可以保有彈性,讓使用者從其他網站連進你的網站時還能夠維持登入狀態,一方面也可以防止掉 CSRF 攻擊。但 Lax 模式之下就沒辦法擋掉 GET 形式的 CSRF,這點要特別注意一下。
講到這種比較新的東西,相信大家一定都很想知道瀏覽器的支援度如何,caniuse 告訴我們說:目前只有 Chrome 支援這個新的特性(畢竟是 Google 自己推的方案,自己當然要支持一下)。
雖然瀏覽器的支援度不太高,但日後其他瀏覽器可能也會跟進實做這個方案,不妨在現在就把 SameSite 加上去,以後就不用再為 CSRF 煩惱了。

http://blog.techbridge.cc/2017/02/25/csrf-introduction/