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:
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user