summaryrefslogblamecommitdiff
path: root/src/routers/vault/utils/validate.js
blob: 3632fd1beaaff15a9941b96bba839b3eb141ddb1 (plain) (tree)
1
2
3
4
5
6
7
8
9
10
11
12
13

                                               
                                                               









                                                                       



                                                  


                                         
                                                                                          





























































                                                                                                                                                       
                                  

                                                 
                                       




















                                                                                  
                                       









































































                                                                                                                     
                                 
























                                                                                 
"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,21}[^\s]$/, // this could be increased if we modify manaplus
    /** 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,
};