对于网络安全

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

常见的应用层攻打伎俩

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.jsconst Yup = require('yup');exports.createShopFormSchema = () =>  Yup.object({    name: Yup.string()      .required('店铺名不能为空')      .min(3, '店铺名至多 3 个字符')      .max(120, '店铺名不可超过 120 字'),  });

XSS 攻打 1 百草味<script>alert('XSS 攻打 1 胜利 ????')</script>

XSS 攻打 2 广州酒家<img src="_" onerror="alert('XSS 攻打 2 胜利 ????')"/>

基于 innerHTML 更新 DOM 时 script 标签不会执行(详见规范),所以 XSS 攻打 1 有效。在换了新的写法后,XSS 攻打 2 就失效了。

强化网络安全

接下来通过 escape-html、csurf、helmet 对以后工程的网络安全进行强化,在工程根目录执行以下装置命令:

$ 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.jsconst 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.jsconst { 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 胜利 ????')"/>

这样就能够抵挡 XSS 攻打了,当初再预防一下 CSRF 攻打:

// src/middlewares/index.jsconst { 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.jsconst { 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 服务器(八):网络安全