/*
* Author: Vlad Seryakov vseryakov@gmail.com
* backendjs 2018
*/
/**
* @module api/csrf
*/
const lib = require(__dirname + '/../lib');
const api = require(__dirname + '/../api');
const logger = require(__dirname + '/../logger');
const mod =
/**
* CSRF token format: TYPE,RANDOM_INT,EXPIRE_MS,[UID]
*
* type is
* - h for header
* - c for cookie
*
* Implements double cookie protection using HTTP and cookie tokens, both must be present. This means a web app must
* handle the HTTP header, store and return in all API requests.
*
* In addition a token may contain the user id which must be the same for logged in users.
*
* It must be configured to be used, by default no paths are set
*
* @example <caption>enable public CSRF token, this token will be returned later to make sure a user came from the sire, not from email</caption>
* api-csrf-pub-path = ^/pub/$
*
* @example <caption>On all account access set new token</caption>
* api-csrf-set-path = ^/account/get$
*
* @example <caption>Verify token for logout, i.e. will refuse to logout if not valid</caption>
* api-csrf-check-path = ^/logout/
*
*/
module.exports = {
name: "api.csrf",
args: [
{ name: "err-(.+)", descr: "Error messages for various cases" },
{ name: "set-path", type: "regexpobj", descr: "Regexp for URLs to set CSRF token for all methods, token type(user|pub) is based on the current session" },
{ name: "pub-path", type: "regexpobj", descr: "Regexp for URLs to set public CSRF token only if no valid CSRF token detected" },
{ name: "check-path", type: "regexpobj", descr: "Regexp for URLs to set CSRF token for skip methods and verify for others" },
{ name: "skip-method", type: "regexp", descr: "Do not check for CSRF token for specified methods" },
{ name: "skip-status", type: "regexp", descr: "Do not return CSRF token for specified status codes" },
{ name: "header", descr: "Name for the CSRF header" },
{ name: "secret", descr: "Secret for encryption" },
{ name: "age", type: "int", min: 0, descr: "CSRF token age in milliseconds" },
{ name: "same-site", descr: "Session SameSite option, for cookie based authentication" },
{ name: "secure", type: "bool", descr: "Set cookie Secure flag" },
],
sameSite: "strict",
secure: true,
setPath: {},
checkPath: {},
skipMethod: /^(GET|HEAD|OPTIONS|TRACE)$/i,
skipStatus: /^(5|3|401|403|404|417)/,
/** @var {string} - Header name
* @default
*/
header: "x-csrf-token",
/** @var {int} - Default token age in ms
* @default
*/
age: 3600000,
errInvalidCsrf: "Authentication failed",
};
/**
* Return HTTP CSRF token, can be used in templates or forms, the cookie token will reuse the same token
* @param {IncomingRequest} req
* @returns {string}
* @memberof module:api/csrf
* @method get
*/
mod.get = function(req)
{
if (req && !req.csrfToken) {
req._csrfToken = `,${lib.randomInt()},${Date.now() + this.age},${!req._csrfPub && req.user?.id || ""}`;
req.csrfToken = lib.encrypt(this.secret || api.accessTokenSecret, "h" + req._csrfToken);
logger.debug("get:", mod.name, "new", req.options, "T:", req._csrfToken, "E:", !!req.csrfToken);
}
return req?.csrfToken;
}
/**
* Returns .ok == false if CSRF token verification fails, both header and cookie are checked and retuned as .h and .c
* @param {IncomingRequest} req
* @returns {object} as { ok, h, c }
* @memberof module:api/csrf
* @method verify
*/
mod.verify = function(req)
{
var secret = this.secret || api.accessTokenSecret;
var ok, h = req.headers[this.header] || req.query[this.header] || req.body[this.header];
h = lib.decrypt(secret, h).split(",");
var c, cookie = req.cookies && req.cookies[this.header];
if (cookie && h[0] === "h" && lib.toNumber(h[2]) > Date.now() && (!h[3] || h[3] === req.user?.id)) {
c = lib.decrypt(secret, cookie).split(",");
if (c[0] === "c" && lib.toNumber(c[2]) > Date.now() && (!c[3] || c[3] === req.user?.id)) {
// When using many tabs tokens may get out of sync but both must be valid user tokens
ok = h[1] === c[1] || (req.user?.id && req.user?.id === h[3] && h[3] === c[3]);
}
}
return { ok, h, c };
}
/**
* For configured endpoints check for a token and fail if not present or invalid
* @param {IncomingRequest} req
* @param {object} [options]
* @memberof module:api/csrf
* @method check
*/
mod.check = function(req, options)
{
if (lib.testRegexpObj(req.options.path, this.checkPath)) {
if (options?.force || !lib.testRegexp(req.method, this.skipMethod)) {
var t = this.verify(req);
if (!t.ok) {
logger.debug("invalidCsrfToken:", req.options, "H:", t.h, "C:", t.c, "HDR:", req.headers, "Q:", req.query);
return { status: 401, message: this.errInvalidCsrf, code: "NOCSRF" };
}
logger.debug("check:", mod.name, "ok", req.options, "H:", t.h, "C:", t.c);
}
} else {
var set = lib.testRegexpObj(req.options.path, this.setPath);
if (!set) {
// Set public tokens if no valid tokens are present
if (!lib.testRegexpObj(req.options.path, this.pubPath)) return;
if (this.verify(req).ok) return;
req._csrfPub = 1;
}
}
// Set header/cookie at the time of sending HTTP headers so user id is included in the token if present.
api.registerPreHeaders(req, (req, res, status) => {
if (req.csrfToken === 0 || lib.testRegexp(status, this.skipStatus)) return;
res.header(this.header, this.get(req));
var csrfToken = lib.encrypt(this.secret || api.accessTokenSecret, "c" + req._csrfToken);
var opts = api.session.makeCookie(req, {
httpOnly: true,
maxAge: mod.age,
secure: this.secure,
sameSite: this.sameSite,
});
res.cookie(this.header, csrfToken, opts);
});
}
/**
* Do not return CSRF token in cooies or headers
* @param {IncomingRequest} req
* @memberof module:api/csrf
* @method skip
*/
mod.skip = function(req)
{
req.csrfToken = 0;
}
/**
* Reset CSRF tokens from cookies and headers
* @param {IncomingRequest} req
* @memberof module:api/csrf
* @method clear
*/
mod.clear = function(req)
{
if (!req?.res) return;
this.skip(req);
var opts = api.session.makeCookie(req, {
expires: new Date(1),
httpOnly: true,
sameSite: "strict",
secure: true
});
req.res.cookie(this.header, "", opts);
req.res.header(this.header, req.csrfToken);
}