从零搭建 Node.js 企业级 Web 服务器(八):网络安全

关于网络安全

计算机网络依据 TCP/IP 协议栈分为了物理层、网络层、传输层、应用层,一般基础设施供应商会解决好前三层的网络安全问题,须要开发者自行解决应用层的网络安全问题,本章将着重表述应用层常见的网络安全问题及处理方法。css

9f68f11809933ff550cff53d48bdf1a726f9fb4f.jpg

常见的应用层攻击手段

XSS

XSS(cross-site scripting),跨站脚本攻击,经过在页面中注入脚本发起攻击。举个例子:我在一个有 XSS 缺陷的在线商城开了一家店铺,编辑商品详情页时提交了这样的描述:特制辣酱<script src="https://cross-site.scripting/attack.js"></script>,当用户访问该商品的详情时 attack.js 就被执行了,我经过该脚本能够在用户不知情的状况下窃取数据或者发起操做,好比:把用户正在浏览的商品加入到购物车。html

CSRF

CSRF(cross-site request forgery),跨站请求伪造,经过伪造用户数据请求发起攻击。举个例子:我在一个有 CSRF 缺陷的论坛回复了一则热门帖:赞!<img src="/api/cross-site?request=forgery" />,当用户访问到这条回复时 img 标签就会在用户不知情的状况下以该用户的身份发起提早设置的请求,好比:转 1 积分到我本身的账号上。html5

SQLi

SQLi(SQL injection),SQL 注入,经过在数据库操做注入 SQL 片断发起攻击。SQLi 是很是危险的攻击,能够绕过系统中的各类限制直接对数据进行窃取和篡改。但同时, SQLi 又是比较容易防范的,只要对入参字符串作好转义处理就能够规避,常见的 ORM 模块都作好了此类处理。node

DoS

DoS(denial-of-service),拒绝服务攻击,经过大量的无效访问让应用陷入瘫痪。在 DoS 基础上又有 DDoS(distributed denial-of-service),分布式拒绝服务攻击,是增强版的 DoS。一般此类攻击在传输层就已经作好了过滤,应用层通常在集群入口也作了过滤,应用节点不须要再关心。git

攻击测试

再回到上一章已完成的工程 host1-tech/nodejs-server-examples - 07-authentication,当前的店铺管理功刚好由于店铺名称长度校验限制和没有基于 http get 的变动接口而必定程度上规避了 XSS 和 CSRF 缺陷,另外由于数据库访问基于 ORM 实现也基本规避了 SQLi 缺陷。如今把长度校验放松以进行 XSS 攻击测试:github

// src/moulds/ShopForm.js
const Yup = require('yup');

exports.createShopFormSchema = () =>
  Yup.object({
    name: Yup.string()
      .required('店铺名不能为空')
      .min(3, '店铺名至少 3 个字符')
      .max(120, '店铺名不可超过 120 字'),
  });

XSS 攻击 1 百草味<script>alert('XSS 攻击 1 成功 🤪')</script>数据库

01d6e48c1c1817334982f49279dc7ec2b8445b03.gif

XSS 攻击 2 广州酒家<img src="_" onerror="alert('XSS 攻击 2 成功 🤪')"/>express

0212343bd7e003b73a508cea89d3ea47a62fc56c.gif

基于 innerHTML 更新 DOM 时 script 标签不会执行(详见标准),因此 XSS 攻击 1 无效。在换了新的写法后,XSS 攻击 2 就生效了。npm

强化网络安全

接下来经过 escape-htmlcsurfhelmet 对当前工程的网络安全进行强化,在工程根目录执行如下安装命令:segmentfault

$ yarn add escape-html csurf helmet # 本地安装 escape-html、csurf、helmet
# ...
info Direct dependencies
├─ csurf@1.11.0
├─ escape-html@1.0.3
└─ helmet@3.23.3
# ...

对店铺信息输出作转义处理:

// src/utils/escape-html-in-object.js
const escapeHtml = require('escape-html');

