├── .eslintignore ├── .gitattributes ├── .npmrc ├── .remarkrc.js ├── .commitlintrc.js ├── .husky ├── commit-msg └── pre-commit ├── .xo-config.js ├── .prettierrc.js ├── .lintstagedrc.js ├── .editorconfig ├── .gitignore ├── .eslintrc ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── index.js ├── package.json ├── README.md └── test └── test.js /.eslintignore: -------------------------------------------------------------------------------- 1 | !.*.js 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.remarkrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ['preset-github'] 3 | }; 4 | -------------------------------------------------------------------------------- /.commitlintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'] 3 | }; 4 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.xo-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | prettier: true, 3 | space: true, 4 | extends: ['xo-lass'] 5 | }; 6 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged && npm test 5 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | bracketSpacing: true, 4 | trailingComma: 'none' 5 | }; 6 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.md': (filenames) => filenames.map((filename) => `remark ${filename} -qfo`), 3 | 'package.json': 'fixpack', 4 | '*.js': 'xo --fix' 5 | }; 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | .idea 4 | node_modules 5 | coverage 6 | .nyc_output 7 | locales/ 8 | package-lock.json 9 | yarn.lock 10 | 11 | Thumbs.db 12 | tmp/ 13 | temp/ 14 | *.lcov 15 | .env -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:node/recommended" 5 | ], 6 | "rules": { 7 | "no-unsafe-finally": "warn", 8 | "no-cond-assign": "warn", 9 | "no-console": "warn", 10 | "no-control-regex": "warn", 11 | "no-empty": "warn", 12 | "no-extra-semi": "warn", 13 | "no-func-assign": "warn", 14 | "no-undef": "warn", 15 | "no-unused-vars": "warn", 16 | "no-useless-escape": "warn", 17 | "node/no-deprecated-api": "warn" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | os: 11 | - ubuntu-latest 12 | node_version: 13 | - 14 14 | - 16 15 | - 18 16 | name: Node ${{ matrix.node_version }} on ${{ matrix.os }} 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Setup node 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node_version }} 23 | - name: Install dependencies 24 | run: npm install 25 | - name: Run tests 26 | run: npm run test 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2014 Jonathan Ong & Nick Baugh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const csrf = require('csrf'); 2 | const isSANB = require('is-string-and-not-blank'); 3 | const multimatch = require('multimatch'); 4 | 5 | function CSRF(opts = {}) { 6 | const tokens = csrf(opts); 7 | 8 | opts = { 9 | errorHandler(ctx) { 10 | return ctx.throw(403, 'Invalid CSRF token'); 11 | }, 12 | excludedMethods: ['GET', 'HEAD', 'OPTIONS'], 13 | disableQuery: false, 14 | ignoredPathGlobs: [], 15 | ...opts 16 | }; 17 | 18 | // eslint-disable-next-line complexity 19 | return async function (ctx, next) { 20 | if (!ctx.session) return next(); 21 | 22 | if (!ctx.session.secret) ctx.session.secret = await tokens.secret(); 23 | 24 | if (!ctx.state._csrf) ctx.state._csrf = tokens.create(ctx.session.secret); 25 | 26 | if (opts.excludedMethods.includes(ctx.method)) return next(); 27 | 28 | // check against ignored/whitelisted redirect middleware paths 29 | if ( 30 | Array.isArray(opts.ignoredPathGlobs) && 31 | opts.ignoredPathGlobs.length > 0 32 | ) { 33 | const match = multimatch(ctx.path, opts.ignoredPathGlobs); 34 | if (Array.isArray(match) && match.length > 0) return next(); 35 | } 36 | 37 | const bodyToken = isSANB(ctx.request.body._csrf) 38 | ? ctx.request.body._csrf 39 | : false; 40 | 41 | const queryToken = 42 | !bodyToken && !opts.disableQuery && ctx.query && isSANB(ctx.query._csrf) 43 | ? ctx.query._csrf 44 | : false; 45 | 46 | const token = 47 | bodyToken || 48 | queryToken || 49 | ctx.get('csrf-token') || 50 | ctx.get('xsrf-token') || 51 | ctx.get('x-csrf-token') || 52 | ctx.get('x-xsrf-token'); 53 | 54 | if (!token || !tokens.verify(ctx.session.secret, token)) 55 | return opts.errorHandler(ctx); 56 | 57 | return next(); 58 | }; 59 | } 60 | 61 | module.exports = CSRF; 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "koa-csrf", 3 | "description": "CSRF tokens for Koa", 4 | "version": "5.0.1", 5 | "author": { 6 | "name": "Jonathan Ong", 7 | "email": "me@jongleberry.com", 8 | "url": "http://jongleberry.com", 9 | "twitter": "https://twitter.com/jongleberry" 10 | }, 11 | "bugs": "koajs/csrf/issues", 12 | "contributors": [ 13 | { 14 | "name": "Nick Baugh", 15 | "email": "niftylettuce@gmail.com", 16 | "url": "https://github.com/niftylettuce" 17 | }, 18 | { 19 | "name": "Imed Jaberi", 20 | "email": "imed_jebari@hotmail.fr", 21 | "url": "https://www.3imed-jaberi.com/" 22 | } 23 | ], 24 | "dependencies": { 25 | "csrf": "^3.1.0", 26 | "is-string-and-not-blank": "^0.0.2", 27 | "multimatch": "5" 28 | }, 29 | "devDependencies": { 30 | "@commitlint/cli": "^17.0.3", 31 | "@commitlint/config-conventional": "^17.0.3", 32 | "ava": "^4.3.0", 33 | "cross-env": "^7.0.3", 34 | "eslint": "^8.19.0", 35 | "eslint-config-xo-lass": "^2.0.1", 36 | "fixpack": "^4.0.0", 37 | "husky": "^8.0.1", 38 | "koa": "^2.13.4", 39 | "koa-bodyparser": "^4.3.0", 40 | "koa-convert": "^2.0.0", 41 | "koa-generic-session": "^2.3.0", 42 | "lint-staged": "^13.0.3", 43 | "nyc": "^15.1.0", 44 | "remark-cli": "^11.0.0", 45 | "remark-preset-github": "^4.0.4", 46 | "supertest": "^6.2.4", 47 | "xo": "^0.50.0" 48 | }, 49 | "engines": { 50 | "node": ">= 14" 51 | }, 52 | "files": [ 53 | "index.js" 54 | ], 55 | "homepage": "https://github.com/koajs/csrf", 56 | "keywords": [ 57 | "cross", 58 | "csrf", 59 | "forgery", 60 | "koa", 61 | "koa2", 62 | "koa@2", 63 | "koa@next", 64 | "koanext", 65 | "middleware", 66 | "next", 67 | "request", 68 | "security", 69 | "site" 70 | ], 71 | "license": "MIT", 72 | "main": "index.js", 73 | "repository": "koajs/csrf", 74 | "scripts": { 75 | "lint": "xo --fix && remark . -qfo && fixpack", 76 | "prepare": "husky install", 77 | "pretest": "npm run lint", 78 | "test": "cross-env NODE_ENV=test nyc ava" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # koa-csrf 2 | 3 | [![build status](https://github.com/koajs/csrf/actions/workflows/ci.yml/badge.svg)](https://github.com/koajs/csrf/actions/workflows/ci.yml) 4 | [![build status](https://img.shields.io/travis/koajs/csrf.svg)](https://travis-ci.com/koajs/csrf) 5 | [![code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/sindresorhus/xo) 6 | [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 7 | [![made with lass](https://img.shields.io/badge/made_with-lass-95CC28.svg)](https://lass.js.org) 8 | [![license](https://img.shields.io/github/license/koajs/csrf.svg)](LICENSE) 9 | 10 | > CSRF tokens for Koa 11 | 12 | > **NOTE:** As of v5.0.0+ `ctx.csrf`, `ctx_csrf`, and `ctx.response.csrf` are removed – instead use `ctx.state._csrf`. Furthermore we have dropped `invalidTokenMessage` and `invalidTokenStatusCode` in favor of an `errorHandler` function option. 13 | 14 | 15 | ## Table of Contents 16 | 17 | * [Install](#install) 18 | * [Usage](#usage) 19 | * [Options](#options) 20 | * [Contributors](#contributors) 21 | * [License](#license) 22 | 23 | 24 | ## Install 25 | 26 | [npm][]: 27 | 28 | ```sh 29 | npm install koa-csrf 30 | ``` 31 | 32 | 33 | ## Usage 34 | 35 | 1. Add middleware in Koa app (see [options](#options) below): 36 | 37 | ```js 38 | const Koa = require('koa'); 39 | const bodyParser = require('koa-bodyparser'); 40 | const session = require('koa-generic-session'); 41 | const convert = require('koa-convert'); 42 | const CSRF = require('koa-csrf'); 43 | 44 | const app = new Koa(); 45 | 46 | // set the session keys 47 | app.keys = [ 'a', 'b' ]; 48 | 49 | // add session support 50 | app.use(convert(session())); 51 | 52 | // add body parsing 53 | app.use(bodyParser()); 54 | 55 | // add the CSRF middleware 56 | app.use(new CSRF()); 57 | 58 | // your middleware here (e.g. parse a form submit) 59 | app.use((ctx, next) => { 60 | if (![ 'GET', 'POST' ].includes(ctx.method)) 61 | return next(); 62 | if (ctx.method === 'GET') { 63 | ctx.body = ctx.state._csrf; 64 | return; 65 | } 66 | ctx.body = 'OK'; 67 | }); 68 | 69 | app.listen(); 70 | ``` 71 | 72 | 2. Add the CSRF token in your template forms: 73 | 74 | > Jade Template: 75 | 76 | ```jade 77 | form(action='/register', method='POST') 78 | input(type='hidden', name='_csrf', value=_csrf) 79 | input(type='email', name='email', placeholder='Email') 80 | input(type='password', name='password', placeholder='Password') 81 | button(type='submit') Register 82 | ``` 83 | 84 | > EJS Template: 85 | 86 | ```ejs 87 |
88 | 89 | 90 | 91 | 92 |
93 | ``` 94 | 95 | 96 | ## Options 97 | 98 | * `errorHandler` (Function) - defaults to a function that returns `ctx.throw(403, 'Invalid CSRF token')` 99 | * `excludedMethods` (Array) - defaults to `[ 'GET', 'HEAD', 'OPTIONS' ]` 100 | * `disableQuery` (Boolean) - defaults to `false` 101 | * `ignoredPathGlobs` (Array) - defaults to an empty Array, but you can pass an Array of glob paths to ignore 102 | 103 | 104 | ## Contributors 105 | 106 | | Name | Website | 107 | | --------------- | --------------------------------- | 108 | | **Nick Baugh** | | 109 | | **Imed Jaberi** | | 110 | 111 | 112 | ## License 113 | 114 | [MIT](LICENSE) © [Jonathan Ong](http://jongleberry.com) 115 | 116 | 117 | ## 118 | 119 | [npm]: https://www.npmjs.com/ 120 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const Koa = require('koa'); 3 | const bodyParser = require('koa-bodyparser'); 4 | const session = require('koa-generic-session'); 5 | const supertest = require('supertest'); 6 | 7 | const CSRF = require('..'); 8 | 9 | const tokenRegExp = /^\w+-[\w+/-]+/; 10 | 11 | test.before((t) => { 12 | t.context.request = getApp(); 13 | t.context.requestWithOpts = getApp({ 14 | disableQuery: true, 15 | ignoredPathGlobs: ['/beep'] 16 | }); 17 | }); 18 | 19 | test('should create a token', async (t) => { 20 | const res = await t.context.request.get('/'); 21 | t.is(res.status, 200); 22 | t.regex(res.text, tokenRegExp); 23 | }); 24 | 25 | test('should create a new token every request', async (t) => { 26 | const res1 = await t.context.request.get('/'); 27 | t.is(res1.status, 200); 28 | t.regex(res1.text, tokenRegExp); 29 | const res2 = await t.context.request.get('/'); 30 | t.regex(res2.text, tokenRegExp); 31 | t.true(res1.text !== res2.text); 32 | }); 33 | 34 | test('should be invalid error when token is missing', async (t) => { 35 | const res = await t.context.request.post('/'); 36 | t.is(res.status, 403); 37 | t.is(res.text, 'Invalid CSRF token'); 38 | }); 39 | 40 | test('should be invalid when token is incorrect', async (t) => { 41 | const res = await t.context.request.post('/').send({ 42 | _csrf: 'wrong csrf token' 43 | }); 44 | t.is(res.status, 403); 45 | t.is(res.text, 'Invalid CSRF token'); 46 | }); 47 | 48 | test('should be valid when token is provided via json body', async (t) => { 49 | const res1 = await t.context.request.get('/'); 50 | t.is(res1.status, 200); 51 | const res2 = await t.context.request.post('/').send({ 52 | _csrf: res1.text 53 | }); 54 | t.is(res2.status, 200); 55 | }); 56 | 57 | test('should be valid when token is provided via query string', async (t) => { 58 | const res1 = await t.context.request.get('/'); 59 | t.is(res1.status, 200); 60 | const res2 = await t.context.request.post( 61 | '/?_csrf=' + encodeURIComponent(res1.text) 62 | ); 63 | t.is(res2.status, 200); 64 | }); 65 | 66 | test('should be valid when token is provided via csrf-token header', async (t) => { 67 | const res1 = await t.context.request.get('/'); 68 | const res2 = await t.context.request.post('/').set('csrf-token', res1.text); 69 | t.is(res2.status, 200); 70 | }); 71 | 72 | test('should be valid when token is provided via xsrf-token header', async (t) => { 73 | const res1 = await t.context.request.get('/'); 74 | const res2 = await t.context.request.post('/').set('xsrf-token', res1.text); 75 | t.is(res2.status, 200); 76 | }); 77 | 78 | test('should be valid when token is provided via x-csrf-token header', async (t) => { 79 | const res1 = await t.context.request.get('/'); 80 | const res2 = await t.context.request.post('/').set('x-csrf-token', res1.text); 81 | t.is(res2.status, 200); 82 | }); 83 | 84 | test('should be valid when token is provided via x-xsrf-token header', async (t) => { 85 | const res1 = await t.context.request.get('/'); 86 | const res2 = await t.context.request.post('/').set('x-xsrf-token', res1.text); 87 | t.is(res2.status, 200); 88 | }); 89 | 90 | test('should not respect the _csrf querystring given disableQuery=true', async (t) => { 91 | const res1 = await t.context.requestWithOpts.get('/'); 92 | const res2 = await t.context.requestWithOpts.post( 93 | '/?_csrf=' + encodeURIComponent(res1.text) 94 | ); 95 | t.is(res2.status, 403); 96 | t.is(res2.text, 'Invalid CSRF token'); 97 | }); 98 | 99 | test('should ignore CSRF validation when ignoredPathGlobs matches', async (t) => { 100 | await t.context.requestWithOpts.get('/'); 101 | await t.context.requestWithOpts.post('/beep'); 102 | const res = await t.context.requestWithOpts.post('/boop'); 103 | t.is(res.status, 403); 104 | t.is(res.text, 'Invalid CSRF token'); 105 | }); 106 | 107 | function getApp(opts = {}) { 108 | const app = new Koa(); 109 | app.keys = ['a', 'b']; 110 | app.use(session()); 111 | app.use(bodyParser()); 112 | app.use(new CSRF(opts)); 113 | app.use((ctx, next) => { 114 | if (!['GET', 'POST'].includes(ctx.method)) return next(); 115 | if (ctx.method === 'GET') { 116 | ctx.body = ctx.state._csrf; 117 | return; 118 | } 119 | 120 | ctx.body = 'OK'; 121 | }); 122 | return supertest.agent(app.listen()); 123 | } 124 | --------------------------------------------------------------------------------