fix: SSO-MFA-Umgehung, Datei-Magic-Bytes und oeffentliches Swagger geschlossen

Weitere vom Sicherheitsaudit gemeldete Luecken behoben, die bisher als
niedrige Prioritaet offen gelassen wurden:

- SSO-Login (Entra ID) prüft jetzt lokale MFA genauso wie der
  Passwort-Login: ist mfaEnabled gesetzt, liefert /auth/sso/complete
  denselben mfaRequired-Codepfad wie /auth/login, bevor Tokens
  ausgestellt werden. Frontend (Login-Seite, AuthContext) zeigt in dem
  Fall den bestehenden Code-Eingabe-Schritt statt die Anmeldung
  stillschweigend abzuschliessen.
- Avatar- und Dokument-Uploads pruefen jetzt die tatsaechlichen
  Datei-Bytes (Magic Numbers fuer PNG/JPEG/WEBP/PDF) statt nur dem
  client-angegebenen MIME-Typ/data:-URL-Praefix zu vertrauen.
- Swagger-UI (/api/v1/docs) ist jetzt standardmaessig deaktiviert
  (ENABLE_SWAGGER=true erforderlich), da sie zuvor das komplette
  API-Schema ungeschuetzt oeffentlich preisgab.

npm-audit-Warnungen (deepmerge-ts/mysql2 unter prisma) bewusst nicht
gefixt: kein stabiles Prisma-Release loest sie bislang auf, nur eine
Release-Candidate-Hauptversion - das Risiko eines Downgrades/RC-Upgrades
der Datenbankschicht uebersteigt den Nutzen fuer eine Kette, die zur
Laufzeit ohnehin nicht erreichbar ist (nur Prisma-CLI, nur MySQL-Treiber,
dieses Projekt nutzt Postgres).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-02 09:09:58 +02:00
1 parent 1b16b49913
commit b7ff92abd3
16 files changed
+225 -28

No files matched your search

@@ -111,11 +111,25 @@ describe("LoginPage", () => {
it("completes SSO login and redirects to the dashboard when returning with ssoPending", async () => {
searchParamsMock.mockReturnValue(new URLSearchParams("ssoPending=1"));
completeSsoMock.mockResolvedValueOnce(undefined);
completeSsoMock.mockResolvedValueOnce({ mfaRequired: false });
render(<LoginPage />);
expect(await screen.findByText(/microsoft-anmeldung wird abgeschlossen/i)).toBeInTheDocument();
await vi.waitFor(() => expect(replaceMock).toHaveBeenCalledWith("/dashboard"));
expect(completeSsoMock).toHaveBeenCalled();
expect(replaceMock).toHaveBeenCalledWith("/dashboard");
});
it("shows the MFA code step when SSO completion still requires local MFA", async () => {
searchParamsMock.mockReturnValue(new URLSearchParams("ssoPending=1"));
completeSsoMock.mockResolvedValueOnce({ mfaRequired: true, mfaToken: "sso-mfa-token" });
verifyMfaMock.mockResolvedValueOnce(undefined);
render(<LoginPage />);
const codeInput = await screen.findByLabelText("Code");
expect(replaceMock).toHaveBeenCalledWith("/login");
await userEvent.type(codeInput, "123456");
await userEvent.click(screen.getByRole("button", { name: /bestätigen/i }));
expect(verifyMfaMock).toHaveBeenCalledWith("sso-mfa-token", "123456");
expect(pushMock).toHaveBeenCalledWith("/dashboard");
});
});
+15 -2
View File
@@ -51,7 +51,11 @@ function LoginPageContent() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [ssoEnabled, setSsoEnabled] = useState(false);
const [ssoLoginHref, setSsoLoginHref] = useState<string | null>(null);
const isCompletingSso = ssoPending;
// Its own state rather than deriving straight from the ssoPending query
// param: once completeSso() resolves with an MFA challenge, the MFA form
// must show immediately (same render pass as setMfaToken), without
// waiting on a router.replace() round trip to first clear the param.
const [isCompletingSso, setIsCompletingSso] = useState(ssoPending);
useEffect(() => {
ssoStatusRequest().then(setSsoEnabled);
@@ -66,8 +70,17 @@ function LoginPageContent() {
useEffect(() => {
if (ssoPending) {
completeSso()
.then(() => router.replace("/dashboard"))
.then((outcome) => {
setIsCompletingSso(false);
if (outcome.mfaRequired) {
setMfaToken(outcome.mfaToken);
router.replace("/login");
} else {
router.replace("/dashboard");
}
})
.catch(() => {
setIsCompletingSso(false);
setError("SSO-Anmeldung fehlgeschlagen, bitte erneut versuchen.");
router.replace("/login");
});