"use strict"; const Session = require("../types/Session.js"); const nolookalikes = require("nanoid-dictionary/nolookalikes"); /** thrown when the user attempts to bypass security measures */ class BypassAttempt extends Error {}; /** thrown when the received data does not match the expected format */ class ValidationError extends Error {}; /** the patterns used for parsing */ const regexes = { /** a Universally Unique Identifier */ uuid: /^[0-9a-f]{8}(?:\-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i, /** nolookalikes nanoid */ nano23: new RegExp(`^[${nolookalikes}]{23}$`), /** nanoid */ nano36: /^[A-Za-z0-9_-]{36}$/, /** tmwa password */ any23: /^[^\s][^\t\r\n]{2,21}[^\s]$/, /** hercules password */ any30: /^[^\s][^\t\r\n]{6,28}[^\s]$/, /** username */ alnum23: /^\w{4,23}$/i, /** tmwa/hercules GID */ gid: /^[23][0-9]{6}$/, /** RFC 5322 email, but must also have a TLD */ email: /^(?:[a-zA-Z0-9.$&+=_~-]{1,255}@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,255}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,255}[a-zA-Z0-9])?){1,9})$/i, }; /** * gets the name of the endpoint for pretty-printing * @param {Request} req * @returns {string} the pretty name */ const get_endpoint = req => `Vault.${req.path.slice(1).split("/").join(".")}`; /** * log a bad action * @param {Request} req * @param {string} msg */ const warn = (req, msg) => req.app.locals.logger.warn(`${get_endpoint(req)}: ${msg} [${req.ip}]`); /** * check the request ip against the session settings * @param {Request} * @param {Session} session * @returns {boolean} whether the ip is allowed to use this session */ const check_ip = (req, session) => !session.strictIPCheck || req.ip === session.ip; /** * gets a property from the body or from the query * @param {Request} req * @param {string} prop - the name of the property to get * @returns {string} the property value */ const get_prop = (req, prop, regex = null) => { try { let value = ""; if (req.body && Reflect.has(req.body, prop)) { value = String(req.body[prop]); } else if (req.query && Reflect.has(req.query, prop)) { value = String(req.query[prop]); } if (regex !== null && !value.match(regex)) { return ""; // does not match the provided regex } return value; } catch { return ""; // couldn't convert to string } }; /** * get the secret * @param {Request} * @returns {string} the session secret */ const get_secret = (req, res) => { const token = req.get("X-VAULT-TOKEN") || ""; if (!token.match(regexes.nano36)) { res.status(400).json({ status: "error", error: "missing secret key", }); warn(req, "blocked an attempt to bypass authentication (missing secret)"); req.app.locals.cooldown(req, 3e5); throw new BypassAttempt("missing secret"); } return token; }; /** * get the session without authenticating * @param {Request} req * @param {Response} res * @returns {[string, Session]} the session token and session */ const get_raw_session = (req, res) => { const token = String(req.get("X-VAULT-SESSION") || ""); if (!token.match(regexes.nano23)) { res.status(400).json({ status: "error", error: "missing session key", }); warn(req, "blocked an attempt to bypass authentication (missing key)"); req.app.locals.cooldown(req, 3e5); throw new BypassAttempt("missing session key"); } return [token, req.app.locals.session.get(token) || null]; }; /** * check authentication and get the session * @param {Request} req * @param {Response} res * @returns {[string, Session]} the session token and session */ const get_session = (req, res) => { const [token, session] = get_raw_session(req, res); if (session === null) { res.status(410).json({ status: "error", error: "session expired", }); req.app.locals.cooldown(req, 5e3); // XXX: maybe a lower cooldown here throw new BypassAttempt("session not found"); } if (get_secret(req) !== session.secret) { res.status(410).json({ status: "error", error: "session expired", // yes, we lie to them }); // max 3 attempts per 15 minutes if (req.app.locals.brute.consume(req, 3, 9e5)) { req.app.locals.cooldown(req, 5e3); } else { warn(req, "blocked an attempt to bypass authentication (wrong secret)"); req.app.locals.cooldown(req, 3.6e6); } throw new BypassAttempt("wrong secret"); } if (!session.authenticated) { // this should not be possible because they cannot know the secret // before authenticating, but we check just to be safe res.status(401).json({ status: "error", error: "not authenticated", }); warn(req, "blocked an attempt to bypass authentication (not authed)"); req.app.locals.cooldown(req, 3e5); throw new BypassAttempt("session not authenticated"); } if (!check_ip(req, session)) { // ip address has changed res.status(403).json({ status: "error", error: "ip address mismatch", }); req.app.locals.logger.warn(`${get_endpoint(req)}: ip address mismatch <${session.vault}@vault> [${req.ip}]`); req.app.locals.cooldown(req, 3e5); throw new ValidationError("ip mismatch"); } return [token, session]; }; const get_email = (req, res) => { const email = get_prop(req, "email"); if (!email.match(regexes.email) || email.length >= 320) { res.status(400).json({ status: "error", error: "invalid email address", }); warn(req, "blocked an attempt to bypass authentication (invalid email)"); req.app.locals.cooldown(req, 3e5); throw new BypassAttempt("invalid email format"); } return email; }; module.exports = { regexes, check_ip, get_prop, get_email, get_endpoint, get_raw_session, get_session, };