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");
});
+5 -2
View File
@@ -93,7 +93,10 @@ export async function ssoLoginUrl(): Promise<string> {
// The one-time completion code lives only in an httpOnly cookie the callback
// set (never in the URL or a request body) - the browser sends it automatically.
export async function ssoCompleteRequest(): Promise<AccessTokenResponse> {
// Returns the same union as loginRequest: SSO establishes identity, not the
// second factor, so a user with local MFA enabled still has to complete the
// same challenge as a password login before tokens are issued.
export async function ssoCompleteRequest(): Promise<LoginResponse> {
const apiUrl = await getApiUrl();
const response = await fetch(`${apiUrl}/auth/sso/complete`, {
method: 'POST',
@@ -102,7 +105,7 @@ export async function ssoCompleteRequest(): Promise<AccessTokenResponse> {
if (!response.ok) {
throw new Error('SSO-Anmeldung fehlgeschlagen. Bitte erneut versuchen.');
}
return response.json() as Promise<AccessTokenResponse>;
return response.json() as Promise<LoginResponse>;
}
export async function logoutRequest(): Promise<void> {
@@ -30,7 +30,7 @@ interface AuthContextValue {
isLoading: boolean;
login: (email: string, password: string) => Promise<LoginOutcome>;
verifyMfa: (mfaToken: string, code: string) => Promise<void>;
completeSso: () => Promise<void>;
completeSso: () => Promise<LoginOutcome>;
logout: () => Promise<void>;
hasPermission: (permission: string) => boolean;
refreshUser: () => Promise<void>;
@@ -78,9 +78,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(await applyAccessToken(result.accessToken));
}, []);
const completeSso = useCallback(async () => {
const completeSso = useCallback(async (): Promise<LoginOutcome> => {
const result = await ssoCompleteRequest();
if (isMfaRequired(result)) {
return { mfaRequired: true, mfaToken: result.mfaToken };
}
setUser(await applyAccessToken(result.accessToken));
return { mfaRequired: false };
}, []);
const logout = useCallback(async () => {