module.exports = function escapeHtmlInObject(input) {
  // 尝试将 ORM 对象转化为普通对象
  try {
    input = input.toJSON();
  } catch {}

  // 对类型为 string 的值转义处理
  if (Array.isArray(input)) {
    return input.map(escapeHtmlInObject);
  } else if (typeof input == 'object') {
    const output = {};
    Object.keys(input).forEach(k => {
      output[k] = escapeHtmlInObject(input[k]);
    });
    return output;
  } else if (typeof input == 'string') {
    return escapeHtml(input);
  } else {
    return input;
  }
};
// src/controllers/shop.js
const { Router } = require('express');
const bodyParser = require('body-parser');
const shopService = require('../services/shop');
const { createShopFormSchema } = require('../moulds/ShopForm');
const cc = require('../utils/cc');
+const escapeHtmlInObject = require('../utils/escape-html-in-object');

class ShopController {
  shopService;

  async init() {
    this.shopService = await shopService();

    const router = Router();
    router.get('/', this.getAll);
    router.get('/:shopId', this.getOne);
    router.put('/:shopId', this.put);
    router.delete('/:shopId', this.delete);
    router.post('/', bodyParser.urlencoded({ extended: false }), this.post);
    return router;
  }

  getAll = cc(async (req, res) => {
    const { pageIndex, pageSize } = req.query;
    const shopList = await this.shopService.find({ pageIndex, pageSize });

-    res.send({ success: true, data: shopList });
+    res.send(escapeHtmlInObject({ success: true, data: shopList }));
  });

  getOne = cc(async (req, res) => {
    const { shopId } = req.params;
    const shopList = await this.shopService.find({ id: shopId });

    if (shopList.length) {
-      res.send({ success: true, data: shopList[0] });
+      res.send(escapeHtmlInObject({ success: true, data: shopList[0] }));
    } else {
      res.status(404).send({ success: false, data: null });
    }
  });

  put = cc(async (req, res) => {
    const { shopId } = req.params;
    const { name } = req.query;

    try {
      await createShopFormSchema().validate({ name });
    } catch (e) {
      res.status(400).send({ success: false, message: e.message });
      return;
    }

    const shopInfo = await this.shopService.modify({
      id: shopId,
      values: { name },
    });

    if (shopInfo) {
-      res.send({ success: true, data: shopInfo });
+      res.send(escapeHtmlInObject({ success: true, data: shopInfo }));
    } else {
      res.status(404).send({ success: false, data: null });
    }
  });

  delete = cc(async (req, res) => {
    const { shopId } = req.params;
    const success = await this.shopService.remove({ id: shopId });

    if (!success) {
      res.status(404);
    }
    res.send({ success });
  });

  post = cc(async (req, res) => {
    const { name } = req.body;

    try {
      await createShopFormSchema().validate({ name });
    } catch (e) {
      res.status(400).send({ success: false, message: e.message });
      return;
    }

    const shopInfo = await this.shopService.create({ values: { name } });

-    res.send({ success: true, data: shopInfo });
+    res.send(escapeHtmlInObject({ success: true, data: shopInfo }));
  });
}

module.exports = async () => {
  const c = new ShopController();
  return await c.init();
};

再次尝试 XSS 攻击 2 广州酒家<img src="_" onerror="alert('XSS 攻击 2 成功 🤪')"/>

0dbee9993f5c51d1981d027f1eeb81c2a2fad7d2.gif

这样就能够抵御 XSS 攻击了,如今再预防一下 CSRF 攻击:

// src/middlewares/index.js
const { Router } = require('express');
const cookieParser = require('cookie-parser');
+const bodyParser = require('body-parser');
+const csurf = require('csurf');
const sessionMiddleware = require('./session');
const urlnormalizeMiddleware = require('./urlnormalize');
const loginMiddleware = require('./login');
const authMiddleware = require('./auth');

const secret = '842d918ced1888c65a650f993077c3d36b8f114d';

module.exports = async function initMiddlewares() {
  const router = Router();
  router.use(urlnormalizeMiddleware());
  router.use(cookieParser(secret));
  router.use(sessionMiddleware(secret));
  router.use(loginMiddleware());
  router.use(authMiddleware());
+  router.use(bodyParser.urlencoded({ extended: false }), csurf());
  return router;
};
// src/controllers/csrf.js
const { Router } = require('express');

class CsrfController {
  async init() {
    const router = Router();
    router.get('/script', this.getScript);
    return router;
  }

  getScript = (req, res) => {
    res.type('js');
    res.send(`window.__CSRF_TOKEN__='${req.csrfToken()}';`);
  };
}

module.exports = async () => {
  const c = new CsrfController();
  return await c.init();
};
const { parse } = require('url');

