diff --git a/ApiCore-Backend/src/auth/auth.controller.ts b/ApiCore-Backend/src/auth/auth.controller.ts index e6f3258..df062a4 100644 --- a/ApiCore-Backend/src/auth/auth.controller.ts +++ b/ApiCore-Backend/src/auth/auth.controller.ts @@ -82,6 +82,7 @@ export class AuthController { } @Public() + @Throttle(SENSITIVE_ACTION_THROTTLE) @Post('logout') @HttpCode(HttpStatus.NO_CONTENT) async logout( diff --git a/ApiCore-Backend/src/auth/sso/sso.controller.ts b/ApiCore-Backend/src/auth/sso/sso.controller.ts index b879e0e..37d3664 100644 --- a/ApiCore-Backend/src/auth/sso/sso.controller.ts +++ b/ApiCore-Backend/src/auth/sso/sso.controller.ts @@ -82,7 +82,7 @@ export class SsoController { const rawCookie = (req.cookies as Record | undefined)?.[ PKCE_COOKIE ]; - res.clearCookie(PKCE_COOKIE, { path: PKCE_COOKIE_PATH }); + res.clearCookie(PKCE_COOKIE, this.cookieOptions(PKCE_COOKIE_PATH, 0)); const pkce = this.parsePkceCookie(rawCookie); if (!pkce) { @@ -90,10 +90,10 @@ export class SsoController { return; } - const callbackUrl = new URL( - req.originalUrl, - `${req.protocol}://${req.get('host')}`, - ); + // Built from the configured redirect URI, not from the Host header a + // client sends; only the query (code, state, iss) comes from the request. + const callbackUrl = new URL(process.env.OIDC_REDIRECT_URI as string); + callbackUrl.search = new URL(req.originalUrl, 'http://placeholder').search; const result = await this.ssoService.handleCallback( callbackUrl, pkce.codeVerifier, @@ -126,7 +126,10 @@ export class SsoController { const ssoCode = (req.cookies as Record | undefined)?.[ SSO_CODE_COOKIE ]; - res.clearCookie(SSO_CODE_COOKIE, { path: SSO_CODE_COOKIE_PATH }); + res.clearCookie( + SSO_CODE_COOKIE, + this.cookieOptions(SSO_CODE_COOKIE_PATH, 0), + ); if (!ssoCode) { throw new UnauthorizedException( 'SSO-Anmeldung abgelaufen, bitte erneut versuchen', diff --git a/ApiCore-Backend/src/common/filters/all-exceptions.filter.ts b/ApiCore-Backend/src/common/filters/all-exceptions.filter.ts index db9dc4f..ec98389 100644 --- a/ApiCore-Backend/src/common/filters/all-exceptions.filter.ts +++ b/ApiCore-Backend/src/common/filters/all-exceptions.filter.ts @@ -49,6 +49,30 @@ const FOREIGN_KEY_MESSAGE = * status and message they deserve. Anything not listed here stays a 500 with * a generic body, so no internal detail leaks. */ +/** + * Errors raised by Express middleware before Nest sees the request (body + * too large, malformed JSON, unsupported encoding) carry a 4xx `status`. + * They are client mistakes, not outages: answer with that status. + */ +function middlewareErrorResponse( + exception: unknown, +): { status: number; message: string } | null { + if (typeof exception !== 'object' || exception === null) { + return null; + } + const candidate = exception as { status?: unknown; statusCode?: unknown }; + const status = candidate.status ?? candidate.statusCode; + if (typeof status !== 'number' || status < 400 || status >= 500) { + return null; + } + const messages: Record = { + 400: 'Ungültige Anfrage', + 413: 'Anfrage ist zu groß', + 415: 'Format der Anfrage wird nicht unterstützt', + }; + return { status, message: messages[status] ?? 'Ungültige Anfrage' }; +} + function prismaErrorResponse( exception: unknown, ): { status: number; message: string } | null { @@ -105,7 +129,8 @@ export class AllExceptionsFilter implements ExceptionFilter { const request = ctx.getRequest(); const isHttpException = exception instanceof HttpException; - const prismaResponse = prismaErrorResponse(exception); + const prismaResponse = + prismaErrorResponse(exception) ?? middlewareErrorResponse(exception); const status = isHttpException ? exception.getStatus() : (prismaResponse?.status ?? HttpStatus.INTERNAL_SERVER_ERROR); @@ -117,7 +142,8 @@ export class AllExceptionsFilter implements ExceptionFilter { }; const message = exception instanceof Error ? exception.message : 'Unknown error'; - const context = `${request.method} ${request.originalUrl} -> ${status}`; + // Path only - query strings can carry codes and personal data. + const context = `${request.method} ${request.baseUrl}${request.path} -> ${status}`; // Compared against the literal 500, not HttpStatus.INTERNAL_SERVER_ERROR: // eslint's no-unsafe-enum-comparison and TS's no-unnecessary-type-assertion // fight over any cast here, so a plain number sidesteps both cleanly. diff --git a/ApiCore-Backend/src/public/public.controller.ts b/ApiCore-Backend/src/public/public.controller.ts index 81680b5..148cb0b 100644 --- a/ApiCore-Backend/src/public/public.controller.ts +++ b/ApiCore-Backend/src/public/public.controller.ts @@ -1,16 +1,26 @@ -import { Controller, Get, Param } from '@nestjs/common'; +import { Controller, Get, NotFoundException, Param } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; import { ApiTags } from '@nestjs/swagger'; import { Public } from '../auth/decorators/public.decorator'; import { PublicBatch, PublicService } from './public.service'; +// What a printed lot number can look like - anything else is not a lot. +const LOT_NUMBER_PATTERN = /^[A-Za-z0-9._\/-]{1,64}$/; + @ApiTags('public') @Public() @Controller('public/batches') export class PublicController { constructor(private readonly publicService: PublicService) {} + // Anonymous and enumerable by design (lot numbers are printed on jars), + // so it gets a tighter per-IP limit than logged-in traffic. + @Throttle({ default: { limit: 30, ttl: 60000 } }) @Get(':lotNumber') findByLot(@Param('lotNumber') lotNumber: string): Promise { + if (!LOT_NUMBER_PATTERN.test(lotNumber)) { + throw new NotFoundException(); + } return this.publicService.findBatchByLot(lotNumber); } }