From 25a0e566f7c6fb43237fcfd0b2bcb4e70d48cb27 Mon Sep 17 00:00:00 2001
From: f00wl <f00wl@felinn.org>
Date: Sun, 29 Sep 2024 19:15:39 +0000
Subject: [PATCH] feat(sec): Add rules to username creation

---
 hiboo/account/forms.py    | 13 +++++++------
 hiboo/application/base.py |  6 +-----
 hiboo/configuration.py    |  8 +++++++-
 hiboo/format.py           | 17 +++++++++++------
 hiboo/models.py           | 22 +++++++++++++++++++---
 hiboo/profile/forms.py    |  5 +++++
 hiboo/profile/views.py    | 18 ++++++++++++------
 hiboo/security.py         | 15 +++++++++++++++
 8 files changed, 77 insertions(+), 27 deletions(-)

diff --git a/hiboo/account/forms.py b/hiboo/account/forms.py
index edd0fbb..025c759 100644
--- a/hiboo/account/forms.py
+++ b/hiboo/account/forms.py
@@ -1,4 +1,5 @@
 from hiboo.captcha import captcha
+from hiboo.format import UsernameFormat
 from wtforms import validators, fields
 from flask_babel import lazy_gettext as _
 from flask_bootstrap import SwitchField
@@ -18,12 +19,12 @@ class TotpForm(flask_wtf.FlaskForm):
 
 
 class SignupForm(flask_wtf.FlaskForm):
-    username = fields.StringField(_('Username'), [
-        validators.DataRequired(),
-        validators.Regexp("(^[a-z0-9-_]+$)", message=(_("Your username must be \
-           comprised of lowercase letters, numbers and '-' '_' only")))
-    ], description = (_("Your username must be \
-           comprised of lowercase letters, numbers and '-' '_' only")))
+    formatter = UsernameFormat.registry["lowercase"]
+    username = fields.StringField(
+        _('Username'),
+        formatter.validators(),
+        description = (_("Your username must be comprised of ") + formatter.message)
+    )
     password = fields.PasswordField(_('Password'), [validators.DataRequired()])
     password2 = fields.PasswordField(_('Confirm password'),
         [validators.DataRequired(), validators.EqualTo('password')])
diff --git a/hiboo/application/base.py b/hiboo/application/base.py
index 56f0567..ee9d02f 100644
--- a/hiboo/application/base.py
+++ b/hiboo/application/base.py
@@ -19,12 +19,8 @@ class BaseForm(flask_wtf.FlaskForm):
         [validators.NumberRange(1, 1000)])
     profile_format = fields.SelectField(_('Profile username format'),
         choices=(
-            [("", (_("Default ({})".format(
-                format.ProfileFormat.registry[None].message))
-            ))] +
             [(name, format.message.capitalize())
-              for name, format in format.ProfileFormat.registry.items()
-              if name is not None
+              for name, format in format.UsernameFormat.registry.items()
             ]
         )
     )
diff --git a/hiboo/configuration.py b/hiboo/configuration.py
index 5839ee8..c035cc2 100644
--- a/hiboo/configuration.py
+++ b/hiboo/configuration.py
@@ -1,6 +1,9 @@
 import os
+import ast
 import logging
 
