diff --git a/CHANGELOG.md b/CHANGELOG.md index dc2c95c..e931452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,47 @@ Changelog ist die Upgrade-Anleitung für die Tools. ## [Unreleased] +### basicbar-lti (→ wird `lti/v0.1.4`) + +**Sicherheit: Reflected XSS im LTI-Login behoben.** Die 400er-Antworten von +`lti_login` spiegelten `iss`/`client_id` aus GET-Parametern in eine +HTML-Antwort (Django-Default-Content-Type) — eine präparierte URL konnte so +Skript auf der Tool-Origin ausführen. Alle Fehlerantworten des Endpunkts sind +jetzt `text/plain`. Migration: Version heben, sonst nichts. + +### basicbar-auth (→ wird `auth/v0.1.1`) + +**Sicherheit: Back-Channel-Logout-Tokens werden auf Frische geprüft.** Bisher +prüfte der Endpunkt nur Signatur, Nonce-Verbot und iss/aud/events/sub — ein +einmal abgefangenes Token ließ sich unbegrenzt wiederholen (erzwungener +Logout als DoS). Jetzt ist `iat` Pflicht und darf höchstens +`OIDC_BACKCHANNEL_MAX_AGE` Sekunden alt sein (neues Setting, Default 300); +`exp` wird respektiert. Migration: Version heben; bei stark abweichenden +Uhren zwischen IdP und Tool ggf. `OIDC_BACKCHANNEL_MAX_AGE` erhöhen. + +### basicbar-integrations (→ wird `integrations/v0.2.2`) + +- `ai.chat_json` und `translation_service.translate` fangen jetzt alle + Transportfehler (`ConnectionResetError`, `IncompleteRead`, …) als + `AIError`/`TranslationError`, statt sie als 500 durchzulassen. +- HTTP-Fehler tragen den Provider-Body in der Meldung (z. B. + „HTTP 400: en is not supported“) statt nur „Bad Request“; + LibreTranslate-4xx heißen nicht mehr fälschlich „unavailable“. +- Nicht-Objekt-JSON vom Übersetzungsdienst ist ein `TranslationError`. +- Migration: Version heben; wer `str(exc)` an Nutzer durchreicht, zeigt jetzt + aussagekräftigere Texte. + +### Template + +- `REST_FRAMEWORK.DEFAULT_AUTHENTICATION_CLASSES` nur noch + `SessionAuthentication` — DRFs Default aktiviert `BasicAuthentication`, die + mit dem Break-glass-Superuser (ModelBackend) jeden Endpunkt für + Passwort-Raten ohne Rate-Limit und am CSRF vorbei öffnet. **Empfehlung für + Bestandstools** (abstimmbar, ausleihbar; erkennbar hat es bereits): dieselbe + Zeile in `config/settings.py` übernehmen. +- CLAUDE.md: Der ui-Release entsteht als Draft und muss manuell veröffentlicht + werden, sonst ist der Tarball nicht abrufbar. + ### @basicbar/ui (→ wird `ui/v0.6.0`) **Geteilte Einstellungs-Bausteine** (ausleihbar#35): `LanguageOptions`, @@ -152,6 +193,14 @@ Migration: keine — rein additiv. Neue UI-Strings `"Show all fields in one language"` und `"Show all fields in {{language}}"` (Tools ergänzen ihre Übersetzungen). +### basicbar-lti (`lti/v0.1.3`, 2026-08-04 — Eintrag nachgetragen) + +- `lti_login` antwortet bei fehlenden Parametern (`iss`, `login_hint`, + `target_link_uri`) und nicht auflösbarer Plattform-Registrierung + (Issuer/Client-ID-Mismatch, auch Trailing-Slash) mit erklärendem `400` + statt opakem `500`; pylti1p3-Exceptions werden abgefangen. Migration: + Version heben, sonst nichts. + ### basicbar-integrations (→ wird `integrations/v0.2.0`) und @basicbar/ui (→ wird `ui/v0.3.0`) **Veraltete Übersetzungen markieren** (modulierbar#31, generisch für alle diff --git a/CLAUDE.md b/CLAUDE.md index 99a271f..28863eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,10 @@ pipx run copier copy . /tmp/probe --trust --defaults --vcs-ref HEAD --data proje **Tag `/vX.Y.Z` erst nach dem Merge** auf den main-Commit setzen (sonst zeigt er auf verwaiste Commits). Beim Tag `ui/v*` packt die CI `@basicbar/ui` per `npm pack` und hängt den Tarball als GitHub-Release-Asset - an; die Django-Pakete werden von den Tools direkt als GitHub-Archiv-Tarball + an — **als Draft**: Assets eines Drafts sind nicht öffentlich, die URL in + den Tool-`package.json`s funktioniert erst nach dem manuellen „Publish + release“ (`gh release edit ui/vX.Y.Z --draft=false`). Die Django-Pakete + werden von den Tools direkt als GitHub-Archiv-Tarball vom Tag installiert (`/archive/refs/tags/.tar.gz#subdirectory=…` — Repo ist deshalb öffentlich). Details/Gründe des Hosts: [ADR-0004](docs/ADR/0004-umzug-nach-github.md). diff --git a/packages/django/basicbar-auth/basicbar_auth/__init__.py b/packages/django/basicbar-auth/basicbar_auth/__init__.py index 2e33943..a999b74 100644 --- a/packages/django/basicbar-auth/basicbar_auth/__init__.py +++ b/packages/django/basicbar-auth/basicbar_auth/__init__.py @@ -1,4 +1,4 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright 2026 Universität Osnabrück (virtUOS) -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/packages/django/basicbar-auth/basicbar_auth/conf.py b/packages/django/basicbar-auth/basicbar_auth/conf.py index 1a4e74c..3840184 100644 --- a/packages/django/basicbar-auth/basicbar_auth/conf.py +++ b/packages/django/basicbar-auth/basicbar_auth/conf.py @@ -22,6 +22,9 @@ "OIDC_ADMIN_GROUP": "", # Issuer used to validate back-channel logout tokens (empty = skip check). "OIDC_OP_ISSUER": "", + # Back-channel logout tokens older than this (seconds, measured from + # ``iat``) are rejected as replays; also absorbs clock skew. + "OIDC_BACKCHANNEL_MAX_AGE": 300, # Opt-in: when the IdP re-issued its subjects (re-imported dev realm, # realm/IdP migration), fall back to a username match instead of crashing # into the unique-username constraint. Only enable when the IdP never diff --git a/packages/django/basicbar-auth/basicbar_auth/oidc.py b/packages/django/basicbar-auth/basicbar_auth/oidc.py index 9abed7a..9572f7f 100644 --- a/packages/django/basicbar-auth/basicbar_auth/oidc.py +++ b/packages/django/basicbar-auth/basicbar_auth/oidc.py @@ -10,6 +10,7 @@ deployment opts in; see the README's operator notes. """ import logging +import time from urllib.parse import urlencode from django.conf import settings @@ -31,6 +32,14 @@ # Back-Channel Logout 1.0, §2.4). BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout" +# Tolerance when comparing a token's ``exp`` against our clock. +_CLOCK_SKEW_SECONDS = 30 + + +def _is_timestamp(value) -> bool: + """A JWT NumericDate: int or float, but not bool (which is an int).""" + return isinstance(value, (int, float)) and not isinstance(value, bool) + def claims_in_admin_group(claims) -> bool: """Whether the given OIDC ``claims`` place the user in the configured admin @@ -242,6 +251,20 @@ def backchannel_logout(request): # We key local sessions on the subject; a sid-only token can't be acted on. return HttpResponseBadRequest("missing sub") + # Freshness (§2.6): the token must carry ``iat`` and be recent — mozilla's + # verify_token checks only signature and nonce, so without this a captured + # token could be replayed indefinitely to force the user out again. + now = time.time() + max_age = conf.get("OIDC_BACKCHANNEL_MAX_AGE") + iat = payload.get("iat") + if not _is_timestamp(iat): + return HttpResponseBadRequest("missing iat") + if abs(now - iat) > max_age: + return HttpResponseBadRequest("logout_token too old") + exp = payload.get("exp") + if _is_timestamp(exp) and exp < now - _CLOCK_SKEW_SECONDS: + return HttpResponseBadRequest("logout_token expired") + deleted = _delete_sessions_for_subject(subject) logger.info("Back-channel logout for sub=%s dropped %d session(s)", subject, deleted) # Spec: 200 with no caching on success. diff --git a/packages/django/basicbar-auth/basicbar_auth/tests/test_backchannel.py b/packages/django/basicbar-auth/basicbar_auth/tests/test_backchannel.py index 6e3b71a..a380f8b 100644 --- a/packages/django/basicbar-auth/basicbar_auth/tests/test_backchannel.py +++ b/packages/django/basicbar-auth/basicbar_auth/tests/test_backchannel.py @@ -2,6 +2,7 @@ # Copyright 2026 Universität Osnabrück (virtUOS) """OIDC Back-Channel Logout (ported from ausleihbar).""" +import time from unittest.mock import patch from django.contrib.auth import get_user_model @@ -32,9 +33,41 @@ def _valid_payload(self, sub="sub-123"): "iss": "https://idp.test/realms/x", "aud": "test-client", "sub": sub, + "iat": int(time.time()), "events": {BACKCHANNEL_LOGOUT_EVENT: {}}, } + @patch("basicbar_auth.oidc.OIDCBackend.verify_token") + def test_token_without_iat_is_rejected(self, mock_verify): + payload = self._valid_payload() + del payload["iat"] + mock_verify.return_value = payload + self.assertEqual(self.client.post(self.url, {"logout_token": "tok"}).status_code, 400) + + @patch("basicbar_auth.oidc.OIDCBackend.verify_token") + def test_stale_token_is_rejected_as_replay(self, mock_verify): + key = self._login(self.user) + payload = self._valid_payload() + payload["iat"] = int(time.time()) - 3600 + mock_verify.return_value = payload + self.assertEqual(self.client.post(self.url, {"logout_token": "tok"}).status_code, 400) + self.assertTrue(Session.objects.filter(session_key=key).exists()) + + @patch("basicbar_auth.oidc.OIDCBackend.verify_token") + def test_expired_token_is_rejected(self, mock_verify): + payload = self._valid_payload() + payload["exp"] = int(time.time()) - 120 + mock_verify.return_value = payload + self.assertEqual(self.client.post(self.url, {"logout_token": "tok"}).status_code, 400) + + @patch("basicbar_auth.oidc.OIDCBackend.verify_token") + def test_max_age_is_configurable(self, mock_verify): + payload = self._valid_payload() + payload["iat"] = int(time.time()) - 600 + mock_verify.return_value = payload + with override_settings(OIDC_BACKCHANNEL_MAX_AGE=900): + self.assertEqual(self.client.post(self.url, {"logout_token": "tok"}).status_code, 200) + @patch("basicbar_auth.oidc.OIDCBackend.verify_token") def test_valid_token_deletes_only_that_users_sessions(self, mock_verify): mine = self._login(self.user) diff --git a/packages/django/basicbar-auth/pyproject.toml b/packages/django/basicbar-auth/pyproject.toml index 8202f9b..2a73ae4 100644 --- a/packages/django/basicbar-auth/pyproject.toml +++ b/packages/django/basicbar-auth/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "basicbar-auth" -version = "0.1.0" +version = "0.1.1" description = "OIDC-Fundament der virtUOS -bar-Tools: Backend, Silent Login, Back-Channel-Logout, Discovery, AbstractBasicUser, optionale Account-Limits" readme = "README.md" requires-python = ">=3.12" diff --git a/packages/django/basicbar-integrations/basicbar_integrations/__init__.py b/packages/django/basicbar-integrations/basicbar_integrations/__init__.py index ab95dfd..7b5d754 100644 --- a/packages/django/basicbar-integrations/basicbar_integrations/__init__.py +++ b/packages/django/basicbar-integrations/basicbar_integrations/__init__.py @@ -1,4 +1,4 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright 2026 Universität Osnabrück (virtUOS) -__version__ = "0.2.0" +__version__ = "0.2.2" diff --git a/packages/django/basicbar-integrations/basicbar_integrations/_http.py b/packages/django/basicbar-integrations/basicbar_integrations/_http.py new file mode 100644 index 0000000..61cae07 --- /dev/null +++ b/packages/django/basicbar-integrations/basicbar_integrations/_http.py @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Universität Osnabrück (virtUOS) + +"""Shared bits for the stdlib-``urllib`` provider clients.""" + +from http.client import HTTPException +from urllib import error + +# Everything a ``urlopen`` + ``read`` + ``json.loads`` round trip can raise. +# ``URLError``/``HTTPError``/``TimeoutError``/``ConnectionResetError`` are all +# ``OSError`` subclasses; ``IncompleteRead``/``RemoteDisconnected`` are +# ``HTTPException``; the rest covers a malformed or unexpectedly shaped body. +TRANSPORT_ERRORS = (OSError, HTTPException, ValueError, KeyError, IndexError, TypeError) + +_MAX_DETAIL = 300 + + +def http_error_detail(exc: error.HTTPError) -> str: + """The first bytes of an HTTP error body — providers put the actual cause + there (``{"error": "en is not supported"}``), ``str(exc)`` only says + ``HTTP Error 400: Bad Request``.""" + try: + body = exc.read(_MAX_DETAIL + 1) + except Exception: # noqa: BLE001 — a body that can't be read adds nothing + return "" + text = body.decode("utf-8", errors="replace").strip() + if len(text) > _MAX_DETAIL: + text = text[:_MAX_DETAIL] + "…" + return text diff --git a/packages/django/basicbar-integrations/basicbar_integrations/ai.py b/packages/django/basicbar-integrations/basicbar_integrations/ai.py index c89771b..679faa2 100644 --- a/packages/django/basicbar-integrations/basicbar_integrations/ai.py +++ b/packages/django/basicbar-integrations/basicbar_integrations/ai.py @@ -14,6 +14,7 @@ from urllib import error, request from . import conf +from ._http import TRANSPORT_ERRORS, http_error_detail class AIError(Exception): @@ -72,5 +73,8 @@ def chat_json(system: str, user: str, *, max_tokens: int | None = None) -> Any: if not content: raise AIError("empty response from model") return json.loads(content) - except (error.URLError, TimeoutError, ValueError, KeyError, IndexError, TypeError) as exc: + except error.HTTPError as exc: + detail = http_error_detail(exc) + raise AIError(f"AI request failed: {exc}" + (f" — {detail}" if detail else "")) from exc + except TRANSPORT_ERRORS as exc: raise AIError(f"AI request failed: {exc}") from exc diff --git a/packages/django/basicbar-integrations/basicbar_integrations/tests/test_ai.py b/packages/django/basicbar-integrations/basicbar_integrations/tests/test_ai.py index cd7eda4..d66c16f 100644 --- a/packages/django/basicbar-integrations/basicbar_integrations/tests/test_ai.py +++ b/packages/django/basicbar-integrations/basicbar_integrations/tests/test_ai.py @@ -105,3 +105,50 @@ def test_disables_thinking_and_uses_configured_max_tokens(self): def test_no_thinking_flag_when_disabled_off(self): body = self._sent_payload() self.assertNotIn("chat_template_kwargs", body) + + +class AiTransportErrorTests(SimpleTestCase): + """Every failure mode of the HTTP round trip must surface as AIError, and + an HTTP error must carry the provider's explanation.""" + + SETTINGS = dict( + AI_PROVIDER="litellm", AI_BASE_URL="https://x/v1", + AI_API_KEY="k", AI_MODEL="qwen-3.5", AI_TIMEOUT=5, + ) + + def test_connection_reset_is_an_aierror_not_a_500(self): + with override_settings(**self.SETTINGS), patch( + "basicbar_integrations.ai.request.urlopen", + side_effect=ConnectionResetError("peer closed"), + ): + with self.assertRaises(ai.AIError): + ai.chat_json("s", "u") + + def test_incomplete_read_is_an_aierror(self): + from http.client import IncompleteRead + + fake = MagicMock() + fake.read.side_effect = IncompleteRead(b"partial") + cm = MagicMock() + cm.__enter__.return_value = fake + with override_settings(**self.SETTINGS), patch( + "basicbar_integrations.ai.request.urlopen", return_value=cm + ): + with self.assertRaises(ai.AIError): + ai.chat_json("s", "u") + + def test_http_error_message_includes_provider_body(self): + from io import BytesIO + from urllib import error + + http_error = error.HTTPError( + "https://x/v1/chat/completions", 400, "Bad Request", {}, + BytesIO(b'{"error":{"message":"context length exceeded"}}'), + ) + with override_settings(**self.SETTINGS), patch( + "basicbar_integrations.ai.request.urlopen", side_effect=http_error + ): + with self.assertRaises(ai.AIError) as ctx: + ai.chat_json("s", "u") + self.assertIn("400", str(ctx.exception)) + self.assertIn("context length exceeded", str(ctx.exception)) diff --git a/packages/django/basicbar-integrations/basicbar_integrations/tests/test_translation_service.py b/packages/django/basicbar-integrations/basicbar_integrations/tests/test_translation_service.py index 0ca2c27..0ae0c68 100644 --- a/packages/django/basicbar-integrations/basicbar_integrations/tests/test_translation_service.py +++ b/packages/django/basicbar-integrations/basicbar_integrations/tests/test_translation_service.py @@ -101,3 +101,39 @@ def test_empty_reply_raises_translation_error(self): ): with self.assertRaises(translation_service.TranslationError): translation_service.translate("Hallo", "de", "en") + + +class TranslationTransportErrorTests(SimpleTestCase): + def test_connection_reset_is_a_translation_error(self): + with override_settings(**LT_ON), patch( + "basicbar_integrations.translation_service.request.urlopen", + side_effect=ConnectionResetError("peer closed"), + ): + with self.assertRaises(translation_service.TranslationError): + translation_service.translate("Hallo", "de", "en") + + def test_http_error_reports_status_and_provider_body(self): + from io import BytesIO + + http_error = error.HTTPError( + "http://libretranslate.local/translate", 400, "Bad Request", {}, + BytesIO(b'{"error":"en is not supported"}'), + ) + with override_settings(**LT_ON), patch( + "basicbar_integrations.translation_service.request.urlopen", + side_effect=http_error, + ): + with self.assertRaises(translation_service.TranslationError) as ctx: + translation_service.translate("Hallo", "de", "en") + message = str(ctx.exception) + self.assertIn("HTTP 400", message) + self.assertIn("en is not supported", message) + self.assertNotIn("unavailable", message) + + def test_non_object_json_is_a_translation_error(self): + with override_settings(**LT_ON), patch( + "basicbar_integrations.translation_service.request.urlopen", + return_value=_mock_response('["not", "an", "object"]'), + ): + with self.assertRaises(translation_service.TranslationError): + translation_service.translate("Hallo", "de", "en") diff --git a/packages/django/basicbar-integrations/basicbar_integrations/translation_service.py b/packages/django/basicbar-integrations/basicbar_integrations/translation_service.py index aca3828..80c6f71 100644 --- a/packages/django/basicbar-integrations/basicbar_integrations/translation_service.py +++ b/packages/django/basicbar-integrations/basicbar_integrations/translation_service.py @@ -14,6 +14,7 @@ from urllib import error, request from . import conf +from ._http import TRANSPORT_ERRORS, http_error_detail class TranslationError(Exception): @@ -64,8 +65,18 @@ def _libretranslate(text: str, source: str, target: str, *, html: bool = False) try: with request.urlopen(req, timeout=15) as response: data = json.loads(response.read().decode("utf-8")) - except (error.URLError, TimeoutError, ValueError) as exc: + except error.HTTPError as exc: + # A 4xx carries the actual cause in its body ("en is not supported"); + # that is a configuration problem, not an outage. + detail = http_error_detail(exc) + raise TranslationError( + f"Translation service returned HTTP {exc.code}" + + (f": {detail}" if detail else "") + ) from exc + except TRANSPORT_ERRORS as exc: raise TranslationError(f"Translation service unavailable: {exc}") from exc + if not isinstance(data, dict): + raise TranslationError("Translation service returned an unexpected response.") translated = data.get("translatedText") if not translated: raise TranslationError("Translation service returned no text.") diff --git a/packages/django/basicbar-integrations/pyproject.toml b/packages/django/basicbar-integrations/pyproject.toml index 501723b..ad60fbc 100644 --- a/packages/django/basicbar-integrations/pyproject.toml +++ b/packages/django/basicbar-integrations/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "basicbar-integrations" -version = "0.2.1" +version = "0.2.2" description = "Optionale Integrationen der virtUOS -bar-Tools: LibreTranslate, LiteLLM, Capabilities-Endpoint" readme = "README.md" requires-python = ">=3.12" diff --git a/packages/django/basicbar-lti/basicbar_lti/__init__.py b/packages/django/basicbar-lti/basicbar_lti/__init__.py index f512454..df8a046 100644 --- a/packages/django/basicbar-lti/basicbar_lti/__init__.py +++ b/packages/django/basicbar-lti/basicbar_lti/__init__.py @@ -1,4 +1,4 @@ # SPDX-License-Identifier: Apache-2.0 # Copyright 2026 Universität Osnabrück (virtUOS) -__version__ = "0.1.2" +__version__ = "0.1.4" diff --git a/packages/django/basicbar-lti/basicbar_lti/tests/test_lti.py b/packages/django/basicbar-lti/basicbar_lti/tests/test_lti.py index 27330ec..0b994fc 100644 --- a/packages/django/basicbar-lti/basicbar_lti/tests/test_lti.py +++ b/packages/django/basicbar-lti/basicbar_lti/tests/test_lti.py @@ -162,6 +162,31 @@ def test_wrong_client_id_is_400(self): ) self.assertEqual(response.status_code, 400) + def test_error_responses_are_plain_text_not_html(self): + # The login endpoint echoes platform parameters and is reachable via + # GET: an HTML body would make it a reflected-XSS vector. + payload = "" + response = self.client.get( + "/lti/login/", + { + "iss": payload, + "client_id": CLIENT_ID, + "login_hint": "user-1", + "target_link_uri": TOOL_LAUNCH, + }, + ) + self.assertEqual(response.status_code, 400) + self.assertTrue(response["Content-Type"].startswith("text/plain")) + for response in ( + self.client.post("/lti/login/", {"iss": ISSUER}), + self.client.post( + "/lti/login/", + {"iss": ISSUER, "client_id": CLIENT_ID, "target_link_uri": TOOL_LAUNCH}, + ), + ): + self.assertEqual(response.status_code, 400) + self.assertTrue(response["Content-Type"].startswith("text/plain")) + class HandshakeTests(LtiTestCase): def test_instructor_launch_provisions_user(self): diff --git a/packages/django/basicbar-lti/basicbar_lti/views.py b/packages/django/basicbar-lti/basicbar_lti/views.py index e8c12c2..1477b36 100644 --- a/packages/django/basicbar-lti/basicbar_lti/views.py +++ b/packages/django/basicbar-lti/basicbar_lti/views.py @@ -61,15 +61,15 @@ def lti_login(request): """ target = _login_param(request, "target_link_uri") if not target: - return HttpResponse("Missing target_link_uri", status=400) + return _bad_request("Missing target_link_uri") iss = _login_param(request, "iss") login_hint = _login_param(request, "login_hint") client_id = _login_param(request, "client_id") if not iss: - return HttpResponse("Missing iss", status=400) + return _bad_request("Missing iss") if not login_hint: - return HttpResponse("Missing login_hint", status=400) + return _bad_request("Missing login_hint") tool_conf = build_tool_conf() if _find_registration(tool_conf, iss, client_id) is None: @@ -77,12 +77,11 @@ def lti_login(request): "LTI login: no platform registered for issuer=%r client_id=%r", iss, client_id, ) - return HttpResponse( + return _bad_request( f"No LTI platform registered for issuer={iss!r} " f"client_id={client_id!r}. Check the platform registration " f"(issuer and client_id must match exactly, including any " - f"trailing slash).", - status=400, + f"trailing slash)." ) try: @@ -95,7 +94,14 @@ def lti_login(request): "LTI login init failed (issuer=%r client_id=%r): %s", iss, client_id, exc, ) - return HttpResponse(f"LTI login failed: {exc}", status=400) + return _bad_request(f"LTI login failed: {exc}") + + +def _bad_request(message): + """A 400 that echoes request parameters for the operator — as plain text, + never HTML: the endpoint is reachable via GET, so an HTML body would be a + reflected-XSS vector on the tool's origin.""" + return HttpResponse(message, status=400, content_type="text/plain; charset=utf-8") def lti_jwks(request): diff --git a/packages/django/basicbar-lti/pyproject.toml b/packages/django/basicbar-lti/pyproject.toml index 2ff8ea9..7251a22 100644 --- a/packages/django/basicbar-lti/pyproject.toml +++ b/packages/django/basicbar-lti/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "basicbar-lti" -version = "0.1.3" +version = "0.1.4" description = "LTI-1.3-Fundament der virtUOS -bar-Tools: Plattform-Registrierung, Tool-Key, OIDC-Initiation, JWKS, Nutzer-Provisionierung, Frame-Ancestors-Middleware" readme = "README.md" requires-python = ">=3.12" diff --git a/template/project/backend/config/settings.py.jinja b/template/project/backend/config/settings.py.jinja index e92a309..a688229 100644 --- a/template/project/backend/config/settings.py.jinja +++ b/template/project/backend/config/settings.py.jinja @@ -185,6 +185,12 @@ CSRF_TRUSTED_ORIGINS = _env_list( ) REST_FRAMEWORK = { + # Session auth only. DRF's default also enables BasicAuthentication, which + # would expose every endpoint to password guessing against the break-glass + # superuser (ModelBackend) — without rate limiting and bypassing CSRF. + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + ], "DEFAULT_PAGINATION_CLASS": "common.pagination.StandardPagination", "PAGE_SIZE": 25, }