fix(security): Befunde aus dem Security-Audit beheben #7

Merged
bjoernpoettker merged 10 commits from fix/security-audit into main 2026-11-04 10:03:36 +00:00
4 changed files with 92 additions and 5 deletions
Showing only changes of commit c28475f71f - Show all commits

No files matched your search

+2
View File
@@ -23,6 +23,8 @@ services:
- OLLAMA_MODEL=${OLLAMA_MODEL:-llava}
- SCANNER_ARCHIVE_DIR=${SCANNER_ARCHIVE_DIR:-/data/scanner/_processed_archive}
- OIDC_ISSUER=${OIDC_ISSUER:-}
# Audience-Prüfung der Tokens – muss dieselbe Client-ID wie im Frontend sein
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-${VITE_OIDC_CLIENT_ID:-ogOeIlrcsf2spjuwyOc7CuAPrRkRSt3RXfYguoLn}}
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_SECURE=${SMTP_SECURE:-false}
@@ -0,0 +1,46 @@
import { generateKeyPairSync } from 'crypto';
import * as jwt from 'jsonwebtoken';
import { jwtPruefOptionen } from './jwt-optionen';
describe('jwtPruefOptionen', () => {
it('verlangt die eigene Client-ID als Audience, wenn sie gesetzt ist', () => {
expect(
jwtPruefOptionen('https://auth.example/application/o/pm/', 'client-pm'),
).toEqual({
issuer: 'https://auth.example/application/o/pm/',
audience: 'client-pm',
algorithms: ['RS256'],
jwksUri: 'https://auth.example/application/o/pm/jwks/',
});
});
it('lässt die Audience weg, wenn keine Client-ID konfiguriert ist', () => {
const o = jwtPruefOptionen('https://auth.example/application/o/pm', '');
expect(o.audience).toBeUndefined();
expect(o.jwksUri).toBe('https://auth.example/application/o/pm/jwks/');
});
// Prüft, dass die Optionen in jsonwebtoken (das passport-jwt intern nutzt)
// tatsächlich fremde Tokens desselben Issuers abweisen.
it('weist Tokens anderer Anwendungen desselben Authentik ab', () => {
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
});
const o = jwtPruefOptionen('https://auth.example/', 'client-pm');
const token = (aud: string) =>
jwt.sign({ sub: 'u1' }, privateKey, {
algorithm: 'RS256',
issuer: o.issuer,
audience: aud,
});
const pruefe = (t: string) =>
jwt.verify(t, publicKey, {
issuer: o.issuer,
audience: o.audience,
algorithms: o.algorithms,
});
expect(() => pruefe(token('client-pm'))).not.toThrow();
expect(() => pruefe(token('client-andere-app'))).toThrow(/audience/);
});
});
@@ -0,0 +1,27 @@
import type { Algorithm } from 'jsonwebtoken';
export interface JwtPruefOptionen {
issuer: string;
/** Client-ID dieser Anwendung; ohne sie wird `aud` nicht geprüft. */
audience?: string;
algorithms: Algorithm[];
jwksUri: string;
}
/**
* Prüfoptionen für Access-Tokens von Authentik. Ohne Audience-Prüfung würde
* jedes Token desselben Issuers akzeptiert – also auch eines, das Authentik
* für eine andere Anwendung mit demselben Provider ausgestellt hat.
*/
export function jwtPruefOptionen(
issuer: string,
clientId: string | undefined,
): JwtPruefOptionen {
const basis = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer;
return {
issuer,
...(clientId ? { audience: clientId } : {}),
algorithms: ['RS256'],
jwksUri: `${basis}/jwks/`,
};
}
+17 -5
View File
@@ -1,9 +1,10 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { passportJwtSecret } from 'jwks-rsa';
import { mapGroupsToPermissions } from './permissions.enum';
import { jwtPruefOptionen } from './jwt-optionen';
import { UserIdentitySyncService } from '../user-settings/user-identity-sync.service';
@Injectable()
@@ -12,20 +13,31 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
configService: ConfigService,
private readonly identitySync: UserIdentitySyncService,
) {
const issuer = configService.get<string>('OIDC_ISSUER', '');
const optionen = jwtPruefOptionen(
configService.get<string>('OIDC_ISSUER', ''),
configService.get<string>('OIDC_CLIENT_ID', ''),
);
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
issuer,
algorithms: ['RS256'],
issuer: optionen.issuer,
audience: optionen.audience,
algorithms: optionen.algorithms,
secretOrKeyProvider: passportJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: `${issuer.endsWith('/') ? issuer.slice(0, -1) : issuer}/jwks/`,
jwksUri: optionen.jwksUri,
}),
});
if (!optionen.audience) {
new Logger(JwtStrategy.name).warn(
'OIDC_CLIENT_ID ist nicht gesetzt – die Audience von Tokens wird NICHT geprüft. ' +
'Tokens anderer Anwendungen desselben Authentik würden akzeptiert.',
);
}
}
validate(payload: any): {