diff --git a/.env.prod.example b/.env.prod.example index 5604b8a..c54637a 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -2,6 +2,7 @@ APP_DOMAIN=workdock.example.com APP_BASE_URL=https://workdock.example.com DJANGO_SECRET_KEY=change-me-long-random-value DJANGO_DEBUG=0 +FORCE_BRANDED_ERROR_PAGES=0 DJANGO_ALLOWED_HOSTS=workdock.example.com DJANGO_CSRF_TRUSTED_ORIGINS=https://workdock.example.com DJANGO_SECURE_COOKIES=1 diff --git a/.env.test.example b/.env.test.example index b021ffb..aa5c6cf 100644 --- a/.env.test.example +++ b/.env.test.example @@ -2,6 +2,7 @@ APP_DOMAIN= APP_BASE_URL= DJANGO_SECRET_KEY=change-me-long-random-value DJANGO_DEBUG=1 +FORCE_BRANDED_ERROR_PAGES=1 DJANGO_ALLOWED_HOSTS=192.168.2.55,localhost,127.0.0.1 DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.2.55:8088 DJANGO_SECURE_COOKIES=0 diff --git a/backend/config/settings.py b/backend/config/settings.py index 3a0db7a..c64ae8c 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -22,6 +22,7 @@ def _hostname_from_url(url: str) -> str: SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'unsafe-dev-key') DEBUG = os.getenv('DJANGO_DEBUG', '0') == '1' +FORCE_BRANDED_ERROR_PAGES = os.getenv('FORCE_BRANDED_ERROR_PAGES', '0') == '1' APP_DOMAIN = os.getenv('APP_DOMAIN', '').strip() APP_BASE_URL = os.getenv('APP_BASE_URL', '').strip().rstrip('/') ALLOWED_HOSTS = _split_csv_env('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1') @@ -84,6 +85,7 @@ MIDDLEWARE = [ 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.locale.LocaleMiddleware', 'django.middleware.common.CommonMiddleware', + 'workflows.middleware.FriendlyExceptionMiddleware', 'workflows.middleware.RequestIDMiddleware', 'workflows.middleware.RateLimitMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -95,6 +97,7 @@ MIDDLEWARE = [ ] ROOT_URLCONF = 'config.urls' +CSRF_FAILURE_VIEW = 'workflows.error_views.csrf_failure' TEMPLATES = [ { diff --git a/backend/config/urls.py b/backend/config/urls.py index b783ae7..7ff67f2 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -7,6 +7,11 @@ from django.urls import include, path from workflows.forms import AppPasswordChangeForm, AppPasswordResetForm, AppSetPasswordForm from workflows import views as workflow_views +handler400 = 'workflows.error_views.bad_request' +handler403 = 'workflows.error_views.permission_denied' +handler404 = 'workflows.error_views.not_found' +handler500 = 'workflows.error_views.server_error' + urlpatterns = [ path('admin/', admin.site.urls), path('i18n/', include('django.conf.urls.i18n')), diff --git a/backend/workflows/error_views.py b/backend/workflows/error_views.py new file mode 100644 index 0000000..9923f27 --- /dev/null +++ b/backend/workflows/error_views.py @@ -0,0 +1,71 @@ +from django.shortcuts import render +from django.utils.translation import gettext as _ + + +def _render_error(request, *, status: int, title: str, code: str, heading: str, message: str): + return render( + request, + 'workflows/errors/error_page.html', + { + 'error_title': title, + 'error_code': code, + 'error_heading': heading, + 'error_message': message, + }, + status=status, + ) + + +def bad_request(request, exception=None): + return _render_error( + request, + status=400, + title=_('Ungültige Anfrage'), + code='400', + heading=_('Die Anfrage konnte nicht verarbeitet werden'), + message=_('Die übermittelten Daten waren unvollständig oder ungültig. Bitte gehen Sie zurück und versuchen Sie es erneut.'), + ) + + +def permission_denied(request, exception=None): + return _render_error( + request, + status=403, + title=_('Kein Zugriff'), + code='403', + heading=_('Für diese Seite fehlt die Berechtigung'), + message=_('Sie sind angemeldet, aber für diesen Bereich nicht freigeschaltet. Wenn das nicht erwartet ist, wenden Sie sich an die Administration.'), + ) + + +def not_found(request, exception=None): + return _render_error( + request, + status=404, + title=_('Seite nicht gefunden'), + code='404', + heading=_('Diese Seite gibt es nicht'), + message=_('Die gewünschte Adresse ist nicht vorhanden oder wurde verschoben. Nutzen Sie die Startseite oder das Dashboard, um weiterzugehen.'), + ) + + +def server_error(request): + return _render_error( + request, + status=500, + title=_('Serverfehler'), + code='500', + heading=_('Etwas ist schiefgelaufen'), + message=_('Der Fehler wurde nicht sauber verarbeitet. Bitte laden Sie die Seite neu. Wenn das Problem bleibt, prüfen Sie die Server-Logs.'), + ) + + +def csrf_failure(request, reason=''): + return _render_error( + request, + status=400, + title=_('Sicherheitsprüfung fehlgeschlagen'), + code='400', + heading=_('Die Sitzung konnte nicht bestätigt werden'), + message=_('Bitte laden Sie die Seite neu und senden Sie das Formular erneut. Wenn das weiter passiert, prüfen Sie Host-, HTTPS- und CSRF-Einstellungen.'), + ) diff --git a/backend/workflows/middleware.py b/backend/workflows/middleware.py index f378f73..46563ad 100644 --- a/backend/workflows/middleware.py +++ b/backend/workflows/middleware.py @@ -5,17 +5,37 @@ from django.contrib import messages from django.contrib.auth import logout from django.contrib.messages.api import MessageFailure from django.core.cache import cache -from django.http import HttpResponse +from django.core.exceptions import PermissionDenied, SuspiciousOperation +from django.http import Http404, HttpResponse from django.shortcuts import redirect, render from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext as _ from .branding import is_trial_expired, is_trial_mode_enabled +from .error_views import bad_request, not_found, permission_denied, server_error from .logging_utils import clear_request_id, set_request_id from .roles import ROLE_PLATFORM_OWNER, get_user_role_key +class FriendlyExceptionMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if not settings.FORCE_BRANDED_ERROR_PAGES: + return self.get_response(request) + + try: + return self.get_response(request) + except Http404 as exc: + return not_found(request, exc) + except PermissionDenied as exc: + return permission_denied(request, exc) + except SuspiciousOperation as exc: + return bad_request(request, exc) + + class RequestIDMiddleware: HEADER_NAME = 'X-Request-ID' diff --git a/backend/workflows/templates/workflows/developer_handbook.html b/backend/workflows/templates/workflows/developer_handbook.html index 6ab00b6..4e0d8ad 100644 --- a/backend/workflows/templates/workflows/developer_handbook.html +++ b/backend/workflows/templates/workflows/developer_handbook.html @@ -185,9 +185,10 @@ docker compose exec -T web python manage.py check
DJANGO_DEBUG=1 in .env.test because the security checks correctly reject insecure cookie settings when DEBUG=0 and the deployment is still plain HTTP behind a local test topology. This is acceptable for the test box only. Production must run with HTTPS and DEBUG=0.
DJANGO_DEBUG=1, enable FORCE_BRANDED_ERROR_PAGES=1 in .env.test. Full branded 500 behavior still requires DEBUG=0, which remains the correct production-style setup.
+ {{ error_message }}
+