Warning
This document is for an in-development version of Galaxy. You can alternatively view this page in the latest release if it exists or view the top of the latest release's documentation.
Source code for galaxy.security.validate_user_input
"""
Utilities for validating inputs related to user objects.
The validate_* methods in this file return simple messages that do not contain
user inputs - so these methods do not need to be escaped.
"""
import logging
from typing import (
Any,
Protocol,
TYPE_CHECKING,
)
import dns.resolver
from dns.exception import DNSException
from sqlalchemy import (
func,
select,
)
from typing_extensions import LiteralString
from galaxy.objectstore import ObjectStore
from galaxy.util.user_input import (
EMAIL_MAX_LEN,
FILL_CHAR,
is_valid_email_str,
PASSWORD_MIN_LEN,
PUBLICNAME_MAX_LEN,
transform_publicname,
VALID_EMAIL_RE,
VALID_PUBLICNAME_RE,
VALID_PUBLICNAME_SUB,
validate_email_str,
validate_password_str,
validate_publicname_str,
)
if TYPE_CHECKING:
from galaxy.model import User
[docs]
class UserValidationContext(Protocol):
"""What the user input validators need from a transaction.
Galaxy and the tool shed keep separate context hierarchies with their own
app, config and User classes. Both offer a session and an app, so the
validators ask for that much rather than naming either hierarchy and
forcing one side to depend on the other.
"""
@property
def app(self) -> Any: ...
@property
def sa_session(self) -> Any: ...
log = logging.getLogger(__name__)
[docs]
def validate_email(
trans: UserValidationContext, email, user=None, check_dup=True, allow_empty=False, validate_domain=False
):
"""
Validates the email format.
Checks whether the domain is blocklisted in the disposable domains configuration.
Checks whether the email address is banned.
Optionally checks if email exists.
"""
if (user and user.email == email) or (email == "" and allow_empty):
return ""
message = validate_email_str(email)
if not message and validate_domain:
domain = extract_domain(email)
message = validate_email_domain_name(domain)
if not message:
if is_email_banned(email, trans.app.config.email_ban_file, trans.app.config.canonical_email_rules):
message = "This email address has been banned."
if not message and check_dup:
user_class = trans.app.model.User
stmt = select(user_class).filter(func.lower(user_class.email) == email.lower())
if user is not None:
# Changing only the case of one's own address is not a duplicate.
stmt = stmt.filter(user_class.id != user.id)
if trans.sa_session.scalars(stmt.limit(1)).first():
message = f"User with email '{email}' already exists."
if not message:
# If the allowlist is not empty filter out any domain not in the list and ignore blocklist.
if trans.app.config.email_domain_allowlist_content is not None:
domain = extract_domain(email)
if domain not in trans.app.config.email_domain_allowlist_content:
message = "Please enter an allowed domain email address for this server."
# If the blocklist is not empty filter out the disposable domains.
elif trans.app.config.email_domain_blocklist_content is not None:
domain = extract_domain(email, base_only=True)
if domain in trans.app.config.email_domain_blocklist_content:
message = "Please enter your permanent email address."
return message
[docs]
def validate_email_domain_name(domain: str) -> LiteralString:
message = ""
try:
dns.resolver.resolve(domain, "MX")
except DNSException:
try:
# Per RFC 5321, try to fall back to the A record (implicit MX) for
# the domain, see https://www.rfc-editor.org/rfc/rfc5321#section-5.1
dns.resolver.resolve(domain, "A")
except DNSException:
message = "The email domain cannot be resolved."
return message
[docs]
def extract_domain(email, base_only=False):
domain = email.rsplit("@", 1)[-1]
parts = domain.split(".")
if len(parts) > 2 and base_only:
return (".").join(parts[-2:])
return domain
[docs]
def validate_publicname(trans: UserValidationContext, publicname, user=None):
"""
Check that publicname respects the minimum and maximum string length, the
allowed characters, and that the username is not taken already.
"""
if user and user.username == publicname:
return ""
if message := validate_publicname_str(publicname):
return message
stmt = select(trans.app.model.User).filter_by(username=publicname).limit(1)
if trans.sa_session.scalars(stmt).first():
return "Public name is taken; please choose another."
return ""
[docs]
def validate_password(trans: UserValidationContext, password, confirm):
if password != confirm:
return "Passwords do not match."
return validate_password_str(password)
[docs]
def validate_preferred_object_store_id(
user: "User | None", object_store: ObjectStore, preferred_object_store_id: str | None
) -> str:
return object_store.validate_selected_object_store_id(user, preferred_object_store_id) or ""
[docs]
def is_email_banned(email: str, filepath: str | None, canonical_email_rules: dict | None) -> bool:
if not filepath:
return False
normalizer = EmailAddressNormalizer(canonical_email_rules)
email = normalizer.normalize(email)
banned_emails = _read_email_ban_list(filepath)
for address in banned_emails:
if email == normalizer.normalize(address):
return True
return False
def _read_email_ban_list(filepath: str) -> list[str]:
with open(filepath) as f:
return [line.strip() for line in f if not line.startswith("#")]
[docs]
class EmailAddressNormalizer:
IGNORE_CASE_RULE = "ignore_case"
IGNORE_DOTS_RULE = "ignore_dots"
SUB_ADDRESSING_RULE = "sub_addressing"
SUB_ADDRESSING_DELIM = "sub_addressing_delim"
SUB_ADDRESSING_DELIM_DEFAULT = "+"
ALL = "all"
[docs]
def __init__(self, canonical_email_rules: dict | None) -> None:
self.config = canonical_email_rules
[docs]
def normalize(self, email: str) -> str:
"""Transform email to its canonical form."""
email_localpart, email_domain = email.split("@")
# the domain part of an email address is case-insensitive (RFC1035)
email_domain = email_domain.lower()
# Step 1: If no rules are set, do not modify local-part
if not self.config:
return f"{email_localpart}@{email_domain}"
# Step 2: Apply rules defined for all services before applying rules defined for specific services
if self.ALL in self.config:
email_localpart = self._apply_rules(email_localpart, self.ALL)
# Step 3: Apply rules definied for each email service if email matches service
for service in (s for s in self.config if s != self.ALL):
service = service.lower() # ensure domain is lowercase
apply_rules = False
if email_domain == service:
apply_rules = True
elif self.config[service].get("aliases"):
service_aliases = [
a.lower() for a in self.config[service]["aliases"]
] # ensure domain aliases are lowercase
if email_domain in service_aliases:
# email domain is an alias of the service. Change it to the service's primary domain name.
email_domain = service
apply_rules = True
if apply_rules:
email_localpart = self._apply_rules(email_localpart, service)
return f"{email_localpart}@{email_domain}"
def _apply_rules(self, email_localpart: str, service: str) -> str:
assert self.config
config = self.config[service]
if config.get(self.IGNORE_CASE_RULE, False):
email_localpart = email_localpart.lower()
if config.get(self.IGNORE_DOTS_RULE, False):
email_localpart = email_localpart.replace(".", "")
if config.get(self.SUB_ADDRESSING_RULE, False):
delim = config.get(self.SUB_ADDRESSING_DELIM, self.SUB_ADDRESSING_DELIM_DEFAULT)
if email_localpart.find(delim) > -1:
email_localpart = email_localpart[: email_localpart.index(delim)]
return email_localpart
__all__ = (
"EMAIL_MAX_LEN",
"EmailAddressNormalizer",
"extract_domain",
"FILL_CHAR",
"is_email_banned",
"is_valid_email_str",
"PASSWORD_MIN_LEN",
"PUBLICNAME_MAX_LEN",
"transform_publicname",
"UserValidationContext",
"VALID_EMAIL_RE",
"VALID_PUBLICNAME_RE",
"VALID_PUBLICNAME_SUB",
"validate_email",
"validate_email_domain_name",
"validate_email_str",
"validate_password",
"validate_password_str",
"validate_preferred_object_store_id",
"validate_publicname",
"validate_publicname_str",
)