electrode-csrf-jwt
Advanced tools
Comparing version 1.3.1 to 1.4.0
"use strict"; | ||
const JwtTokenEngine = require("./jwt-token-engine"); | ||
const onHeaders = require("on-headers"); | ||
const CSRF = require("./csrf"); | ||
const pkg = require("../package.json"); | ||
const makeCookieConfig = require("./make-cookie-config"); | ||
const constants = require("./constants"); | ||
@@ -22,41 +24,27 @@ function csrfMiddleware(options) { | ||
const falseCb = () => false; | ||
const skipCreate = options.skipCreate || falseCb; | ||
const skipVerify = options.skipVerify || falseCb; | ||
const shouldSkip = options.shouldSkip || falseCb; | ||
const csrf = new CSRF(options); | ||
const engine = new JwtTokenEngine(options); | ||
function middleware(req, res, next) { | ||
// completely skip CSRF JWT | ||
if (shouldSkip(req)) { | ||
return next(); | ||
} | ||
function createToken() { | ||
if (skipCreate(req)) return; | ||
const tokens = engine.create({}); | ||
res.header("x-csrf-jwt", tokens.header); | ||
res.cookie("x-csrf-jwt", tokens.cookie, cookieConfig); | ||
} | ||
// Skip verify for HTTP GET or HEAD or if user indicate skip verify | ||
const method = req.method.toUpperCase(); | ||
if (method === "GET" || method === "HEAD" || skipVerify(req)) { | ||
createToken(); | ||
return next(); | ||
} | ||
// verify and create new CSRF tokens | ||
createToken(); | ||
const verify = engine.verify(req.headers["x-csrf-jwt"], req.cookies["x-csrf-jwt"]); | ||
return next(verify.error); | ||
} | ||
return middleware; | ||
return function middleware(req, res, next) { | ||
csrf.process( | ||
{ | ||
request: req, | ||
method: req.method, | ||
firstPost: req.headers[constants.firstPostHeaderName], | ||
create: () => { | ||
// use on-headers to defer creating and setting tokens | ||
onHeaders(res, () => { | ||
const tokens = csrf.create(); | ||
res.header(csrf.headerName, tokens.header); | ||
res.cookie(csrf.cookieName, tokens.cookie, cookieConfig); | ||
}); | ||
}, | ||
verify: () => csrf.verify(req.headers[csrf.headerName], req.cookies[csrf.cookieName]), | ||
continue: () => next(), | ||
error: verify => next(verify.error) | ||
}, | ||
{} | ||
); | ||
}; | ||
} | ||
module.exports = csrfMiddleware; |
"use strict"; | ||
const Boom = require("boom"); | ||
const JwtTokenEngine = require("./jwt-token-engine"); | ||
const CSRF = require("./csrf"); | ||
const pkg = require("../package.json"); | ||
const makeCookieConfig = require("./make-cookie-config"); | ||
const constants = require("./constants"); | ||
@@ -23,51 +24,41 @@ function csrfPlugin(server, options, next) { | ||
const falseCb = () => false; | ||
const skipCreate = options.skipCreate || falseCb; | ||
const skipVerify = options.skipVerify || falseCb; | ||
const shouldSkip = options.shouldSkip || falseCb; | ||
const csrf = new CSRF(options); | ||
const engine = new JwtTokenEngine(options); | ||
const createToken = (request, paylaod) => { | ||
const plugin = request.plugins[pkg.name]; | ||
server.ext("onPreAuth", (request, reply) => { | ||
const routeConfig = request.route.settings.plugins[pkg.name] || {}; | ||
// completely skip CSRF JWT | ||
if (shouldSkip(request) || routeConfig.enabled === false || routeConfig.shouldSkip === true) { | ||
return reply.continue(); | ||
} | ||
function createToken() { | ||
if (skipCreate(request) || routeConfig.skipCreate === true) { | ||
return; | ||
if (plugin) { | ||
if (!plugin.tokens) { | ||
plugin.tokens = csrf.create(paylaod); | ||
plugin.createToken = undefined; | ||
} | ||
request.plugins[pkg.name] = engine.create({}); | ||
return plugin.tokens; | ||
} | ||
// Skip verify for HTTP GET or HEAD or if user indicate skip verify | ||
const method = request.method.toUpperCase(); | ||
if ( | ||
method === "GET" || | ||
method === "HEAD" || | ||
skipVerify(request) || | ||
routeConfig.skipVerify === true | ||
) { | ||
createToken(); | ||
return reply.continue(); | ||
} | ||
return undefined; | ||
}; | ||
// verify and create new CSRF tokens | ||
createToken(); | ||
server.ext("onPreAuth", (request, reply) => { | ||
const routeConfig = request.route.settings.plugins[pkg.name] || {}; | ||
const verify = engine.verify(request.headers["x-csrf-jwt"], request.state["x-csrf-jwt"]); | ||
if (verify.error) { | ||
return reply(Boom.badRequest(verify.error.message)); | ||
} | ||
return reply.continue(); | ||
csrf.process( | ||
{ | ||
request, | ||
method: request.method, | ||
firstPost: request.headers[constants.firstPostHeaderName], | ||
create: () => { | ||
// initialize plugin in request to let onPreResponse to create tokens later | ||
request.plugins[pkg.name] = { createToken }; | ||
}, | ||
verify: () => csrf.verify(request.headers[csrf.headerName], request.state[csrf.cookieName]), | ||
continue: () => reply.continue(), | ||
error: verify => reply(Boom.badRequest(verify.error.message)) | ||
}, | ||
routeConfig | ||
); | ||
}); | ||
server.ext("onPreResponse", (request, reply) => { | ||
const tokens = request.plugins[pkg.name]; | ||
const tokens = createToken(request); | ||
@@ -79,8 +70,8 @@ if (tokens) { | ||
reply.state("x-csrf-jwt", tokens.cookie, cookieConfig); | ||
reply.state(csrf.cookieName, tokens.cookie, cookieConfig); | ||
headers["x-csrf-jwt"] = tokens.header; | ||
headers[csrf.headerName] = tokens.header; | ||
} | ||
return reply.continue(); | ||
reply.continue(); | ||
}); | ||
@@ -87,0 +78,0 @@ |
"use strict"; | ||
const CSRF = require("./csrf"); | ||
const pkg = require("../package.json"); | ||
const makeCookieConfig = require("./make-cookie-config"); | ||
const JwtTokenEngine = require("./jwt-token-engine"); | ||
const constants = require("./constants"); | ||
@@ -22,46 +23,25 @@ function csrfMiddleware(options) { | ||
const falseCb = () => false; | ||
const skipCreate = options.skipCreate || falseCb; | ||
const skipVerify = options.skipVerify || falseCb; | ||
const shouldSkip = options.shouldSkip || falseCb; | ||
const csrf = new CSRF(options); | ||
const engine = new JwtTokenEngine(options); | ||
return function middleware(ctx, next) { | ||
csrf.process( | ||
{ | ||
request: ctx, | ||
method: ctx.method, | ||
firstPost: ctx.headers[constants.firstPostHeaderName], | ||
create: () => { | ||
const tokens = csrf.create(); | ||
function middleware(ctx, next) { | ||
// completely skip CSRF JWT | ||
if (shouldSkip(ctx)) { | ||
return next(); | ||
} | ||
function createToken() { | ||
if (skipCreate(ctx)) return; | ||
const tokens = engine.create({}); | ||
ctx.set("x-csrf-jwt", tokens.header); | ||
ctx.cookies.set("x-csrf-jwt", tokens.cookie, cookieConfig); | ||
} | ||
// Skip verify for HTTP GET or HEAD or if user indicate skip verify | ||
const method = ctx.method.toUpperCase(); | ||
if (method === "GET" || method === "HEAD" || skipVerify(ctx)) { | ||
createToken(); | ||
return next(); | ||
} | ||
// verify and create new CSRF tokens | ||
createToken(); | ||
const verify = engine.verify(ctx.headers["x-csrf-jwt"], ctx.cookies.get("x-csrf-jwt")); | ||
if (verify.error) { | ||
return ctx.throw(verify.error); | ||
} | ||
return next(); | ||
} | ||
return middleware; | ||
ctx.set(csrf.headerName, tokens.header); | ||
ctx.cookies.set(csrf.cookieName, tokens.cookie, cookieConfig); | ||
}, | ||
verify: () => csrf.verify(ctx.headers[csrf.headerName], ctx.cookies.get(csrf.cookieName)), | ||
continue: () => next(), | ||
error: verify => ctx.throw(verify.error) | ||
}, | ||
{} | ||
); | ||
}; | ||
} | ||
module.exports = csrfMiddleware; |
@@ -6,2 +6,3 @@ "use strict"; | ||
const BAD_TOKEN = "BAD_TOKEN"; | ||
const FIRST_POST_NOT_ALLOWED = "FIRST_POST_NOT_ALLOWED"; | ||
@@ -11,3 +12,4 @@ module.exports = { | ||
INVALID_TOKEN, | ||
BAD_TOKEN | ||
BAD_TOKEN, | ||
FIRST_POST_NOT_ALLOWED | ||
}; |
@@ -6,2 +6,3 @@ "use strict"; | ||
const csrfKoa = require("./csrf-koa"); | ||
const pkg = require("../package.json"); | ||
@@ -11,3 +12,16 @@ module.exports = { | ||
expressMiddleware: csrfExpress, | ||
koaMiddleware: csrfKoa | ||
koaMiddleware: csrfKoa, | ||
hapiCreateToken: (request, payload) => { | ||
const plugin = request.plugins[pkg.name]; | ||
if (plugin) { | ||
if (plugin.createToken) { | ||
return plugin.createToken(request, payload); | ||
} | ||
return plugin.tokens || {}; | ||
} | ||
return {}; | ||
} | ||
}; |
{ | ||
"name": "electrode-csrf-jwt", | ||
"version": "1.3.1", | ||
"description": "", | ||
"version": "1.4.0", | ||
"description": "Stateless Cross-Site Request Forgery (CSRF) protection with JWT", | ||
"main": "lib/index.js", | ||
@@ -10,3 +10,4 @@ "scripts": { | ||
"coverage": "clap check", | ||
"prepublish": "npm test" | ||
"prepublishOnly": "npm test", | ||
"demo": "node demo/server" | ||
}, | ||
@@ -33,2 +34,4 @@ "repository": { | ||
"jsonwebtoken": "^8.0.0", | ||
"ms": "^2.1.1", | ||
"on-headers": "^1.0.1", | ||
"uuid": "^3.2.1" | ||
@@ -40,2 +43,4 @@ }, | ||
"electrode-archetype-njs-module-dev": "^3.0.0", | ||
"electrode-server": "^1.5.0", | ||
"electrode-static-paths": "^1.1.0", | ||
"express": "^4.13.3", | ||
@@ -50,3 +55,4 @@ "hapi": "^16.2.0", | ||
"set-cookie-parser": "^2.1.1", | ||
"vision": "^4.0.1" | ||
"vision": "^4.0.1", | ||
"xstdout": "^0.1.1" | ||
}, | ||
@@ -64,3 +70,4 @@ "nyc": { | ||
"dist", | ||
"test" | ||
"test", | ||
"demo" | ||
], | ||
@@ -67,0 +74,0 @@ "check-coverage": true, |
121
README.md
@@ -1,2 +0,2 @@ | ||
# Electrode CSRF JWT | ||
# Electrode Stateless CSRF | ||
@@ -9,9 +9,9 @@ [![NPM version][npm-image]][npm-url] [![Build Status][travis-image]][travis-url] [![Dependency Status][daviddm-image]][daviddm-url] | ||
[CSRF] protection is an important security feature, but in systems which don't have backend session persistence, doing CSRF token validation is tricky. Stateless CSRF support addresses this need. | ||
[CSRF] protection is an important security feature, but in systems which don't have backend session persistence, validation is tricky. Stateless CSRF support addresses this need. | ||
## How do we validate requests? | ||
The technique used by this module is similar to the CSRF [double submit cookie prevention technique]. | ||
CSRF attacks can be bad when a malicious script can make a request that can perform harmful operations through the user (victim)'s browser, attaching user specific and sensitive data in the cookies. | ||
These techniques rely on these two restrictions by the browsers: | ||
To prevent it, the technique used by this module is similar to the CSRF [double submit cookie prevention technique], and relies on these two restrictions by the browsers: | ||
@@ -27,5 +27,5 @@ 1. cross site scripts can't read/modify cookies. | ||
For use with [XMLHttpRequest], we extend the technique by using two JWT tokens for validation. One token in the cookies and the other in the HTTP headers. Since XSS cannot set HTTP headers also, it strengthens the security further. | ||
For use with [XMLHttpRequest] and [fetch], we extend the technique by using two JWT tokens for validation. One token in the cookies and the other in the HTTP headers. Since XSS cannot set HTTP headers also, it strengthens the security further. | ||
So two JWT CSRF tokens are generated on the server side with the same payload but different types (see below), one for the HTTP header, one for the cookie. | ||
So two JWT CSRF tokens are generated on the server side with the same payload but different types (see below), one for the HTTP header and one for the cookie. This makes two different tokens but uniquely paired with each other by the UUID. | ||
@@ -37,31 +37,90 @@ ```js | ||
When a client makes a request, the JWT tokens must be sent in the cookie and headers. | ||
When a client makes a request, the JWT tokens must be sent in the cookie and headers, both are channels that cross site scripts have no control over. | ||
On server side, both tokens are received, decoded, and validated to make sure the payloads match. | ||
Further, we set the cookie to be [HTTP Only] so any browser that supports it would prevent **any** scripts from accessing it at all. | ||
Disadvantage: relies on client making all request through AJAX. | ||
On the server side, the tokens are decoded and validated to pair with each other to identify legitimate requests. | ||
If a malicious script somehow manages to alter one of the tokens passed through the cookie or HTTP header, then they will not match. In order to forge a request on the victim's behalf, both restrictions must be circumvented. | ||
### Issues | ||
There are some issues with our technique. | ||
1. We rely on client making all request through AJAX because of the requirement to set HTTP header. | ||
2. First call has to be a GET to prime the header token. Since the code that use [XMLHttpRequest] or [fetch] need to first acquire valid tokens through a non-mutable request like HTTP GET to populate its internal state, so if your first call has to be POST, then it's tricky. | ||
3. Similar to the cause in #2 above, multiple browser tabs could run into token mismatches, since cookies are shared across tabs but each tab's code keeps its own internal token for the HTTP header. | ||
Issue 1 is the essential of how the technique works so that's just its limitation. | ||
Issue 2 and 3 are tricky, but there are some solutions. See [demo](./demo/README.md) for reference. | ||
## Install | ||
```bash | ||
$ npm install electrode-csrf-jwt | ||
$ npm install --save electrode-csrf-jwt | ||
``` | ||
> You can use the `--save` option to update `package.json` | ||
# Usage and Integration | ||
## Usage | ||
## Browser Integration | ||
To protect your AJAX requests from the browser, your JavaScript code need to first make a GET call to acquire an initial pair of CSRF tokens. The [HTTP only] cookie token is dropped automatically. Your code has to extract the header token and save it to an internal variable. | ||
In subsequent requests (GET or POST), you have to attach the header token acquired in the HTTP header `x-csrf-jwt`. | ||
If you receive an error, then you should take the token from the error response and retry one more time. | ||
### Full Demo | ||
You can reference a sample [demo](./demo/README.md) to use this for your webapp. | ||
## Serverside Integration | ||
This module includes a plugin for [Hapi] (v16 or lower) and middleware for [express] and [koa]. They can be used with the following: | ||
* [electrode-server](#electrode-server) | ||
* [Express](#express) | ||
* [Hapi](#hapi) | ||
* [Koa 2](#koa-2) | ||
### Options | ||
`options`: | ||
First the options. Regardless of which server framework you use, the options remains the same when you pass it to the plugin or middleware. | ||
* `secret`: **Required**. A string or buffer containing either the secret for HMAC algorithms, or the PEM encoded private key for RSA and ECDSA. | ||
* `shouldSkip`: **Optional** A callback that takes the `request` (or context for Koa) object and returns `true` if it wants completely skip the CSRF JWT middleware/plugin for the given `request` | ||
* `skipCreate`: **Optional** A callback that takes the `request` (or context for Koa) object and returns `true` if it wants the CSRF JWT to skip creating the token for the given `request` | ||
* `skipVerify`: **Optional** A callback that takes the `request` (or context for Koa) object and returns `true` if it wants the CSRF JWT to skip verifying for the given `request` | ||
* `cookieConfig`: **Optional** An object with extra configs for setting the JWT cookie token. Values set to `undefined` or `null` will delete the field from the default cookie config. | ||
* `uuidGen`: **Optional** A string of `uuid` or `simple` to select the unique ID generator, or a callback to generate the ID. See [uuidGen Option](#uuidgen-option) for details. | ||
#### Required Fields | ||
Others are optional and follow the [same usage as jsonwebtoken](https://github.com/auth0/node-jsonwebtoken/blob/master/README.md#usage) | ||
* `secret`: A string or buffer containing either the secret for HMAC algorithms, or the PEM encoded private key for RSA and ECDSA. | ||
#### Optional Fields | ||
* `cookieName`: A string to use as name for setting the cookie token. Default: `x-csrf-jwt` | ||
* `headerName`: A string to use as name for setting the header token. Default: **cookieName** | ||
* `cookieConfig`: An object with extra configs for setting the JWT cookie token. Values set to `undefined` or `null` will delete the field from the default cookie config. See the respective server framework for info on what their cookie config should be. | ||
* `tokenEngine`: **Experimental** A string that specifies the token engine. Either the default [`"jwt"`](./lib/jwt-token-engine.js) or [`"hash"`](./lib/hash-token-engine.js). | ||
#### Optional `uuidGen` Field | ||
This module by default uses the [uuid] module. However, it uses [crypto.randomBytes](https://nodejs.org/docs/latest-v8.x/api/crypto.html#crypto_crypto_randombytes_size_callback), which "uses libuv's threadpool, which can have surprising and negative performance implications for some applications". | ||
If that's an issue, then you can set the `uuidGen` option as follows to select another UUID generator: | ||
* `"simple"` - select a [simple](./lib/simple-id-generator.js) one from this module | ||
* `"uuid"` - the default: uses [uuid] | ||
* **function** - your own function that returns the ID, which should be a URL safe string | ||
#### Optional Skip Callbacks | ||
The following should be functions that take the `request` (or `context` for Koa) object and return `true` to skip their respective step for the given `request`: | ||
* `shouldSkip`: Completely skip the CSRF middleware/plugin | ||
* `skipCreate`: Skip creating the tokens for the response | ||
* `skipVerify`: Skip verifying the incoming tokens | ||
#### JWT specific optional fields | ||
Others are optional and follow the [same usage as jsonwebtoken](https://github.com/auth0/node-jsonwebtoken/blob/master/README.md#usage) if the `tokenEngine` is `jwt`. | ||
* `algorithm` | ||
@@ -78,18 +137,6 @@ * `expiresIn` | ||
This module can be used with either [Electrode](#electrode), [Express](#express), [Hapi](#hapi), or [Koa 2](#koa-2). | ||
### Electrode Server | ||
#### uuidGen Option | ||
[electrode-server] is a top level wrapper for [Hapi]. You can use the hapi-plugin in [electrode-server] by setting your configuration. | ||
This module by default use the [uuid] module to generate the uuid used in the JWT token. | ||
However, [uuid] uses [crypto.randomBytes](https://nodejs.org/docs/latest-v8.x/api/crypto.html#crypto_crypto_randombytes_size_callback), which "uses libuv's threadpool, which can have surprising and negative performance implications for some applications". | ||
You can set `options.uuidGen` as follows to select another UUID generator: | ||
* `"simple"` - select a [simple](./lib/simple-id-generator.js) one from this module | ||
* `"uuid"` - the default: uses [uuid] | ||
* callback - your own function that returns the ID | ||
### Electrode | ||
#### Example `config/default.js` configuration | ||
@@ -226,2 +273,8 @@ | ||
[xmlhttprequest]: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest | ||
[fetch]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API | ||
[csrf]: https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet | ||
[hapi]: https://www.npmjs.com/package/hapi | ||
[express]: https://www.npmjs.com/package/express | ||
[koa]: https://www.npmjs.com/package/koa | ||
[electrode-server]: https://www.npmjs.com/package/electrode-server | ||
[http only]: https://www.owasp.org/index.php/HttpOnly |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
27831
15
480
276
5
16
1
+ Addedms@^2.1.1
+ Addedon-headers@^1.0.1
+ Addedon-headers@1.0.2(transitive)