Fix several small security and robustness issues
Login: - One message for unknown email, wrong password and deactivated account. Before, "This account is not enabled!" only appeared after the right password and confirmed valid credentials. - Email and password must be non-empty strings. A missing password field became the string "None" and logged in a user with that password. Forms: - "Create user" no longer renders the password back into the HTML after a validation error. - Password fields for a new password use autocomplete="new-password", so a password manager does not fill in the admin's own password. The email fields on the create pages use autocomplete="off". - Email addresses are limited to 64 characters in the local part and 254 in total (RFC 5321). Longer ones caused a database error and HTTP 500. - alerts.html no longer translates flash messages a second time. Running str.format() again on an already formatted message was fragile and logged "Unknown translation" for every German message. All flashes now translate when flashing, "Invalid data" included. Debug mode: - The Docker image sets MAX_PRODUCTION=1, and the app refuses to start in debug mode then. FLASK_DEBUG=1 in production would otherwise enable the fixed secret key, cookies without Secure, no HSTS and query arguments (including password hashes) in the log. - make run-dev and run-dev-as-prod bind to 127.0.0.1. Other: - A denied access is logged with the logger at debug level instead of print. PermissionDenied loses its unused message, it is deliberately answered with a 404. - Fix the typo in /user/<id>/ser-toggle-can-use-different-alias-domain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
58242117f6
commit
2b1160862a
17 files changed
+59
-27
No files matched your search
@@ -26,6 +26,8 @@ WORKDIR /app
|
|||||||
|
|
||||||
ENV PYTHONPATH=/app/
|
ENV PYTHONPATH=/app/
|
||||||
ENV FLASK_APP=max.app
|
ENV FLASK_APP=max.app
|
||||||
|
# Refuses to start in debug mode, see max/app.py
|
||||||
|
ENV MAX_PRODUCTION=1
|
||||||
|
|
||||||
# Owned by root, so the app cannot modify its own code
|
# Owned by root, so the app cannot modify its own code
|
||||||
COPY max/ ./max
|
COPY max/ ./max
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ clear-dev:
|
|||||||
scripts/dev/clear-db.sh
|
scripts/dev/clear-db.sh
|
||||||
|
|
||||||
run-dev:
|
run-dev:
|
||||||
FLASK_APP=max.app FLASK_DEBUG=1 flask run --host 0.0.0.0 --port 8757
|
FLASK_APP=max.app FLASK_DEBUG=1 flask run --host 127.0.0.1 --port 8757
|
||||||
|
|
||||||
run-dev-as-prod:
|
run-dev-as-prod:
|
||||||
FLASK_APP=max.app MAX_ORIGIN=http://localhost:8757 gunicorn -w 1 -b 0.0.0.0:8757 max.app:app
|
FLASK_APP=max.app MAX_ORIGIN=http://localhost:8757 gunicorn -w 1 -b 127.0.0.1:8757 max.app:app
|
||||||
|
|
||||||
run-cleanup:
|
run-cleanup:
|
||||||
scripts/dev/cleanup.sh
|
scripts/dev/cleanup.sh
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ Start with `make run-dev`
|
|||||||
|
|
||||||
- `MAX_ORIGIN` (required in production): The public origin of MAX, e.g.
|
- `MAX_ORIGIN` (required in production): The public origin of MAX, e.g.
|
||||||
`https://max.finn.st`
|
`https://max.finn.st`
|
||||||
|
- `MAX_PRODUCTION=1` is set in the Docker image and makes MAX refuse to start
|
||||||
|
in debug mode (`FLASK_DEBUG`). Do not override it.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ def main():
|
|||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
init_logging(app)
|
init_logging(app)
|
||||||
app.logger.info("Max started.")
|
app.logger.info("Max started.")
|
||||||
|
if app.debug and os.environ.get("MAX_PRODUCTION") == "1":
|
||||||
|
raise RuntimeError(
|
||||||
|
"Debug mode is not allowed in production (MAX_PRODUCTION=1), unset FLASK_DEBUG"
|
||||||
|
)
|
||||||
if app.debug:
|
if app.debug:
|
||||||
app.logger.info("!! App is in debug mode !!")
|
app.logger.info("!! App is in debug mode !!")
|
||||||
else:
|
else:
|
||||||
|
|||||||
+9
-3
@@ -10,18 +10,24 @@ from max.sessions import session_storage
|
|||||||
from max.translations import t
|
from max.translations import t
|
||||||
|
|
||||||
SSHA512_PREFIX = "{SSHA512}"
|
SSHA512_PREFIX = "{SSHA512}"
|
||||||
|
LOGIN_FAILED_MESSAGE = "Email or password is wrong, or the account is deactivated"
|
||||||
|
|
||||||
|
|
||||||
def login(email, password):
|
def login(email, password):
|
||||||
|
if not isinstance(email, str) or not isinstance(password, str):
|
||||||
|
return t(LOGIN_FAILED_MESSAGE)
|
||||||
|
if not email or not password:
|
||||||
|
return t(LOGIN_FAILED_MESSAGE)
|
||||||
|
|
||||||
user = get_user_by_email(email)
|
user = get_user_by_email(email)
|
||||||
if user is None:
|
if user is None:
|
||||||
return t("Email or password was wrong!")
|
return t(LOGIN_FAILED_MESSAGE)
|
||||||
|
|
||||||
if not check_password(password, user.passwordhash):
|
if not check_password(password, user.passwordhash):
|
||||||
return t("Email or password was wrong!")
|
return t(LOGIN_FAILED_MESSAGE)
|
||||||
|
|
||||||
if not user.enabled:
|
if not user.enabled:
|
||||||
return t("This account is not enabled!")
|
return t(LOGIN_FAILED_MESSAGE)
|
||||||
|
|
||||||
# Start a new session (new id, new CSRF token) against session fixation.
|
# Start a new session (new id, new CSRF token) against session fixation.
|
||||||
session.clear()
|
session.clear()
|
||||||
|
|||||||
+4
-3
@@ -15,6 +15,7 @@ class SessionWithoutUser(MaxException):
|
|||||||
|
|
||||||
|
|
||||||
class PermissionDenied(MaxException):
|
class PermissionDenied(MaxException):
|
||||||
def __init__(self, message):
|
"""
|
||||||
super().__init__()
|
Deliberately answered with a 404, like NotFound, so nobody can tell
|
||||||
self.message = message
|
whether the object exists.
|
||||||
|
"""
|
||||||
@@ -38,8 +38,7 @@
|
|||||||
"Delete alias {alias}": "Lösche Alias {alias}",
|
"Delete alias {alias}": "Lösche Alias {alias}",
|
||||||
"Alias {alias} for {user}": "Alias {alias} für {user}",
|
"Alias {alias} for {user}": "Alias {alias} für {user}",
|
||||||
"Edit note for alias {alias}": "Notiz für Alias {alias} bearbeiten",
|
"Edit note for alias {alias}": "Notiz für Alias {alias} bearbeiten",
|
||||||
"Email or password was wrong!": "Email oder Password ist falsch!",
|
"Email or password is wrong, or the account is deactivated": "E-Mail oder Passwort falsch, oder der Account ist deaktiviert",
|
||||||
"This account is not enabled!": "Dieser Account ist nicht aktiviert!",
|
|
||||||
"Login was sucessfull!": "Erfolgreich angemeldet!",
|
"Login was sucessfull!": "Erfolgreich angemeldet!",
|
||||||
"Logout was sucessfull!": "Erfolgreich abgemeldet!",
|
"Logout was sucessfull!": "Erfolgreich abgemeldet!",
|
||||||
"The email is not valid": "Die Email ist nicht valide",
|
"The email is not valid": "Die Email ist nicht valide",
|
||||||
@@ -67,6 +66,8 @@
|
|||||||
"Used as an user.": "Wird für einen Nutzer verwendet",
|
"Used as an user.": "Wird für einen Nutzer verwendet",
|
||||||
"Used as an alias by {alias}.": "Word für den Alias {alias} verwendet",
|
"Used as an alias by {alias}.": "Word für den Alias {alias} verwendet",
|
||||||
"Invalid domain": "Invalide Domain",
|
"Invalid domain": "Invalide Domain",
|
||||||
|
"Invalid data": "Ungültige Daten",
|
||||||
|
"The email is too long": "Die E-Mail ist zu lang",
|
||||||
"Can use different alias domains": "Kann unterschiedliche Aliasdomains benutzen",
|
"Can use different alias domains": "Kann unterschiedliche Aliasdomains benutzen",
|
||||||
"Can use different alias domains is not set": "Kann unterschiedliche Aliasdomains benutzen ist nicht gesetzt",
|
"Can use different alias domains is not set": "Kann unterschiedliche Aliasdomains benutzen ist nicht gesetzt",
|
||||||
"This address is reserved and can only be created by an admin.": "Diese Adresse ist reserviert und kann nur von einem Admin angelegt werden."
|
"This address is reserved and can only be created by an admin.": "Diese Adresse ist reserviert und kann nur von einem Admin angelegt werden."
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ def init_routes(app):
|
|||||||
UserToggleAdmin.as_view(),
|
UserToggleAdmin.as_view(),
|
||||||
)
|
)
|
||||||
app.add_url_rule(
|
app.add_url_rule(
|
||||||
"/user/<int:user_id>/ser-toggle-can-use-different-alias-domain",
|
"/user/<int:user_id>/toggle-can-use-different-alias-domain",
|
||||||
"user-toggle-can-use-different-alias-domain",
|
"user-toggle-can-use-different-alias-domain",
|
||||||
UserToggleCanUseDifferentAliasDomain.as_view(),
|
UserToggleCanUseDifferentAliasDomain.as_view(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class AliasEditNote(AllowAdminOrSelf, BackToAliasesMixin, FetchAliasTemplateGetV
|
|||||||
def post(self):
|
def post(self):
|
||||||
note = self.request.form.get("note", "")
|
note = self.request.form.get("note", "")
|
||||||
if not isinstance(note, str):
|
if not isinstance(note, str):
|
||||||
flash("invalid data", category="error")
|
flash(t("Invalid data"), category="error")
|
||||||
return
|
return
|
||||||
|
|
||||||
update_alias(self.alias.id, note=note)
|
update_alias(self.alias.id, note=note)
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ from flask import flash
|
|||||||
from max.db import get_domains
|
from max.db import get_domains
|
||||||
from max.translations import t
|
from max.translations import t
|
||||||
|
|
||||||
|
# RFC 5321, both below the varchar(255) of emails.email
|
||||||
|
MAX_LOCAL_PART_LENGTH = 64
|
||||||
|
MAX_EMAIL_LENGTH = 254
|
||||||
|
|
||||||
RESERVED_LOCAL_PARTS = frozenset(
|
RESERVED_LOCAL_PARTS = frozenset(
|
||||||
(
|
(
|
||||||
"admin",
|
"admin",
|
||||||
@@ -24,6 +28,11 @@ RESERVED_LOCAL_PARTS = frozenset(
|
|||||||
class CheckEmailMixin:
|
class CheckEmailMixin:
|
||||||
def is_email_valid(self, email: str):
|
def is_email_valid(self, email: str):
|
||||||
"""Checks for the email being valid and that it has a valid domain."""
|
"""Checks for the email being valid and that it has a valid domain."""
|
||||||
|
local_part = email.split("@")[0]
|
||||||
|
if len(local_part) > MAX_LOCAL_PART_LENGTH or len(email) > MAX_EMAIL_LENGTH:
|
||||||
|
flash(t("The email is too long"), category="error")
|
||||||
|
return False
|
||||||
|
|
||||||
domains = "|".join(map(re.escape, get_domains()))
|
domains = "|".join(map(re.escape, get_domains()))
|
||||||
regex = re.compile(f"^[a-zA-Z0-9+_-]+@({domains})$")
|
regex = re.compile(f"^[a-zA-Z0-9+_-]+@({domains})$")
|
||||||
match = regex.match(email)
|
match = regex.match(email)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class UserChangePassword(AllowAdminOrSelf, BackToUsersMixin, FetchUserTemplateGe
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not isinstance(pw1, str) or not isinstance(pw2, str):
|
if not isinstance(pw1, str) or not isinstance(pw2, str):
|
||||||
flash("Invalid data", category="error")
|
flash(t("Invalid data"), category="error")
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.is_own_password():
|
if self.is_own_password():
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class UserCreate(AllowAdmin, CheckEmailMixin, BaseTemplateGetView):
|
|||||||
flash(t("Both passwords must be given"), category="error")
|
flash(t("Both passwords must be given"), category="error")
|
||||||
error = True
|
error = True
|
||||||
elif not isinstance(pw1, str) or not isinstance(pw2, str):
|
elif not isinstance(pw1, str) or not isinstance(pw2, str):
|
||||||
flash("Invalid data")
|
flash(t("Invalid data"), category="error")
|
||||||
error = True
|
error = True
|
||||||
elif pw1 != pw2:
|
elif pw1 != pw2:
|
||||||
flash(t("Both passwords must be the same"), category="error")
|
flash(t("Both passwords must be the same"), category="error")
|
||||||
@@ -120,7 +120,6 @@ class UserCreate(AllowAdmin, CheckEmailMixin, BaseTemplateGetView):
|
|||||||
for key in (
|
for key in (
|
||||||
"email",
|
"email",
|
||||||
"domain",
|
"domain",
|
||||||
"password",
|
|
||||||
"enabled",
|
"enabled",
|
||||||
"is_admin",
|
"is_admin",
|
||||||
"can_use_different_alias_domain",
|
"can_use_different_alias_domain",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class UserEditNote(AllowAdmin, BackToUsersMixin, FetchUserTemplateGetView):
|
|||||||
def post(self):
|
def post(self):
|
||||||
note = self.request.form.get("note", "")
|
note = self.request.form.get("note", "")
|
||||||
if not isinstance(note, str):
|
if not isinstance(note, str):
|
||||||
flash("invalid data", category="error")
|
flash(t("Invalid data"), category="error")
|
||||||
return
|
return
|
||||||
|
|
||||||
update_user(self.user.id, note=note)
|
update_user(self.user.id, note=note)
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{% for category, message in get_flashed_messages(with_categories=true) %}
|
{% for category, message in get_flashed_messages(with_categories=true) %}
|
||||||
<div class="msg-{{ category }}">{{ t(message) }}</div>
|
<div class="msg-{{ category }}">{{ message }}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<form id="form" class="pure-form pure-form-aligned" action="{{ url_for('alias-create', user_id=user.id) }}" method="post">
|
<form id="form" class="pure-form pure-form-aligned" action="{{ url_for('alias-create', user_id=user.id) }}" method="post">
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<label for="email">{{ t("Email") }}</label>
|
<label for="email">{{ t("Email") }}</label>
|
||||||
<input id="email" type="text" name="email" {% if email is defined %}value="{{ email }}"{% endif %} placeholder="{{ t("Email") }}">
|
<input id="email" type="text" name="email" {% if email is defined %}value="{{ email }}"{% endif %} autocomplete="off" placeholder="{{ t("Email") }}">
|
||||||
|
|
||||||
<div class="domain-selector">
|
<div class="domain-selector">
|
||||||
{% if can_use_different_alias_domain %}
|
{% if can_use_different_alias_domain %}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<form id="form" class="pure-form pure-form-aligned" action="{{ url_for('user-create') }}" method="post">
|
<form id="form" class="pure-form pure-form-aligned" action="{{ url_for('user-create') }}" method="post">
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<label for="email">{{ t("Email") }}</label>
|
<label for="email">{{ t("Email") }}</label>
|
||||||
<input id="email" type="text" name="email" {% if email is defined %}value="{{ email }}"{% endif %} placeholder="{{ t("Email") }}">
|
<input id="email" type="text" name="email" {% if email is defined %}value="{{ email }}"{% endif %} autocomplete="off" placeholder="{{ t("Email") }}">
|
||||||
|
|
||||||
<div class="domain-selector">
|
<div class="domain-selector">
|
||||||
@
|
@
|
||||||
@@ -19,12 +19,12 @@
|
|||||||
|
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<label for="password1">{{ t("Password") }}</label>
|
<label for="password1">{{ t("Password") }}</label>
|
||||||
<input id="password1" type="password" name="password1" {% if password is defined %}value="{{ password }}"{% endif %} placeholder="{{ t("Password") }}">
|
<input id="password1" type="password" name="password1" autocomplete="new-password" placeholder="{{ t("Password") }}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<label for="password2">{{ t("Reenter Password") }}</label>
|
<label for="password2">{{ t("Reenter Password") }}</label>
|
||||||
<input id="password2" type="password" name="password2" {% if password is defined %}value="{{ password }}"{% endif %} placeholder="{{ t("Reenter Password") }}">
|
<input id="password2" type="password" name="password2" autocomplete="new-password" placeholder="{{ t("Reenter Password") }}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pure-controls checkbox">
|
<div class="pure-controls checkbox">
|
||||||
|
|||||||
+14
-6
@@ -1,6 +1,12 @@
|
|||||||
from functools import update_wrapper
|
from functools import update_wrapper
|
||||||
|
|
||||||
from flask import current_app, redirect as flask_redirect, render_template, request
|
from flask import (
|
||||||
|
current_app,
|
||||||
|
redirect as flask_redirect,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
)
|
||||||
from werkzeug.exceptions import MethodNotAllowed
|
from werkzeug.exceptions import MethodNotAllowed
|
||||||
|
|
||||||
from max.exceptions import ImproperlyConfigured, PermissionDenied
|
from max.exceptions import ImproperlyConfigured, PermissionDenied
|
||||||
@@ -72,11 +78,13 @@ class BaseView:
|
|||||||
|
|
||||||
def check_access(self):
|
def check_access(self):
|
||||||
if not self.can_access():
|
if not self.can_access():
|
||||||
print("Cannot access for ", self.__class__.__name__)
|
self.app.logger.debug(
|
||||||
raise PermissionDenied(
|
"Access denied for user %s to %s (%s)",
|
||||||
"Not found"
|
session.get("user_id"),
|
||||||
) # we do not want to pass PermissionDenied directly to the user.
|
self.request.path,
|
||||||
# TODO: Better message.
|
self.__class__.__name__,
|
||||||
|
)
|
||||||
|
raise PermissionDenied()
|
||||||
|
|
||||||
def can_access(self):
|
def can_access(self):
|
||||||
return False
|
return False
|
||||||
|
|||||||
Reference in new issue
Block a user