fix(api): tighter public lot endpoint, SSO callback from config, mapped 4xx errors

- Public lot lookup: 30 requests/min per IP and only well-formed lot
  numbers (1-64 chars) reach the database.
- SSO callback URL is built from OIDC_REDIRECT_URI instead of the Host
  header; SSO cookies are cleared with the attributes they were set with.
- Logout is rate-limited like the other auth endpoints.
- Middleware errors with a 4xx status (body too large, malformed JSON)
  answer with that status instead of a 500; error log lines carry the
  path without query string.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 07:20:33 +02:00
1 parent eafe925d60
commit 0899576262
4 files changed
+49 -9

No files matched your search

@@ -82,6 +82,7 @@ export class AuthController {
} }
@Public() @Public()
@Throttle(SENSITIVE_ACTION_THROTTLE)
@Post('logout') @Post('logout')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
async logout( async logout(
@@ -82,7 +82,7 @@ export class SsoController {
const rawCookie = (req.cookies as Record<string, string> | undefined)?.[ const rawCookie = (req.cookies as Record<string, string> | undefined)?.[
PKCE_COOKIE 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); const pkce = this.parsePkceCookie(rawCookie);
if (!pkce) { if (!pkce) {
@@ -90,10 +90,10 @@ export class SsoController {
return; return;
} }
const callbackUrl = new URL( // Built from the configured redirect URI, not from the Host header a
req.originalUrl, // client sends; only the query (code, state, iss) comes from the request.
`${req.protocol}://${req.get('host')}`, 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( const result = await this.ssoService.handleCallback(
callbackUrl, callbackUrl,
pkce.codeVerifier, pkce.codeVerifier,
@@ -126,7 +126,10 @@ export class SsoController {
const ssoCode = (req.cookies as Record<string, string> | undefined)?.[ const ssoCode = (req.cookies as Record<string, string> | undefined)?.[
SSO_CODE_COOKIE 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) { if (!ssoCode) {
throw new UnauthorizedException( throw new UnauthorizedException(
'SSO-Anmeldung abgelaufen, bitte erneut versuchen', 'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
@@ -49,6 +49,30 @@ const FOREIGN_KEY_MESSAGE =
* status and message they deserve. Anything not listed here stays a 500 with * status and message they deserve. Anything not listed here stays a 500 with
* a generic body, so no internal detail leaks. * 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<number, string> = {
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( function prismaErrorResponse(
exception: unknown, exception: unknown,
): { status: number; message: string } | null { ): { status: number; message: string } | null {
@@ -105,7 +129,8 @@ export class AllExceptionsFilter implements ExceptionFilter {
const request = ctx.getRequest<Request>(); const request = ctx.getRequest<Request>();
const isHttpException = exception instanceof HttpException; const isHttpException = exception instanceof HttpException;
const prismaResponse = prismaErrorResponse(exception); const prismaResponse =
prismaErrorResponse(exception) ?? middlewareErrorResponse(exception);
const status = isHttpException const status = isHttpException
? exception.getStatus() ? exception.getStatus()
: (prismaResponse?.status ?? HttpStatus.INTERNAL_SERVER_ERROR); : (prismaResponse?.status ?? HttpStatus.INTERNAL_SERVER_ERROR);
@@ -117,7 +142,8 @@ export class AllExceptionsFilter implements ExceptionFilter {
}; };
const message = const message =
exception instanceof Error ? exception.message : 'Unknown error'; 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: // Compared against the literal 500, not HttpStatus.INTERNAL_SERVER_ERROR:
// eslint's no-unsafe-enum-comparison and TS's no-unnecessary-type-assertion // 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. // fight over any cast here, so a plain number sidesteps both cleanly.
@@ -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 { ApiTags } from '@nestjs/swagger';
import { Public } from '../auth/decorators/public.decorator'; import { Public } from '../auth/decorators/public.decorator';
import { PublicBatch, PublicService } from './public.service'; 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') @ApiTags('public')
@Public() @Public()
@Controller('public/batches') @Controller('public/batches')
export class PublicController { export class PublicController {
constructor(private readonly publicService: PublicService) {} 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') @Get(':lotNumber')
findByLot(@Param('lotNumber') lotNumber: string): Promise<PublicBatch> { findByLot(@Param('lotNumber') lotNumber: string): Promise<PublicBatch> {
if (!LOT_NUMBER_PATTERN.test(lotNumber)) {
throw new NotFoundException();
}
return this.publicService.findBatchByLot(lotNumber); return this.publicService.findBatchByLot(lotNumber);
} }
} }