計算機網絡依據 TCP/IP 協議棧分爲了物理層、網絡層、傳輸層、應用層,一般基礎設施供應商會解決好前三層的網絡安全問題,須要開發者自行解決應用層的網絡安全問題,本章將着重表述應用層常見的網絡安全問題及處理方法。css
XSS(cross-site scripting),跨站腳本攻擊,經過在頁面中注入腳本發起攻擊。舉個例子:我在一個有 XSS 缺陷的在線商城開了一家店鋪,編輯商品詳情頁時提交了這樣的描述:特製辣醬<script src="https://cross-site.scripting/attack.js"></script>
,當用戶訪問該商品的詳情時 attack.js
就被執行了,我經過該腳本能夠在用戶不知情的狀況下竊取數據或者發起操做,好比:把用戶正在瀏覽的商品加入到購物車。html
CSRF(cross-site request forgery),跨站請求僞造,經過僞造用戶數據請求發起攻擊。舉個例子:我在一個有 CSRF 缺陷的論壇回覆了一則熱門帖:贊!<img src="/api/cross-site?request=forgery" />
,當用戶訪問到這條回覆時 img
標籤就會在用戶不知情的狀況下以該用戶的身份發起提早設置的請求,好比:轉 1 積分到我本身的賬號上。html5
SQLi(SQL injection),SQL 注入,經過在數據庫操做注入 SQL 片斷髮起攻擊。SQLi 是很是危險的攻擊,能夠繞過系統中的各類限制直接對數據進行竊取和篡改。但同時, SQLi 又是比較容易防範的,只要對入參字符串作好轉義處理就能夠規避,常見的 ORM 模塊都作好了此類處理。node
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>
:數據庫
XSS 攻擊 2 廣州酒家<img src="_" onerror="alert('XSS 攻擊 2 成功 🤪')"/>
:express
基於 innerHTML 更新 DOM 時 script 標籤不會執行(詳見標準),因此 XSS 攻擊 1 無效。在換了新的寫法後,XSS 攻擊 2 就生效了。npm
接下來經過 escape-html、csurf、helmet 對當前工程的網絡安全進行強化,在工程根目錄執行如下安裝命令: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 成功 🤪')"/>
:
這樣就能夠抵禦 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 服務器(八):網絡安全