diff options
Diffstat (limited to 'src/routers/vault/utils/validate.js')
-rw-r--r-- | src/routers/vault/utils/validate.js | 204 |
1 files changed, 204 insertions, 0 deletions
diff --git a/src/routers/vault/utils/validate.js b/src/routers/vault/utils/validate.js new file mode 100644 index 0000000..a0d0ea3 --- /dev/null +++ b/src/routers/vault/utils/validate.js @@ -0,0 +1,204 @@ +"use strict"; +const Session = require("../types/Session.js"); + +/** 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, + /** 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) => { + const token = req.get("X-VAULT-TOKEN") || ""; + + if (!token.match(regexes.uuid)) { + 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.uuid)) { + 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) => { + 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, +}; |