+from hiboo.security import username_blocklist
+
 
 DEFAULT_CONFIG = {
     # Common configuration variables
@@ -17,7 +20,8 @@ DEFAULT_CONFIG = {
     'WEBSITE_NAME': 'Hiboo',
     'OPEN_SIGNUP': True,
     'USER_TIMEOUT': 86400,
-    'API_TOKEN': 'changeMe'
+    'API_TOKEN': 'changeMe',
+    'USERNAME_BLOCKLIST': username_blocklist(),
 }
 
 class ConfigManager(dict):
@@ -32,6 +36,8 @@ class ConfigManager(dict):
             return True
         elif isinstance(value, str) and value.lower() in ('false', 'no'):
             return False
+        elif isinstance(value, str) and value[0] in "[{(" and value[-1] in ")}]":
+            return ast.literal_eval(value)
         return value
 
     def init_app(self, app):
diff --git a/hiboo/format.py b/hiboo/format.py
index 6954122..4dad141 100644
--- a/hiboo/format.py
+++ b/hiboo/format.py
@@ -1,10 +1,11 @@
 from wtforms import validators
 from flask_babel import lazy_gettext as _
+from hiboo.security import username_blocklist
 
 import string
 
 
-class ProfileFormat(object):
+class UsernameFormat(object):
 
     registry = {}
     regex = ".*"
@@ -25,7 +26,7 @@ class ProfileFormat(object):
         return register_function
 
     @classmethod
-    def validators(cls):
+    def validators(cls, blocklist=True):
         """ Return a username validator for wtforms
         """
         return [
@@ -37,6 +38,10 @@ class ProfileFormat(object):
             validators.Regexp(
                 "^{}$".format(cls.regex),
                 message=(_("must comprise only of ")) + cls.message
+            ),
+            validators.NoneOf(
+                username_blocklist() if blocklist else (),
+                message="Username not available"
             )
         ]
 
@@ -57,11 +62,11 @@ class ProfileFormat(object):
             index += 1
 
 
-register = ProfileFormat.register
+register = UsernameFormat.register
 
 
-@register("lowercase", "", None)
-class LowercaseAlphanumPunct(ProfileFormat):
+@register("lowercase")
+class LowercaseAlphanumPunct(UsernameFormat):
     """ Lowercase username, including digits and very basic punctuation
     """
 
@@ -72,7 +77,7 @@ class LowercaseAlphanumPunct(ProfileFormat):
 
 
 @register("alnum")
-class AlphanumPunct(ProfileFormat):
+class AlphanumPunct(UsernameFormat):
     """ Alphanum username, including some very basic punctuation
     """
 
diff --git a/hiboo/models.py b/hiboo/models.py
index 6c8125d..fd2977a 100644
--- a/hiboo/models.py
+++ b/hiboo/models.py
@@ -1,5 +1,6 @@
 from passlib import context, hash
 from flask import current_app as app
+from hiboo.security import username_blocklist
 from sqlalchemy import orm, types, schema, sql
 from sqlalchemy.ext import mutable
 from flask_babel import lazy_gettext as _
@@ -148,6 +149,20 @@ class User(db.Model):
             db.session.delete(user)
         db.session.commit()
 
+    @classmethod
+    def exist_username(cls, username):
+        return cls.query.filter(cls.username.ilike(username)).first()
+
+    def reserved_username(self, username):
+        return self.username == username.lower()
+
+    def available_username(self, username, service, spoof_allowed=False):
+        return not Profile.exist_username(username, service=service) and (
+            not User.exist_username(username)
+            or self.reserved_username(username)
+            or spoof_allowed
+        )
+
 
 class Auth(db.Model):
     """ An authenticator is a method to authenticate a user.
@@ -220,9 +235,6 @@ class Service(db.Model):
     single_profile = db.Column(db.Boolean(), nullable=False, default=False)
     config = db.Column(mutable.MutableDict.as_mutable(JSONEncoded))
 
-    def check_username(self, username):
-        return Profile.query.filter_by(service_uuid=self.uuid, username=username).first()
-
 
 class Profile(db.Model):
     """ A profile is a per-service custom identity.
@@ -329,6 +341,10 @@ class Profile(db.Model):
             )
         ))
 
