diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c6261c2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,165 @@ +name: ci + +on: +- pull_request +- push + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + name: + - Node.js 0.8 + - Node.js 0.10 + - Node.js 0.12 + - io.js 1.x + - io.js 2.x + - io.js 3.x + - Node.js 4.x + - Node.js 5.x + - Node.js 6.x + - Node.js 7.x + - Node.js 8.x + - Node.js 9.x + - Node.js 10.x + - Node.js 11.x + - Node.js 12.x + - Node.js 13.x + - Node.js 14.x + - Node.js 15.x + - Node.js 16.x + + include: + - name: Node.js 0.8 + node-version: "0.8" + npm-i: mocha@2.5.3 supertest@1.1.0 + npm-rm: nyc + + - name: Node.js 0.10 + node-version: "0.10" + npm-i: mocha@3.5.3 nyc@10.3.2 supertest@2.0.0 + + - name: Node.js 0.12 + node-version: "0.12" + npm-i: mocha@3.5.3 nyc@10.3.2 supertest@2.0.0 + + - name: io.js 1.x + node-version: "1.8" + npm-i: mocha@3.5.3 nyc@10.3.2 supertest@2.0.0 + + - name: io.js 2.x + node-version: "2.5" + npm-i: mocha@3.5.3 nyc@10.3.2 supertest@2.0.0 + + - name: io.js 3.x + node-version: "3.3" + npm-i: mocha@3.5.3 nyc@10.3.2 supertest@2.0.0 + + - name: Node.js 4.x + node-version: "4.9" + npm-i: mocha@5.2.0 nyc@11.9.0 supertest@3.4.2 + + - name: Node.js 5.x + node-version: "5.12" + npm-i: mocha@5.2.0 nyc@11.9.0 supertest@3.4.2 + + - name: Node.js 6.x + node-version: "6.17" + npm-i: mocha@6.2.2 nyc@14.1.1 + + - name: Node.js 7.x + node-version: "7.10" + npm-i: mocha@6.2.2 nyc@14.1.1 + + - name: Node.js 8.x + node-version: "8.17" + npm-i: mocha@7.2.0 + + - name: Node.js 9.x + node-version: "9.11" + npm-i: mocha@7.2.0 + + - name: Node.js 10.x + node-version: "10.18" + + - name: Node.js 11.x + node-version: "11.15" + + - name: Node.js 12.x + node-version: "12.22" + + - name: Node.js 13.x + node-version: "13.14" + + - name: Node.js 14.x + node-version: "14.18" + + - name: Node.js 15.x + node-version: "15.14" + + - name: Node.js 16.x + node-version: "16.10" + + steps: + - uses: actions/checkout@v2 + + - name: Install Node.js ${{ matrix.node-version }} + shell: bash -eo pipefail -l {0} + run: | + nvm install --default ${{ matrix.node-version }} + if [[ "${{ matrix.node-version }}" == 0.* && "$(cut -d. -f2 <<< "${{ matrix.node-version }}")" -lt 10 ]]; then + nvm install --alias=npm 0.10 + nvm use ${{ matrix.node-version }} + sed -i '1s;^.*$;'"$(printf '#!%q' "$(nvm which npm)")"';' "$(readlink -f "$(which npm)")" + npm config set strict-ssl false + fi + dirname "$(nvm which ${{ matrix.node-version }})" >> "$GITHUB_PATH" + + - name: Configure npm + run: npm config set shrinkwrap false + + - name: Remove npm module(s) ${{ matrix.npm-rm }} + run: npm rm --silent --save-dev ${{ matrix.npm-rm }} + if: matrix.npm-rm != '' + + - name: Install npm module(s) ${{ matrix.npm-i }} + run: npm install --save-dev ${{ matrix.npm-i }} + if: matrix.npm-i != '' + + - name: Setup Node.js version-specific dependencies + shell: bash + run: | + # eslint for linting + # - remove on Node.js < 10 + if [[ "$(cut -d. -f1 <<< "${{ matrix.node-version }}")" -lt 10 ]]; then + node -pe 'Object.keys(require("./package").devDependencies).join("\n")' | \ + grep -E '^eslint(-|$)' | \ + sort -r | \ + xargs -n1 npm rm --silent --save-dev + fi + + - name: Install Node.js dependencies + run: npm install + + - name: List environment + id: list_env + shell: bash + run: | + echo "node@$(node -v)" + echo "npm@$(npm -v)" + npm -s ls ||: + (npm -s ls --depth=0 ||:) | awk -F'[ @]' 'NR>1 && $2 { print "::set-output name=" $2 "::" $3 }' + + - name: Run tests + shell: bash + run: | + if npm -ps ls nyc | grep -q nyc; then + npm run test-ci + else + npm test + fi + + - name: Lint code + if: steps.list_env.outputs.eslint != '' + run: npm run lint \ No newline at end of file diff --git a/README.md b/README.md index 8b57079..ca3dc55 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,25 @@ -# csurf +# @sailshq/csurf + +A fork of the [now deprecated](https://github.com/expressjs/csurf?tab=readme-ov-file#deprecated) csurf package with ongoing maintenance from the [Sails core team](http://sailsjs.com/about). + +[![NPM Version][npm-version-image]][npm-url] +[![NPM Downloads][npm-downloads-image]][node-url] +[![Build status][github-actions-ci-image]][github-actions-ci-url] +[![Test coverage][coveralls-image]][coveralls-url] + +Node.js [CSRF][wikipedia-csrf] protection middleware. + +Requires either a session middleware or [cookie-parser](https://www.npmjs.com/package/cookie-parser) to be initialized first. + + * If you are setting the ["cookie" option](#cookie) to a non-`false` value, + then you must use [cookie-parser](https://www.npmjs.com/package/cookie-parser) + before this module. + * Otherwise, you must use a session middleware before this module. For example: + - [express-session](https://www.npmjs.com/package/express-session) + - [cookie-session](https://www.npmjs.com/package/cookie-session) + +If you have questions on how this module is implemented, please read +[Understanding CSRF](https://github.com/pillarjs/understanding-csrf). ## Deprecated @@ -8,3 +29,305 @@ project does not have the resources to put into this module, which is largely un SPA-based applications. Please instead use an alternative CSRF protection package if you do need one: https://www.npmjs.com/search?q=express%20csrf + +## Installation + +This is a [Node.js](https://nodejs.org/en/) module available through the +[npm registry](https://www.npmjs.com/). Installation is done using the +[`npm install` command](https://docs.npmjs.com/getting-started/installing-npm-packages-locally): + +```sh +$ npm install csurf +``` + +## API + +```js +var csurf = require('csurf') +``` + +### csurf([options]) + +Create a middleware for CSRF token creation and validation. This middleware +adds a `req.csrfToken()` function to make a token which should be added to +requests which mutate state, within a hidden form field, query-string etc. +This token is validated against the visitor's session or csrf cookie. + +#### Options + +The `csurf` function takes an optional `options` object that may contain +any of the following keys: + +##### cookie + +Determines if the token secret for the user should be stored in a cookie +or in `req.session`. Storing the token secret in a cookie implements +the [double submit cookie pattern][owsap-csrf-double-submit]. +Defaults to `false`. + +When set to `true` (or an object of options for the cookie), then the module +changes behavior and no longer uses `req.session`. This means you _are no +longer required to use a session middleware_. Instead, you do need to use the +[cookie-parser](https://www.npmjs.com/package/cookie-parser) middleware in +your app before this middleware. + +When set to an object, cookie storage of the secret is enabled and the +object contains options for this functionality (when set to `true`, the +defaults for the options are used). The options may contain any of the +following keys: + + - `key` - the name of the cookie to use to store the token secret + (defaults to `'_csrf'`). + - `path` - the path of the cookie (defaults to `'/'`). + - `signed` - indicates if the cookie should be signed (defaults to `false`). + - `secure` - marks the cookie to be used with HTTPS only (defaults to + `false`). + - `maxAge` - the number of seconds after which the cookie will expire + (defaults to session length). + - `httpOnly` - flags the cookie to be accessible only by the web server + (defaults to `false`). + - `sameSite` - sets the same site policy for the cookie(defaults to + `false`). This can be set to `'strict'`, `'lax'`, `'none'`, or `true` + (which maps to `'strict'`). + - `domain` - sets the domain the cookie is valid on(defaults to current + domain). + +##### ignoreMethods + +An array of the methods for which CSRF token checking will disabled. +Defaults to `['GET', 'HEAD', 'OPTIONS']`. + +##### sessionKey + +Determines what property ("key") on `req` the session object is located. +Defaults to `'session'` (i.e. looks at `req.session`). The CSRF secret +from this library is stored and read as `req[sessionKey].csrfSecret`. + +If the ["cookie" option](#cookie) is not `false`, then this option does +nothing. + +##### value + +Provide a function that the middleware will invoke to read the token from +the request for validation. The function is called as `value(req)` and is +expected to return the token as a string. + +The default value is a function that reads the token from the following +locations, in order: + + - `req.body._csrf` - typically generated by the `body-parser` module. + - `req.query._csrf` - a built-in from Express.js to read from the URL + query string. + - `req.headers['csrf-token']` - the `CSRF-Token` HTTP request header. + - `req.headers['xsrf-token']` - the `XSRF-Token` HTTP request header. + - `req.headers['x-csrf-token']` - the `X-CSRF-Token` HTTP request header. + - `req.headers['x-xsrf-token']` - the `X-XSRF-Token` HTTP request header. + +## Example + +### Simple express example + +The following is an example of some server-side code that generates a form +that requires a CSRF token to post back. + +```js +var cookieParser = require('cookie-parser') +var csrf = require('csurf') +var bodyParser = require('body-parser') +var express = require('express') + +// setup route middlewares +var csrfProtection = csrf({ cookie: true }) +var parseForm = bodyParser.urlencoded({ extended: false }) + +// create express app +var app = express() + +// parse cookies +// we need this because "cookie" is true in csrfProtection +app.use(cookieParser()) + +app.get('/form', csrfProtection, function (req, res) { + // pass the csrfToken to the view + res.render('send', { csrfToken: req.csrfToken() }) +}) + +app.post('/process', parseForm, csrfProtection, function (req, res) { + res.send('data is being processed') +}) +``` + +Inside the view (depending on your template language; handlebars-style +is demonstrated here), set the `csrfToken` value as the value of a hidden +input field named `_csrf`: + +```html +
+``` + +#### Using AJAX + +When accessing protected routes via ajax both the csrf token will need to be +passed in the request. Typically this is done using a request header, as adding +a request header can typically be done at a central location easily without +payload modification. + +The CSRF token is obtained from the `req.csrfToken()` call on the server-side. +This token needs to be exposed to the client-side, typically by including it in +the initial page content. One possibility is to store it in an HTML `` tag, +where value can then be retrieved at the time of the request by JavaScript. + +The following can be included in your view (handlebar example below), where the +`csrfToken` value came from `req.csrfToken()`: + +```html + +``` + +The following is an example of using the +[Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) to post +to the `/process` route with the CSRF token from the `` tag on the page: + + + +```js +// Read the CSRF token from the tag +var token = document.querySelector('meta[name="csrf-token"]').getAttribute('content') + +// Make a request using the Fetch API +fetch('/process', { + credentials: 'same-origin', // <-- includes cookies in the request + headers: { + 'CSRF-Token': token // <-- is the csrf token as a header + }, + method: 'POST', + body: { + favoriteColor: 'blue' + } +}) +``` + +#### Single Page Application (SPA) + +Many SPA frameworks like Angular have CSRF support built in automatically. +Typically they will reflect the value from a specific cookie, like +`XSRF-TOKEN` (which is the case for Angular). + +To take advantage of this, set the value from `req.csrfToken()` in the cookie +used by the SPA framework. This is only necessary to do on the route that +renders the page (where `res.render` or `res.sendFile` is called in Express, +for example). + +The following is an example for Express of a typical SPA response: + +```js +app.all('*', function (req, res) { + res.cookie('XSRF-TOKEN', req.csrfToken()) + res.render('index') +}) +``` + +### Ignoring Routes + +**Note** CSRF checks should only be disabled for requests that you expect to +come from outside of your website. Do not disable CSRF checks for requests +that you expect to only come from your website. An existing session, even if +it belongs to an authenticated user, is not enough to protect against CSRF +attacks. + +The following is an example of how to order your routes so that certain endpoints +do not check for a valid CSRF token. + +```js +var cookieParser = require('cookie-parser') +var csrf = require('csurf') +var bodyParser = require('body-parser') +var express = require('express') + +// create express app +var app = express() + +// create api router +var api = createApiRouter() + +// mount api before csrf is appended to the app stack +app.use('/api', api) + +// now add csrf and other middlewares, after the "/api" was mounted +app.use(bodyParser.urlencoded({ extended: false })) +app.use(cookieParser()) +app.use(csrf({ cookie: true })) + +app.get('/form', function (req, res) { + // pass the csrfToken to the view + res.render('send', { csrfToken: req.csrfToken() }) +}) + +app.post('/process', function (req, res) { + res.send('csrf was required to get here') +}) + +function createApiRouter () { + var router = new express.Router() + + router.post('/getProfile', function (req, res) { + res.send('no csrf to get here') + }) + + return router +} +``` + +### Custom error handling + +When the CSRF token validation fails, an error is thrown that has +`err.code === 'EBADCSRFTOKEN'`. This can be used to display custom +error messages. + +```js +var bodyParser = require('body-parser') +var cookieParser = require('cookie-parser') +var csrf = require('csurf') +var express = require('express') + +var app = express() +app.use(bodyParser.urlencoded({ extended: false })) +app.use(cookieParser()) +app.use(csrf({ cookie: true })) + +// error handler +app.use(function (err, req, res, next) { + if (err.code !== 'EBADCSRFTOKEN') return next(err) + + // handle CSRF token errors here + res.status(403) + res.send('form tampered with') +}) +``` + +## References + +- [Cross-side request forgery on Wikipedia][wikipedia-csrf] +- [OWASP Cross-Site Request Forgery Prevention Cheat Sheet][owsap-csrf] + +[owsap-csrf]: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html +[owsap-csrf-double-submit]: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie +[wikipedia-csrf]: https://en.wikipedia.org/wiki/Cross-site_request_forgery + +## License + +[MIT](LICENSE) + +[coveralls-image]: https://badgen.net/coveralls/c/github/expressjs/csurf/master +[coveralls-url]: https://coveralls.io/r/expressjs/csurf?branch=master +[node-url]: https://nodejs.org/en/download +[npm-downloads-image]: https://badgen.net/npm/dm/csurf +[npm-url]: https://npmjs.org/package/csurf +[npm-version-image]: https://badgen.net/npm/v/csurf +[github-actions-ci-image]: https://badgen.net/github/checks/expressjs/csurf/master?label=ci +[github-actions-ci-url]: https://github.com/expressjs/csurf/actions/workflows/ci.yml diff --git a/index.js b/index.js new file mode 100644 index 0000000..367bdba --- /dev/null +++ b/index.js @@ -0,0 +1,297 @@ +/*! + * csurf + * Copyright(c) 2011 Sencha Inc. + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2014-2016 Douglas Christopher Wilson + * MIT Licensed + */ + +'use strict' + +/** + * Module dependencies. + * @private + */ + +var Cookie = require('cookie') +var createError = require('http-errors') +var sign = require('cookie-signature').sign +var Tokens = require('csrf') + +/** + * Module exports. + * @public + */ + +module.exports = csurf + +/** + * CSRF protection middleware. + * + * This middleware adds a `req.csrfToken()` function to make a token + * which should be added to requests which mutate + * state, within a hidden form field, query-string etc. This + * token is validated against the visitor's session. + * + * @param {Object} options + * @return {Function} middleware + * @public + */ + +function csurf (options) { + var opts = options || {} + + // get cookie options + var cookie = getCookieOptions(opts.cookie) + + // get session options + var sessionKey = opts.sessionKey || 'session' + + // get value getter + var value = opts.value || defaultValue + + // token repo + var tokens = new Tokens(opts) + + // ignored methods + var ignoreMethods = opts.ignoreMethods === undefined + ? ['GET', 'HEAD', 'OPTIONS'] + : opts.ignoreMethods + + if (!Array.isArray(ignoreMethods)) { + throw new TypeError('option ignoreMethods must be an array') + } + + // generate lookup + var ignoreMethod = getIgnoredMethods(ignoreMethods) + + return function csrf (req, res, next) { + // validate the configuration against request + if (!verifyConfiguration(req, sessionKey, cookie)) { + return next(new Error('misconfigured csrf')) + } + + // get the secret from the request + var secret = getSecret(req, sessionKey, cookie) + var token + + // lazy-load token getter + req.csrfToken = function csrfToken () { + var sec = !cookie + ? getSecret(req, sessionKey, cookie) + : secret + + // use cached token if secret has not changed + if (token && sec === secret) { + return token + } + + // generate & set new secret + if (sec === undefined) { + sec = tokens.secretSync() + setSecret(req, res, sessionKey, sec, cookie) + } + + // update changed secret + secret = sec + + // create new token + token = tokens.create(secret) + + return token + } + + // generate & set secret + if (!secret) { + secret = tokens.secretSync() + setSecret(req, res, sessionKey, secret, cookie) + } + + // verify the incoming token + if (!ignoreMethod[req.method] && !tokens.verify(secret, value(req))) { + return next(createError(403, 'invalid csrf token', { + code: 'EBADCSRFTOKEN' + })) + } + + next() + } +} + +/** + * Default value function, checking the `req.body` + * and `req.query` for the CSRF token. + * + * @param {IncomingMessage} req + * @return {String} + * @api private + */ + +function defaultValue (req) { + return (req.body && req.body._csrf) || + (req.query && req.query._csrf) || + (req.headers['csrf-token']) || + (req.headers['xsrf-token']) || + (req.headers['x-csrf-token']) || + (req.headers['x-xsrf-token']) +} + +/** + * Get options for cookie. + * + * @param {boolean|object} [options] + * @returns {object} + * @api private + */ + +function getCookieOptions (options) { + if (options !== true && typeof options !== 'object') { + return undefined + } + + var opts = Object.create(null) + + // defaults + opts.key = '_csrf' + opts.path = '/' + + if (options && typeof options === 'object') { + for (var prop in options) { + var val = options[prop] + + if (val !== undefined) { + opts[prop] = val + } + } + } + + return opts +} + +/** + * Get a lookup of ignored methods. + * + * @param {array} methods + * @returns {object} + * @api private + */ + +function getIgnoredMethods (methods) { + var obj = Object.create(null) + + for (var i = 0; i < methods.length; i++) { + var method = methods[i].toUpperCase() + obj[method] = true + } + + return obj +} + +/** + * Get the token secret from the request. + * + * @param {IncomingMessage} req + * @param {String} sessionKey + * @param {Object} [cookie] + * @api private + */ + +function getSecret (req, sessionKey, cookie) { + // get the bag & key + var bag = getSecretBag(req, sessionKey, cookie) + var key = cookie ? cookie.key : 'csrfSecret' + + if (!bag) { + throw new Error('misconfigured csrf') + } + + // return secret from bag + return bag[key] +} + +/** + * Get the token secret bag from the request. + * + * @param {IncomingMessage} req + * @param {String} sessionKey + * @param {Object} [cookie] + * @api private + */ + +function getSecretBag (req, sessionKey, cookie) { + if (cookie) { + // get secret from cookie + var cookieKey = cookie.signed + ? 'signedCookies' + : 'cookies' + + return req[cookieKey] + } else { + // get secret from session + return req[sessionKey] + } +} + +/** + * Set a cookie on the HTTP response. + * + * @param {OutgoingMessage} res + * @param {string} name + * @param {string} val + * @param {Object} [options] + * @api private + */ + +function setCookie (res, name, val, options) { + var data = Cookie.serialize(name, val, options) + + var prev = res.getHeader('set-cookie') || [] + var header = Array.isArray(prev) ? prev.concat(data) + : [prev, data] + + res.setHeader('set-cookie', header) +} + +/** + * Set the token secret on the request. + * + * @param {IncomingMessage} req + * @param {OutgoingMessage} res + * @param {string} sessionKey + * @param {string} val + * @param {Object} [cookie] + * @api private + */ + +function setSecret (req, res, sessionKey, val, cookie) { + if (cookie) { + // set secret on cookie + var value = val + + if (cookie.signed) { + value = 's:' + sign(val, req.secret) + } + + setCookie(res, cookie.key, value, cookie) + } else { + // set secret on session + req[sessionKey].csrfSecret = val + } +} + +/** + * Verify the configuration against the request. + * @private + */ + +function verifyConfiguration (req, sessionKey, cookie) { + if (!getSecretBag(req, sessionKey, cookie)) { + return false + } + + if (cookie && cookie.signed && !req.secret) { + return false + } + + return true +} diff --git a/package.json b/package.json index 940242d..ee5f0c0 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,49 @@ { - "name": "csurf", - "description": "CSRF token middleware", + "name": "@sailshq/csurf", + "description": "A fork of csurf with ongoing maintenance from the Sails core team", "version": "1.11.0", "author": "Jonathan Ong