module.exports = function loginMiddleware(
  homepagePath = '/',
  loginPath = '/login.html',
  whiteList = {
    '/500.html': ['get'],
    '/api/health': ['get'],
+    '/api/csrf/script': ['get'],
    '/api/login': ['post'],
    '/api/login/github': ['get'],
    '/api/login/github/callback': ['get'],
  }
) {
  // ...
};
<!-- public/login.html -->
<html>
  <head>
    <meta charset="utf-8" />
+    <script src="/api/csrf/script"></script>
  </head>
  <body>
    <form method="post" action="/api/login">
+      <script>
+        document.write(
+          `<input type="hidden" name="_csrf" value="${__CSRF_TOKEN__}" />`
+        );
+      </script>
      <button type="submit">一键登陆</button>
    </form>
    <a href="/api/login/github"><button>Github 登陆</button></a>
  </body>
</html>
<!-- public/index.html -->
<html>
  <head>
    <meta charset="utf-8" />
    <link rel="stylesheet" href="./index.css" />
+    <script src="/api/csrf/script"></script>
  </head>
  <!-- ... -->
</html>
// public/index.js
// ...
export async function modifyShopInfo(e) {
  const shopId = e.target.parentElement.dataset.shopId;
  const name = e.target.parentElement.querySelector('input').value;

  try {
    await createShopFormSchema().validate({ name });
  } catch ({ message }) {
    e.target.parentElement.querySelector('.error').innerHTML = message;
    return;
  }

  await fetch(`/api/shop/${shopId}?name=${encodeURIComponent(name)}`, {
    method: 'PUT',
+    headers: {
+      'Csrf-Token': __CSRF_TOKEN__,
+    },
  });
  await refreshShopList();
}

export async function removeShopInfo(e) {
  const shopId = e.target.parentElement.dataset.shopId;
-  const res = await fetch(`/api/shop/${shopId}`, { method: 'DELETE' });
+  const res = await fetch(`/api/shop/${shopId}`, {
+    method: 'DELETE',
+    headers: {
+      'Csrf-Token': __CSRF_TOKEN__,
+    },
  });
  await refreshShopList();
}

export async function createShopInfo(e) {
  e.preventDefault();
  const name = e.target.parentElement.querySelector('input[name=name]').value;

  try {
    await createShopFormSchema().validate({ name });
  } catch ({ message }) {
    e.target.parentElement.querySelector('.error').innerHTML = message;
    return;
  }

  await fetch('/api/shop', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
+      'Csrf-Token': __CSRF_TOKEN__,
    },
    body: `name=${encodeURIComponent(name)}`,
  });

  await refreshShopList();
}

最后,使用 helmet 模块经过 http 头控制浏览器提供更安全的环境:

const { Router } = require('express');
const cookieParser = require('cookie-parser');
const bodyParser = require('body-parser');
const csurf = require('csurf');
+const helmet = require('helmet');
const sessionMiddleware = require('./session');
const urlnormalizeMiddleware = require('./urlnormalize');
const loginMiddleware = require('./login');
const authMiddleware = require('./auth');

const secret = '842d918ced1888c65a650f993077c3d36b8f114d';

module.exports = async function initMiddlewares() {
  const router = Router();
+  router.use(helmet());
  router.use(urlnormalizeMiddleware());
  router.use(cookieParser(secret));
  router.use(sessionMiddleware(secret));
  router.use(loginMiddleware());
  router.use(authMiddleware());
  router.use(bodyParser.urlencoded({ extended: false }), csurf());
  return router;
};

以上是 Node.js 中经常使用的安全防范措施,有兴趣的读者能够在 OWASP 进一步了解。

本章源码

host1-tech/nodejs-server-examples - 08-security

更多阅读

从零搭建 Node.js 企业级 Web 服务器(零):静态服务
从零搭建 Node.js 企业级 Web 服务器(一):接口与分层
从零搭建 Node.js 企业级 Web 服务器(二):校验
从零搭建 Node.js 企业级 Web 服务器(三):中间件
从零搭建 Node.js 企业级 Web 服务器(四):异常处理
从零搭建 Node.js 企业级 Web 服务器(五):数据库访问
从零搭建 Node.js 企业级 Web 服务器(六):会话
从零搭建 Node.js 企业级 Web 服务器(七):认证登陆从零搭建 Node.js 企业级 Web 服务器(八):网络安全