+    @classmethod
+    def exist_username(cls, username, service):
+        return cls.query.filter_by(service_uuid=service.uuid).filter(cls.username.ilike(username)).first()
+
 
 class ClaimName(db.Model):
     """ A profile might have multiple claimable names.
diff --git a/hiboo/profile/forms.py b/hiboo/profile/forms.py
index 6775c5d..e5c4e1d 100644
--- a/hiboo/profile/forms.py
+++ b/hiboo/profile/forms.py
@@ -7,6 +7,11 @@ import flask_wtf
 class ProfileForm(flask_wtf.FlaskForm):
     username = fields.StringField(_('Username'), [validators.DataRequired()])
     comment = fields.StringField(_('Comment'))
+    username_spoof_protection = fields.BooleanField(
+        _('Username spoof protection'),
+        description=_("Prevent to register a profile username that case-insensitively exists in user database"),
+        default=True
+    )
     submit = fields.SubmitField(_('Create profile'))
 
     def force_username(self, username):
diff --git a/hiboo/profile/views.py b/hiboo/profile/views.py
index 45df1b9..1965e26 100644
--- a/hiboo/profile/views.py
+++ b/hiboo/profile/views.py
@@ -26,7 +26,7 @@ def create(service_uuid, create_for=False, quick=False):
     service = models.Service.query.get(service_uuid) or flask.abort(404)
     status = models.Profile.ACTIVE
     is_admin = flask_login.current_user.is_admin
-    formatter = format.ProfileFormat.registry[service.profile_format]
+    formatter = format.UsernameFormat.registry[service.profile_format]
     # If the admin passed a user uuid, use that one, otherwise ignore it
     user = hiboo_user.get_user(intent="profile.create_for", create_for=None) if (create_for and is_admin) else flask_login.current_user
     # Check that profile creation is allowed
@@ -46,21 +46,25 @@ def create(service_uuid, create_for=False, quick=False):
         status = models.Profile.REQUEST
     # Initialize and validate the form before applying overrides
     form = forms.ProfileForm()
-    form.username.validators = formatter.validators()
+    form.username.validators = formatter.validators(blocklist=not is_admin)
+    if not is_admin:
+        del form.username_spoof_protection
     submit = form.validate_on_submit()
     # If this is a quick creation or the service prevents custom profile creation, force the username
     if quick or service.single_profile:
         for username in formatter.alternatives(formatter.coalesce(user.username)):
-            if not service.check_username(username):
+            if user.available_username(username, service=service):
                 form.force_username(username)
                 break
     if quick:
         form.hide_fields()
     # Handle the creation form
     if submit:
-        if service.check_username(form.username.data):
-            flask.flash(_("A profile with that username exists already"), "danger")
-        else:
+        if user.available_username(
+            username=form.username.data,
+            service=service,
+            spoof_allowed=not form.username_spoof_protection.data if is_admin else False,
+        ):
             profile = models.Profile(
                 user_uuid=user.uuid, service_uuid=service.uuid,
                 username=form.username.data,
@@ -72,6 +76,8 @@ def create(service_uuid, create_for=False, quick=False):
                 user=user, service=service, profile=profile)
             models.db.session.commit()
             return flask.redirect(utils.url_or_intent("account.home"))
+        else:
+            flask.flash(_("A profile with that username exists already"), "danger")
     # Display either the quick version or the full version (one can switch from one to another)
     return flask.render_template("profile_quick.html" if quick else "profile_create.html",
         form=form, service=service, user=user, create_for=create_for)
diff --git a/hiboo/security.py b/hiboo/security.py
index 6ee5ea3..859b855 100644
--- a/hiboo/security.py
+++ b/hiboo/security.py
@@ -62,3 +62,18 @@ def confirmation_required(action):
             )
         return wrapper
     return inner
+
+
+def username_blocklist():
+    """ Return an opinionated set of unsafe usernames
+    """
+    return set.union(
+        # linux common users and groups
+        {"abuild", "adm", "_apt", "at", "audio", "avahi", "backup", "bin", "bluetooth", "cdrom", "cdrw", "console", "cron", "crontab", "cyrus", "daemon", "dbus", "dhcp", "dialout", "dip", "disk", "dnsmasq", "docker", "fax", "floppy", "ftp", "games", "gnats", "guest", "halt", "http", "input", "irc", "kmem", "kvm", "libvirt", "list", "locate", "lock", "log", "lp", "lpadmin", "mail", "man", "mem", "messagebus", "netdev", "network", "news", "nobody", "nofiles", "nogroup", "ntp", "nullmail", "nvpd", "openvpn", "operator", "optical", "pcap", "ping", "plugdev", "polkitd", "portage", "postfix", "postmaster", "power", "proc", "proxy", "qemu", "radvd", "readproc", "render", "rfkill", "root", "saned", "sasl", "scanner", "sgx", "shadow", "shutdown", "smmsp", "squid", "src", "ssh", "sshd", "ssl-cert", "staff", "storage", "sudo", "sync", "sys", "systemd", "systemd-coredump", "systemd-journal", "systemd-network", "systemd-resolve", "systemd-timesync", "tape", "tcpdump", "tss", "tty", "usb", "users", "utmp", "uucp", "uuidd", "video", "voice", "vpopmail", "wheel", "www", "www-data", "xfs"},
+        # linux reserved uids
+        {uid for uid in range(1000)},
+        # web stack commons
+        {"admin", "administrator", "git", "localhost", "localdomain", "mariadb", "master", "mysql", "nginx", "no-reply", "noreply", "passwd", "password", "postgres", "python", "sqlite", "user", "users", "username"},
+        # Hiboo specifics
+        {"account", "action", "api", "app", "application_id", "assign", "auth", "authentication", "authorize", "cancel", "check", "claim", "complete", "contact", "create", "create_for", "create_quick", "delete", "details", "disable", "edit", "email", "enable", "flask", "home", "invite", "jwks", "list", "login", "logout", "metadata", "oidc", "openid-configuration", "password", "pick", "profile", "profiles", "profile_uuid", "redirect", "reset", "saml", "service", "service_uuid", "setapp", "signin", "signout", "signup", "sso", "static", "status", "token", "totp", "transition", "transition_id", "unclaimedexport", "user", "userinfo", "user_uuid"}
+    )
-- 
GitLab