summaryrefslogtreecommitdiff
path: root/src/routers/vault/utils/validate.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/routers/vault/utils/validate.js')
-rw-r--r--src/routers/vault/utils/validate.js204
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,
+};