Compare commits

..

2 Commits

Author SHA1 Message Date
Matthias Hochmeister
3c9b7d3446 apply security audit 2026-03-11 13:51:01 +01:00
Matthias Hochmeister
93a87a7ae9 apply security audit 2026-03-11 13:18:10 +01:00
35 changed files with 519 additions and 379 deletions

View File

@@ -21,6 +21,7 @@
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.2.1", "express-rate-limit": "^8.2.1",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"jose": "^6.0.11",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"pg": "^8.18.0", "pg": "^8.18.0",
"winston": "^3.19.0", "winston": "^3.19.0",

View File

@@ -55,7 +55,7 @@ const environment: EnvironmentConfig = {
password: process.env.DB_PASSWORD || 'dev_password', password: process.env.DB_PASSWORD || 'dev_password',
}, },
jwt: { jwt: {
secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production', secret: process.env.JWT_SECRET || '',
expiresIn: process.env.JWT_EXPIRES_IN || '24h', expiresIn: process.env.JWT_EXPIRES_IN || '24h',
}, },
cors: { cors: {
@@ -83,4 +83,31 @@ const environment: EnvironmentConfig = {
}, },
}; };
function validateEnvironment(env: EnvironmentConfig): void {
const secret = env.jwt.secret;
if (!secret) {
throw new Error(
'FATAL: JWT_SECRET is not set. ' +
'Set a strong, random secret of at least 32 characters before starting the server.'
);
}
if (secret === 'your-secret-key-change-in-production') {
throw new Error(
'FATAL: JWT_SECRET is still set to the known weak default value. ' +
'Replace it with a strong, random secret of at least 32 characters.'
);
}
if (secret.length < 32) {
throw new Error(
`FATAL: JWT_SECRET is too short (${secret.length} characters). ` +
'A minimum of 32 characters is required.'
);
}
}
validateEnvironment(environment);
export default environment; export default environment;

View File

@@ -1,11 +1,12 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { z } from 'zod';
import authentikService from '../services/authentik.service'; import authentikService from '../services/authentik.service';
import tokenService from '../services/token.service'; import tokenService from '../services/token.service';
import userService from '../services/user.service'; import userService from '../services/user.service';
import logger from '../utils/logger'; import logger from '../utils/logger';
import { AuthRequest } from '../types/auth.types';
import auditService, { AuditAction, AuditResourceType } from '../services/audit.service'; import auditService, { AuditAction, AuditResourceType } from '../services/audit.service';
import { extractIp, extractUserAgent } from '../middleware/audit.middleware'; import { extractIp, extractUserAgent } from '../middleware/audit.middleware';
import { getUserRole } from '../middleware/rbac.middleware';
/** /**
* Extract given_name and family_name from Authentik userinfo. * Extract given_name and family_name from Authentik userinfo.
@@ -60,10 +61,13 @@ class AuthController {
const userAgent = extractUserAgent(req); const userAgent = extractUserAgent(req);
try { try {
const { code } = req.body as AuthRequest; const callbackSchema = z.object({
code: z.string().min(1),
redirect_uri: z.string().url().optional(),
});
// Validate code const parseResult = callbackSchema.safeParse(req.body);
if (!code) { if (!parseResult.success) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
message: 'Authorization code is required', message: 'Authorization code is required',
@@ -71,6 +75,8 @@ class AuthController {
return; return;
} }
const { code } = parseResult.data;
logger.info('Processing OAuth callback', { hasCode: !!code }); logger.info('Processing OAuth callback', { hasCode: !!code });
// Step 1: Exchange code for tokens // Step 1: Exchange code for tokens
@@ -83,9 +89,9 @@ class AuthController {
// Step 3: Verify ID token if present // Step 3: Verify ID token if present
if (tokens.id_token) { if (tokens.id_token) {
try { try {
authentikService.verifyIdToken(tokens.id_token); await authentikService.verifyIdToken(tokens.id_token);
} catch (error) { } catch (error) {
logger.warn('ID token verification failed', { error }); logger.warn('ID token verification failed — continuing with userinfo', { error });
} }
} }
@@ -190,11 +196,13 @@ class AuthController {
} }
// Step 5: Generate internal JWT token // Step 5: Generate internal JWT token
const role = await getUserRole(user.id);
const accessToken = tokenService.generateToken({ const accessToken = tokenService.generateToken({
userId: user.id, userId: user.id,
email: user.email, email: user.email,
authentikSub: user.authentik_sub, authentikSub: user.authentik_sub,
groups, groups,
role,
}); });
// Generate refresh token // Generate refresh token
@@ -309,9 +317,12 @@ class AuthController {
*/ */
async handleRefresh(req: Request, res: Response): Promise<void> { async handleRefresh(req: Request, res: Response): Promise<void> {
try { try {
const { refreshToken } = req.body; const refreshSchema = z.object({
refreshToken: z.string().min(1),
});
if (!refreshToken) { const parseResult = refreshSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
message: 'Refresh token is required', message: 'Refresh token is required',
@@ -319,6 +330,8 @@ class AuthController {
return; return;
} }
const { refreshToken } = parseResult.data;
// Verify refresh token // Verify refresh token
let decoded; let decoded;
try { try {
@@ -358,10 +371,12 @@ class AuthController {
} }
// Generate new access token // Generate new access token
const role = await getUserRole(user.id);
const accessToken = tokenService.generateToken({ const accessToken = tokenService.generateToken({
userId: user.id, userId: user.id,
email: user.email, email: user.email,
authentikSub: user.authentik_sub, authentikSub: user.authentik_sub,
role,
}); });
logger.info('Token refreshed successfully', { logger.info('Token refreshed successfully', {

View File

@@ -28,6 +28,10 @@ class BookStackController {
res.status(400).json({ success: false, message: 'Suchbegriff fehlt' }); res.status(400).json({ success: false, message: 'Suchbegriff fehlt' });
return; return;
} }
if (query.trim().length > 500) {
res.status(400).json({ success: false, message: 'Suchanfrage zu lang' });
return;
}
try { try {
const results = await bookstackService.searchPages(query.trim()); const results = await bookstackService.searchPages(query.trim());
res.status(200).json({ success: true, data: results, configured: true }); res.status(200).json({ success: true, data: results, configured: true });

View File

@@ -3,36 +3,6 @@ import tokenService from '../services/token.service';
import userService from '../services/user.service'; import userService from '../services/user.service';
import logger from '../utils/logger'; import logger from '../utils/logger';
import { JwtPayload } from '../types/auth.types'; import { JwtPayload } from '../types/auth.types';
import { auditPermissionDenied } from './audit.middleware';
import { AuditResourceType } from '../services/audit.service';
// ---------------------------------------------------------------------------
// Application roles — extend as needed when Authentik group mapping is added
// ---------------------------------------------------------------------------
export type AppRole = 'admin' | 'member' | 'viewer';
export const Permission = {
ADMIN_ACCESS: 'admin:access',
MEMBER_WRITE: 'member:write',
MEMBER_READ: 'member:read',
INCIDENT_WRITE:'incident:write',
INCIDENT_READ: 'incident:read',
EXPORT: 'export',
} as const;
export type Permission = typeof Permission[keyof typeof Permission];
// Simple permission → required role mapping.
// Adjust once Authentik group sync is implemented.
const PERMISSION_ROLES: Record<Permission, AppRole[]> = {
'admin:access': ['admin'],
'member:write': ['admin', 'member'],
'member:read': ['admin', 'member', 'viewer'],
'incident:write': ['admin', 'member'],
'incident:read': ['admin', 'member', 'viewer'],
'export': ['admin'],
};
// Extend Express Request type to include user // Extend Express Request type to include user
declare global { declare global {
@@ -42,7 +12,7 @@ declare global {
id: string; // UUID id: string; // UUID
email: string; email: string;
authentikSub: string; authentikSub: string;
role?: AppRole; // populated when role is stored in DB / JWT role?: string; // populated when role is stored in DB / JWT
groups?: string[]; groups?: string[];
}; };
} }
@@ -122,6 +92,7 @@ export const authenticate = async (
email: decoded.email, email: decoded.email,
authentikSub: decoded.authentikSub, authentikSub: decoded.authentikSub,
groups: decoded.groups ?? [], groups: decoded.groups ?? [],
role: decoded.role,
}; };
logger.debug('User authenticated successfully', { logger.debug('User authenticated successfully', {
@@ -139,60 +110,6 @@ export const authenticate = async (
} }
}; };
// ---------------------------------------------------------------------------
// Role-based access control middleware
// ---------------------------------------------------------------------------
/**
* requirePermission — factory that returns Express middleware enforcing a
* specific permission. Must be placed after `authenticate` in the chain.
*
* Usage:
* router.get('/admin/audit-log', authenticate, requirePermission('admin:access'), handler);
*
* When access is denied, a PERMISSION_DENIED audit entry is written before
* the 403 response is sent.
*
* NOTE: Until Authentik group → role mapping is persisted to the users table
* or JWT, this middleware checks req.user.role. Temporary workaround:
* hard-code specific admin user IDs via the ADMIN_USER_IDS env variable, OR
* add a `role` column to the users table (recommended).
*/
export const requirePermission = (permission: Permission) => {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
if (!req.user) {
res.status(401).json({ success: false, message: 'Not authenticated' });
return;
}
const userRole: AppRole = req.user.role ?? 'viewer';
const allowedRoles = PERMISSION_ROLES[permission];
if (!allowedRoles.includes(userRole)) {
logger.warn('Permission denied', {
userId: req.user.id,
permission,
userRole,
path: req.path,
});
// Audit the denied access — fire-and-forget
auditPermissionDenied(req, AuditResourceType.SYSTEM, undefined, {
required_permission: permission,
user_role: userRole,
});
res.status(403).json({
success: false,
message: 'Insufficient permissions',
});
return;
}
next();
};
};
/** /**
* Optional authentication middleware * Optional authentication middleware
* Attaches user if token is valid, but doesn't require it * Attaches user if token is valid, but doesn't require it

View File

@@ -46,9 +46,7 @@ export const errorHandler = (
res.status(500).json({ res.status(500).json({
status: 'error', status: 'error',
message: process.env.NODE_ENV === 'production' message: 'An internal error occurred',
? 'Internal server error'
: err.message,
}); });
}; };

View File

@@ -48,6 +48,19 @@ const PERMISSION_ROLE_MIN: Record<string, AppRole> = {
'admin:access': 'admin', 'admin:access': 'admin',
'audit:read': 'admin', 'audit:read': 'admin',
'audit:export': 'admin', 'audit:export': 'admin',
'members:read': 'mitglied',
'members:write': 'kommandant',
'vehicles:write': 'kommandant',
'vehicles:status': 'gruppenfuehrer',
'vehicles:delete': 'admin',
'equipment:write': 'gruppenfuehrer',
'equipment:delete': 'admin',
'events:write': 'gruppenfuehrer',
'events:categories': 'gruppenfuehrer',
'atemschutz:write': 'gruppenfuehrer',
'atemschutz:delete': 'kommandant',
'bookings:write': 'gruppenfuehrer',
'bookings:delete': 'admin',
}; };
function hasPermission(role: AppRole, permission: string): boolean { function hasPermission(role: AppRole, permission: string): boolean {
@@ -103,7 +116,9 @@ export function requirePermission(permission: string) {
return; return;
} }
const role = await getUserRole(req.user.id); const role = (req.user as any).role
? (req.user as any).role as AppRole
: await getUserRole(req.user.id);
// Attach role to request for downstream use (e.g., bericht_text redaction) // Attach role to request for downstream use (e.g., bericht_text redaction)
(req as Request & { userRole?: AppRole }).userRole = role; (req as Request & { userRole?: AppRole }).userRole = role;
@@ -124,7 +139,7 @@ export function requirePermission(permission: string) {
res.status(403).json({ res.status(403).json({
success: false, success: false,
message: `Keine Berechtigung: ${permission}`, message: 'Keine Berechtigung',
}); });
return; return;
} }
@@ -149,7 +164,9 @@ export function requireGroups(requiredGroups: string[]) {
return; return;
} }
const userGroups: string[] = (req.user as any).groups ?? []; logger.warn('DEPRECATED: requireGroups() — migrate to requirePermission()', { requiredGroups });
const userGroups: string[] = req.user?.groups ?? [];
const hasAccess = requiredGroups.some(g => userGroups.includes(g)); const hasAccess = requiredGroups.some(g => userGroups.includes(g));
if (!hasAccess) { if (!hasAccess) {

View File

@@ -1,10 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import atemschutzController from '../controllers/atemschutz.controller'; import atemschutzController from '../controllers/atemschutz.controller';
import { authenticate } from '../middleware/auth.middleware'; import { authenticate } from '../middleware/auth.middleware';
import { requireGroups } from '../middleware/rbac.middleware'; import { requirePermission } from '../middleware/rbac.middleware';
const ADMIN_GROUPS = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'];
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'];
const router = Router(); const router = Router();
@@ -15,13 +12,13 @@ router.get('/stats', authenticate, atemschutzController.getStats.bind(atems
router.get('/my-status', authenticate, atemschutzController.getMyStatus.bind(atemschutzController)); router.get('/my-status', authenticate, atemschutzController.getMyStatus.bind(atemschutzController));
router.get('/:id', authenticate, atemschutzController.getOne.bind(atemschutzController)); router.get('/:id', authenticate, atemschutzController.getOne.bind(atemschutzController));
// ── Write — admin + kommandant ─────────────────────────────────────────────── // ── Write — gruppenfuehrer+ ─────────────────────────────────────────────────
router.post('/', authenticate, requireGroups(WRITE_GROUPS), atemschutzController.create.bind(atemschutzController)); router.post('/', authenticate, requirePermission('atemschutz:write'), atemschutzController.create.bind(atemschutzController));
router.patch('/:id', authenticate, requireGroups(WRITE_GROUPS), atemschutzController.update.bind(atemschutzController)); router.patch('/:id', authenticate, requirePermission('atemschutz:write'), atemschutzController.update.bind(atemschutzController));
// ── Delete — admin only ────────────────────────────────────────────────────── // ── Delete — kommandant+ ────────────────────────────────────────────────────
router.delete('/:id', authenticate, requireGroups(ADMIN_GROUPS), atemschutzController.delete.bind(atemschutzController)); router.delete('/:id', authenticate, requirePermission('atemschutz:delete'), atemschutzController.delete.bind(atemschutzController));
export default router; export default router;

View File

@@ -1,6 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import authController from '../controllers/auth.controller'; import authController from '../controllers/auth.controller';
import { optionalAuth } from '../middleware/auth.middleware'; import { authenticate } from '../middleware/auth.middleware';
const router = Router(); const router = Router();
@@ -14,9 +14,9 @@ router.post('/callback', authController.handleCallback);
/** /**
* @route POST /api/auth/logout * @route POST /api/auth/logout
* @desc Logout user * @desc Logout user
* @access Public (optional auth for logging purposes) * @access Private
*/ */
router.post('/logout', optionalAuth, authController.handleLogout); router.post('/logout', authenticate, authController.handleLogout);
/** /**
* @route POST /api/auth/refresh * @route POST /api/auth/refresh

View File

@@ -1,10 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import bookingController from '../controllers/booking.controller'; import bookingController from '../controllers/booking.controller';
import { authenticate, optionalAuth } from '../middleware/auth.middleware'; import { authenticate, optionalAuth } from '../middleware/auth.middleware';
import { requireGroups } from '../middleware/rbac.middleware'; import { requirePermission } from '../middleware/rbac.middleware';
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_moderator'];
const ADMIN_GROUPS = ['dashboard_admin'];
const router = Router(); const router = Router();
@@ -22,13 +19,13 @@ router.get('/calendar-token', authenticate, bookingController.getCalendarToken.b
// ── Write operations ────────────────────────────────────────────────────────── // ── Write operations ──────────────────────────────────────────────────────────
router.post('/', authenticate, bookingController.create.bind(bookingController)); router.post('/', authenticate, bookingController.create.bind(bookingController));
router.patch('/:id', authenticate, requireGroups(WRITE_GROUPS), bookingController.update.bind(bookingController)); router.patch('/:id', authenticate, requirePermission('bookings:write'), bookingController.update.bind(bookingController));
// Soft-cancel (sets abgesagt=TRUE) // Soft-cancel (sets abgesagt=TRUE)
router.delete('/:id', authenticate, requireGroups(WRITE_GROUPS), bookingController.cancel.bind(bookingController)); router.delete('/:id', authenticate, requirePermission('bookings:write'), bookingController.cancel.bind(bookingController));
// Hard-delete (admin only) // Hard-delete (admin only)
router.delete('/:id/force', authenticate, requireGroups(ADMIN_GROUPS), bookingController.hardDelete.bind(bookingController)); router.delete('/:id/force', authenticate, requirePermission('bookings:delete'), bookingController.hardDelete.bind(bookingController));
// ── Single booking read — after specific routes to avoid path conflicts ─────── // ── Single booking read — after specific routes to avoid path conflicts ───────

View File

@@ -1,10 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import equipmentController from '../controllers/equipment.controller'; import equipmentController from '../controllers/equipment.controller';
import { authenticate } from '../middleware/auth.middleware'; import { authenticate } from '../middleware/auth.middleware';
import { requireGroups } from '../middleware/rbac.middleware'; import { requirePermission } from '../middleware/rbac.middleware';
const ADMIN_GROUPS = ['dashboard_admin'];
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister', 'dashboard_zeugmeister'];
const router = Router(); const router = Router();
@@ -18,15 +15,15 @@ router.get('/vehicle-warnings', authenticate, equipmentController.getVehicleW
router.get('/vehicle/:fahrzeugId', authenticate, equipmentController.getByVehicle.bind(equipmentController)); router.get('/vehicle/:fahrzeugId', authenticate, equipmentController.getByVehicle.bind(equipmentController));
router.get('/:id', authenticate, equipmentController.getEquipment.bind(equipmentController)); router.get('/:id', authenticate, equipmentController.getEquipment.bind(equipmentController));
// ── Write — admin + fahrmeister ────────────────────────────────────────────── // ── Write — gruppenfuehrer+ ────────────────────────────────────────────────
router.post('/', authenticate, requireGroups(WRITE_GROUPS), equipmentController.createEquipment.bind(equipmentController)); router.post('/', authenticate, requirePermission('equipment:write'), equipmentController.createEquipment.bind(equipmentController));
router.patch('/:id', authenticate, requireGroups(WRITE_GROUPS), equipmentController.updateEquipment.bind(equipmentController)); router.patch('/:id', authenticate, requirePermission('equipment:write'), equipmentController.updateEquipment.bind(equipmentController));
router.patch('/:id/status', authenticate, requireGroups(WRITE_GROUPS), equipmentController.updateStatus.bind(equipmentController)); router.patch('/:id/status', authenticate, requirePermission('equipment:write'), equipmentController.updateStatus.bind(equipmentController));
router.post('/:id/wartung', authenticate, requireGroups(WRITE_GROUPS), equipmentController.addWartung.bind(equipmentController)); router.post('/:id/wartung', authenticate, requirePermission('equipment:write'), equipmentController.addWartung.bind(equipmentController));
// ── Delete — admin only ────────────────────────────────────────────────────── // ── Delete — admin only ──────────────────────────────────────────────────────
router.delete('/:id', authenticate, requireGroups(ADMIN_GROUPS), equipmentController.deleteEquipment.bind(equipmentController)); router.delete('/:id', authenticate, requirePermission('equipment:delete'), equipmentController.deleteEquipment.bind(equipmentController));
export default router; export default router;

View File

@@ -1,13 +1,10 @@
import { Router } from 'express'; import { Router } from 'express';
import eventsController from '../controllers/events.controller'; import eventsController from '../controllers/events.controller';
import { authenticate, optionalAuth } from '../middleware/auth.middleware'; import { authenticate, optionalAuth } from '../middleware/auth.middleware';
import { requireGroups } from '../middleware/rbac.middleware'; import { requirePermission } from '../middleware/rbac.middleware';
const router = Router(); const router = Router();
/** Groups that may create, update, or cancel events */
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_moderator'];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Categories // Categories
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -20,34 +17,34 @@ router.get('/kategorien', authenticate, eventsController.listKategorien.bind(eve
/** /**
* POST /api/events/kategorien * POST /api/events/kategorien
* Create a new category. Requires admin or moderator. * Create a new category. Requires gruppenfuehrer+.
*/ */
router.post( router.post(
'/kategorien', '/kategorien',
authenticate, authenticate,
requireGroups(WRITE_GROUPS), requirePermission('events:categories'),
eventsController.createKategorie.bind(eventsController) eventsController.createKategorie.bind(eventsController)
); );
/** /**
* PATCH /api/events/kategorien/:id * PATCH /api/events/kategorien/:id
* Update an existing category. Requires admin or moderator. * Update an existing category. Requires gruppenfuehrer+.
*/ */
router.patch( router.patch(
'/kategorien/:id', '/kategorien/:id',
authenticate, authenticate,
requireGroups(WRITE_GROUPS), requirePermission('events:categories'),
eventsController.updateKategorie.bind(eventsController) eventsController.updateKategorie.bind(eventsController)
); );
/** /**
* DELETE /api/events/kategorien/:id * DELETE /api/events/kategorien/:id
* Delete a category (only if no events reference it). Requires admin or moderator. * Delete a category (only if no events reference it). Requires gruppenfuehrer+.
*/ */
router.delete( router.delete(
'/kategorien/:id', '/kategorien/:id',
authenticate, authenticate,
requireGroups(WRITE_GROUPS), requirePermission('events:categories'),
eventsController.deleteKategorie.bind(eventsController) eventsController.deleteKategorie.bind(eventsController)
); );
@@ -106,23 +103,23 @@ router.get(
/** /**
* POST /api/events/import * POST /api/events/import
* Bulk import events from CSV data. Requires admin or moderator. * Bulk import events from CSV data. Requires gruppenfuehrer+.
*/ */
router.post( router.post(
'/import', '/import',
authenticate, authenticate,
requireGroups(WRITE_GROUPS), requirePermission('events:write'),
eventsController.importEvents.bind(eventsController) eventsController.importEvents.bind(eventsController)
); );
/** /**
* POST /api/events * POST /api/events
* Create a new event. Requires admin or moderator. * Create a new event. Requires gruppenfuehrer+.
*/ */
router.post( router.post(
'/', '/',
authenticate, authenticate,
requireGroups(WRITE_GROUPS), requirePermission('events:write'),
eventsController.createEvent.bind(eventsController) eventsController.createEvent.bind(eventsController)
); );
@@ -134,34 +131,34 @@ router.get('/:id', authenticate, eventsController.getById.bind(eventsController)
/** /**
* PATCH /api/events/:id * PATCH /api/events/:id
* Update an existing event. Requires admin or moderator. * Update an existing event. Requires gruppenfuehrer+.
*/ */
router.patch( router.patch(
'/:id', '/:id',
authenticate, authenticate,
requireGroups(WRITE_GROUPS), requirePermission('events:write'),
eventsController.updateEvent.bind(eventsController) eventsController.updateEvent.bind(eventsController)
); );
/** /**
* DELETE /api/events/:id * DELETE /api/events/:id
* Soft-cancel an event (sets abgesagt=TRUE + reason). Requires admin or moderator. * Soft-cancel an event (sets abgesagt=TRUE + reason). Requires gruppenfuehrer+.
*/ */
router.delete( router.delete(
'/:id', '/:id',
authenticate, authenticate,
requireGroups(WRITE_GROUPS), requirePermission('events:write'),
eventsController.cancelEvent.bind(eventsController) eventsController.cancelEvent.bind(eventsController)
); );
/** /**
* POST /api/events/:id/delete * POST /api/events/:id/delete
* Hard-delete an event permanently. Requires admin or moderator. * Hard-delete an event permanently. Requires gruppenfuehrer+.
*/ */
router.post( router.post(
'/:id/delete', '/:id/delete',
authenticate, authenticate,
requireGroups(WRITE_GROUPS), requirePermission('events:write'),
eventsController.deleteEvent.bind(eventsController) eventsController.deleteEvent.bind(eventsController)
); );

View File

@@ -1,87 +1,16 @@
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import memberController from '../controllers/member.controller'; import memberController from '../controllers/member.controller';
import { authenticate } from '../middleware/auth.middleware'; import { authenticate } from '../middleware/auth.middleware';
import logger from '../utils/logger'; import { requirePermission } from '../middleware/rbac.middleware';
const router = Router(); const router = Router();
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// Role/permission middleware // Apply authentication to every route in this router.
// // requirePermission is applied per-route because PATCH allows the
// The JWT currently carries: { userId, email, authentikSub }. // owner to update their own limited fields even without 'members:write'.
// Roles come from Authentik group membership stored in the
// `groups` array on the UserInfo response. The auth controller
// already upserts the user in the DB on every login; this
// middleware resolves the role from req.user (extended below).
//
// Until a full roles column exists in the users table, roles are
// derived from a well-known Authentik group naming convention:
//
// "feuerwehr-admin" → AppRole 'admin'
// "feuerwehr-kommandant" → AppRole 'kommandant'
// (everything else) → AppRole 'mitglied'
//
// The groups are passed through the JWT as req.user.groups (added
// by the extended type below). Replace this logic with a DB
// lookup once a roles column is added to users.
// ---------------------------------------------------------------- // ----------------------------------------------------------------
router.use(authenticate);
type AppRole = 'admin' | 'kommandant' | 'mitglied';
/**
* Resolves the AppRole from Authentik groups attached to the JWT.
* Mutates req.user.role so downstream controllers can read it directly.
*/
const resolveRole = (req: Request, _res: Response, next: NextFunction): void => {
if (req.user) {
const groups: string[] = (req.user as any).groups ?? [];
if (groups.includes('feuerwehr-admin')) {
req.user.role = 'admin';
} else if (groups.includes('feuerwehr-kommandant')) {
req.user.role = 'kommandant' as any;
} else {
req.user.role = 'mitglied' as any;
}
logger.debug('resolveRole', { userId: req.user.id, role: req.user.role });
}
next();
};
/**
* Factory: creates a middleware that enforces the minimum required role.
* Role hierarchy: admin > kommandant > mitglied
*/
const requirePermission = (permission: 'members:read' | 'members:write') => {
return (req: Request, res: Response, next: NextFunction): void => {
const role = (req.user as any)?.role ?? 'mitglied';
const writeRoles: AppRole[] = ['admin', 'kommandant'];
const readRoles: AppRole[] = ['admin', 'kommandant', 'mitglied'];
const allowed =
permission === 'members:write'
? writeRoles.includes(role)
: readRoles.includes(role);
if (!allowed) {
res.status(403).json({
success: false,
message: 'Keine Berechtigung für diese Aktion.',
});
return;
}
next();
};
};
// ----------------------------------------------------------------
// Apply authentication + role resolution to every route in this
// router. Note: requirePermission is applied per-route because
// PATCH allows the owner to update their own limited fields even
// without 'members:write'.
// ----------------------------------------------------------------
router.use(authenticate, resolveRole);
// IMPORTANT: The static /stats route must be registered BEFORE // IMPORTANT: The static /stats route must be registered BEFORE
// the dynamic /:userId route, otherwise Express would match // the dynamic /:userId route, otherwise Express would match
@@ -110,14 +39,29 @@ router.post(
memberController.createMemberProfile.bind(memberController) memberController.createMemberProfile.bind(memberController)
); );
/**
* Inline middleware for PATCH /:userId.
* Enforces that the caller is either the profile owner OR holds members:write.
* This is the route-level IDOR guard; the controller still applies the
* correct Zod schema (full vs. limited fields) based on role.
*/
const requireOwnerOrWrite = (req: Request, res: Response, next: NextFunction): void => {
const isOwner = req.user?.id === req.params.userId;
if (isOwner) {
next();
return;
}
// Not the owner — must have members:write permission
requirePermission('members:write')(req, res, next);
};
/** /**
* PATCH /:userId — open to both privileged users AND the profile owner. * PATCH /:userId — open to both privileged users AND the profile owner.
* The controller itself enforces the correct Zod schema (full vs. limited) * Route-level guard rejects all other callers before the controller runs.
* based on the caller's role.
*/ */
router.patch( router.patch(
'/:userId', '/:userId',
// No requirePermission here — controller handles own-profile vs. write-role logic requireOwnerOrWrite,
memberController.updateMember.bind(memberController) memberController.updateMember.bind(memberController)
); );

View File

@@ -1,10 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import vehicleController from '../controllers/vehicle.controller'; import vehicleController from '../controllers/vehicle.controller';
import { authenticate } from '../middleware/auth.middleware'; import { authenticate } from '../middleware/auth.middleware';
import { requireGroups } from '../middleware/rbac.middleware'; import { requirePermission } from '../middleware/rbac.middleware';
const ADMIN_GROUPS = ['dashboard_admin'];
const WRITE_GROUPS = ['dashboard_admin', 'dashboard_fahrmeister'];
const router = Router(); const router = Router();
@@ -16,15 +13,15 @@ router.get('/alerts', authenticate, vehicleController.getAlerts.bind(vehicleCont
router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController)); router.get('/:id', authenticate, vehicleController.getVehicle.bind(vehicleController));
router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController)); router.get('/:id/wartung', authenticate, vehicleController.getWartung.bind(vehicleController));
// ── Write — admin only ──────────────────────────────────────────────────────── // ── Write — kommandant+ ──────────────────────────────────────────────────────
router.post('/', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.createVehicle.bind(vehicleController)); router.post('/', authenticate, requirePermission('vehicles:write'), vehicleController.createVehicle.bind(vehicleController));
router.patch('/:id', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.updateVehicle.bind(vehicleController)); router.patch('/:id', authenticate, requirePermission('vehicles:write'), vehicleController.updateVehicle.bind(vehicleController));
router.delete('/:id', authenticate, requireGroups(ADMIN_GROUPS), vehicleController.deleteVehicle.bind(vehicleController)); router.delete('/:id', authenticate, requirePermission('vehicles:delete'), vehicleController.deleteVehicle.bind(vehicleController));
// ── Status + maintenance log — admin + fahrmeister ──────────────────────────── // ── Status + maintenance log — gruppenfuehrer+ ──────────────────────────────
router.patch('/:id/status', authenticate, requireGroups(WRITE_GROUPS), vehicleController.updateVehicleStatus.bind(vehicleController)); router.patch('/:id/status', authenticate, requirePermission('vehicles:status'), vehicleController.updateVehicleStatus.bind(vehicleController));
router.post('/:id/wartung', authenticate, requireGroups(WRITE_GROUPS), vehicleController.addWartung.bind(vehicleController)); router.post('/:id/wartung', authenticate, requirePermission('vehicles:status'), vehicleController.addWartung.bind(vehicleController));
export default router; export default router;

View File

@@ -1,9 +1,23 @@
import axios, { AxiosError } from 'axios'; import axios, { AxiosError } from 'axios';
import * as jose from 'jose';
import authentikConfig from '../config/authentik'; import authentikConfig from '../config/authentik';
import logger from '../utils/logger'; import logger from '../utils/logger';
import { TokenResponse, UserInfo } from '../types/auth.types'; import { TokenResponse, UserInfo } from '../types/auth.types';
class AuthentikService { class AuthentikService {
private jwks: ReturnType<typeof jose.createRemoteJWKSet> | null = null;
private getJwks(): ReturnType<typeof jose.createRemoteJWKSet> {
if (!this.jwks) {
// Derive JWKS URI from the issuer URL
// Issuer: https://auth.example.com/application/o/myapp/
// JWKS: https://auth.example.com/application/o/myapp/jwks/
const jwksUri = new URL('jwks/', authentikConfig.issuer.endsWith('/') ? authentikConfig.issuer : authentikConfig.issuer + '/');
this.jwks = jose.createRemoteJWKSet(jwksUri);
}
return this.jwks;
}
/** /**
* Exchange authorization code for access and ID tokens * Exchange authorization code for access and ID tokens
*/ */
@@ -61,48 +75,26 @@ class AuthentikService {
} }
/** /**
* Verify and decode ID token (basic validation) * Verify and decode ID token using JWKS
* Note: For production, use a proper JWT verification library like jose or jsonwebtoken
*/ */
verifyIdToken(idToken: string): any { async verifyIdToken(idToken: string): Promise<jose.JWTPayload> {
try { try {
// Split the token into parts const { payload } = await jose.jwtVerify(idToken, this.getJwks(), {
const parts = idToken.split('.'); issuer: authentikConfig.issuer,
if (parts.length !== 3) { });
throw new Error('Invalid ID token format');
}
// Decode the payload (Base64URL)
const payload = JSON.parse(
Buffer.from(parts[1], 'base64url').toString('utf-8')
);
// Basic validation
if (!payload.sub || !payload.email) { if (!payload.sub || !payload.email) {
throw new Error('Invalid ID token payload'); throw new Error('Invalid ID token payload: missing sub or email');
} }
// Check expiration logger.info('ID token verified successfully via JWKS', {
if (payload.exp && payload.exp * 1000 < Date.now()) {
throw new Error('ID token has expired');
}
// Check issuer
if (payload.iss && !payload.iss.includes(authentikConfig.issuer)) {
logger.warn('ID token issuer mismatch', {
expected: authentikConfig.issuer,
received: payload.iss,
});
}
logger.info('ID token verified successfully', {
sub: payload.sub, sub: payload.sub,
email: payload.email, email: payload.email,
}); });
return payload; return payload;
} catch (error) { } catch (error) {
logger.error('Failed to verify ID token', { error }); logger.error('Failed to verify ID token via JWKS', { error });
throw new Error('Invalid ID token'); throw new Error('Invalid ID token');
} }
} }

View File

@@ -32,6 +32,47 @@ export interface BookStackSearchResult {
tags: { name: string; value: string; order: number }[]; tags: { name: string; value: string; order: number }[];
} }
/**
* Validates that a URL is safe to use as an outbound service endpoint.
* Rejects non-http(s) protocols and private/loopback IP ranges to prevent SSRF.
*/
function isValidServiceUrl(raw: string): boolean {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return false;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
const hostname = parsed.hostname.toLowerCase();
// Reject plain loopback / localhost names
if (hostname === 'localhost' || hostname === '::1') {
return false;
}
// Reject numeric IPv4 private / loopback / link-local ranges
const ipv4Parts = hostname.split('.');
if (ipv4Parts.length === 4) {
const [a, b] = ipv4Parts.map(Number);
if (
a === 127 || // 127.0.0.0/8 loopback
a === 10 || // 10.0.0.0/8 private
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 private
(a === 192 && b === 168) || // 192.168.0.0/16 private
(a === 169 && b === 254) // 169.254.0.0/16 link-local
) {
return false;
}
}
return true;
}
function buildHeaders(): Record<string, string> { function buildHeaders(): Record<string, string> {
const { bookstack } = environment; const { bookstack } = environment;
return { return {
@@ -42,8 +83,8 @@ function buildHeaders(): Record<string, string> {
async function getRecentPages(): Promise<BookStackPage[]> { async function getRecentPages(): Promise<BookStackPage[]> {
const { bookstack } = environment; const { bookstack } = environment;
if (!bookstack.url) { if (!bookstack.url || !isValidServiceUrl(bookstack.url)) {
throw new Error('BOOKSTACK_URL is not configured'); throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL');
} }
try { try {
@@ -73,8 +114,8 @@ async function getRecentPages(): Promise<BookStackPage[]> {
async function searchPages(query: string): Promise<BookStackSearchResult[]> { async function searchPages(query: string): Promise<BookStackSearchResult[]> {
const { bookstack } = environment; const { bookstack } = environment;
if (!bookstack.url) { if (!bookstack.url || !isValidServiceUrl(bookstack.url)) {
throw new Error('BOOKSTACK_URL is not configured'); throw new Error('BOOKSTACK_URL is not configured or is not a valid service URL');
} }
try { try {

View File

@@ -34,10 +34,51 @@ interface LoginFlowCredentials {
appPassword: string; appPassword: string;
} }
/**
* Validates that a URL is safe to use as an outbound service endpoint.
* Rejects non-http(s) protocols and private/loopback IP ranges to prevent SSRF.
*/
function isValidServiceUrl(raw: string): boolean {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return false;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
const hostname = parsed.hostname.toLowerCase();
// Reject plain loopback / localhost names
if (hostname === 'localhost' || hostname === '::1') {
return false;
}
// Reject numeric IPv4 private / loopback / link-local ranges
const ipv4Parts = hostname.split('.');
if (ipv4Parts.length === 4) {
const [a, b] = ipv4Parts.map(Number);
if (
a === 127 || // 127.0.0.0/8 loopback
a === 10 || // 10.0.0.0/8 private
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 private
(a === 192 && b === 168) || // 192.168.0.0/16 private
(a === 169 && b === 254) // 169.254.0.0/16 link-local
) {
return false;
}
}
return true;
}
async function initiateLoginFlow(): Promise<LoginFlowResult> { async function initiateLoginFlow(): Promise<LoginFlowResult> {
const baseUrl = environment.nextcloudUrl; const baseUrl = environment.nextcloudUrl;
if (!baseUrl) { if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured'); throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
} }
try { try {
@@ -60,6 +101,9 @@ async function initiateLoginFlow(): Promise<LoginFlowResult> {
} }
async function pollLoginFlow(pollEndpoint: string, pollToken: string): Promise<LoginFlowCredentials | null> { async function pollLoginFlow(pollEndpoint: string, pollToken: string): Promise<LoginFlowCredentials | null> {
if (!isValidServiceUrl(pollEndpoint)) {
throw new Error('pollEndpoint is not a valid service URL');
}
try { try {
const response = await axios.post(pollEndpoint, `token=${pollToken}`, { const response = await axios.post(pollEndpoint, `token=${pollToken}`, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
@@ -85,8 +129,8 @@ async function pollLoginFlow(pollEndpoint: string, pollToken: string): Promise<L
async function getConversations(loginName: string, appPassword: string): Promise<ConversationsResult> { async function getConversations(loginName: string, appPassword: string): Promise<ConversationsResult> {
const baseUrl = environment.nextcloudUrl; const baseUrl = environment.nextcloudUrl;
if (!baseUrl) { if (!baseUrl || !isValidServiceUrl(baseUrl)) {
throw new Error('NEXTCLOUD_URL is not configured'); throw new Error('NEXTCLOUD_URL is not configured or is not a valid service URL');
} }
try { try {

View File

@@ -15,6 +15,7 @@ class TokenService {
email: payload.email, email: payload.email,
authentikSub: payload.authentikSub, authentikSub: payload.authentikSub,
groups: payload.groups ?? [], groups: payload.groups ?? [],
role: payload.role,
}, },
environment.jwt.secret, environment.jwt.secret,
{ {

View File

@@ -16,6 +16,47 @@ export interface VikunjaProject {
title: string; title: string;
} }
/**
* Validates that a URL is safe to use as an outbound service endpoint.
* Rejects non-http(s) protocols and private/loopback IP ranges to prevent SSRF.
*/
function isValidServiceUrl(raw: string): boolean {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return false;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
const hostname = parsed.hostname.toLowerCase();
// Reject plain loopback / localhost names
if (hostname === 'localhost' || hostname === '::1') {
return false;
}
// Reject numeric IPv4 private / loopback / link-local ranges
const ipv4Parts = hostname.split('.');
if (ipv4Parts.length === 4) {
const [a, b] = ipv4Parts.map(Number);
if (
a === 127 || // 127.0.0.0/8 loopback
a === 10 || // 10.0.0.0/8 private
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 private
(a === 192 && b === 168) || // 192.168.0.0/16 private
(a === 169 && b === 254) // 169.254.0.0/16 link-local
) {
return false;
}
}
return true;
}
function buildHeaders(): Record<string, string> { function buildHeaders(): Record<string, string> {
return { return {
'Authorization': `Bearer ${environment.vikunja.apiToken}`, 'Authorization': `Bearer ${environment.vikunja.apiToken}`,
@@ -25,8 +66,8 @@ function buildHeaders(): Record<string, string> {
async function getMyTasks(): Promise<VikunjaTask[]> { async function getMyTasks(): Promise<VikunjaTask[]> {
const { vikunja } = environment; const { vikunja } = environment;
if (!vikunja.url) { if (!vikunja.url || !isValidServiceUrl(vikunja.url)) {
throw new Error('VIKUNJA_URL is not configured'); throw new Error('VIKUNJA_URL is not configured or is not a valid service URL');
} }
try { try {
@@ -58,8 +99,8 @@ async function getOverdueTasks(): Promise<VikunjaTask[]> {
async function getProjects(): Promise<VikunjaProject[]> { async function getProjects(): Promise<VikunjaProject[]> {
const { vikunja } = environment; const { vikunja } = environment;
if (!vikunja.url) { if (!vikunja.url || !isValidServiceUrl(vikunja.url)) {
throw new Error('VIKUNJA_URL is not configured'); throw new Error('VIKUNJA_URL is not configured or is not a valid service URL');
} }
try { try {
@@ -82,8 +123,8 @@ async function getProjects(): Promise<VikunjaProject[]> {
async function createTask(projectId: number, title: string, dueDate?: string): Promise<VikunjaTask> { async function createTask(projectId: number, title: string, dueDate?: string): Promise<VikunjaTask> {
const { vikunja } = environment; const { vikunja } = environment;
if (!vikunja.url) { if (!vikunja.url || !isValidServiceUrl(vikunja.url)) {
throw new Error('VIKUNJA_URL is not configured'); throw new Error('VIKUNJA_URL is not configured or is not a valid service URL');
} }
try { try {

View File

@@ -28,6 +28,7 @@ export interface JwtPayload {
email: string; email: string;
authentikSub: string; authentikSub: string;
groups?: string[]; groups?: string[];
role?: string;
iat?: number; iat?: number;
exp?: number; exp?: number;
} }

View File

@@ -30,8 +30,14 @@ const LoginCallback: React.FC = () => {
try { try {
await login(code); await login(code);
// Navigate to the originally intended page, falling back to the dashboard // Navigate to the originally intended page, falling back to the dashboard.
const from = sessionStorage.getItem('auth_redirect_from') || '/dashboard'; // Validate that the stored path is a safe internal path: must start with '/'
// but must NOT start with '//' (protocol-relative redirect).
const rawFrom = sessionStorage.getItem('auth_redirect_from');
const from =
rawFrom && rawFrom.startsWith('/') && !rawFrom.startsWith('//')
? rawFrom
: '/dashboard';
sessionStorage.removeItem('auth_redirect_from'); sessionStorage.removeItem('auth_redirect_from');
navigate(from, { replace: true }); navigate(from, { replace: true });
} catch (err) { } catch (err) {

View File

@@ -13,13 +13,14 @@ import { formatDistanceToNow } from 'date-fns';
import { de } from 'date-fns/locale'; import { de } from 'date-fns/locale';
import { bookstackApi } from '../../services/bookstack'; import { bookstackApi } from '../../services/bookstack';
import type { BookStackPage } from '../../types/bookstack.types'; import type { BookStackPage } from '../../types/bookstack.types';
import { safeOpenUrl } from '../../utils/safeOpenUrl';
const PageRow: React.FC<{ page: BookStackPage; showDivider: boolean }> = ({ const PageRow: React.FC<{ page: BookStackPage; showDivider: boolean }> = ({
page, page,
showDivider, showDivider,
}) => { }) => {
const handleClick = () => { const handleClick = () => {
window.open(page.url, '_blank', 'noopener,noreferrer'); safeOpenUrl(page.url);
}; };
const relativeTime = page.updated_at const relativeTime = page.updated_at

View File

@@ -14,6 +14,7 @@ import { Search, MenuBook } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { bookstackApi } from '../../services/bookstack'; import { bookstackApi } from '../../services/bookstack';
import type { BookStackSearchResult } from '../../types/bookstack.types'; import type { BookStackSearchResult } from '../../types/bookstack.types';
import { safeOpenUrl } from '../../utils/safeOpenUrl';
function stripHtml(html: string): string { function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, '').trim(); return html.replace(/<[^>]*>/g, '').trim();
@@ -28,7 +29,7 @@ const ResultRow: React.FC<{ result: BookStackSearchResult; showDivider: boolean
return ( return (
<> <>
<Box <Box
onClick={() => window.open(result.url, '_blank', 'noopener,noreferrer')} onClick={() => safeOpenUrl(result.url)}
sx={{ sx={{
py: 1.5, py: 1.5,
px: 1, px: 1,

View File

@@ -18,6 +18,7 @@ import { formatDistanceToNow } from 'date-fns';
import { de } from 'date-fns/locale'; import { de } from 'date-fns/locale';
import { nextcloudApi } from '../../services/nextcloud'; import { nextcloudApi } from '../../services/nextcloud';
import type { NextcloudConversation } from '../../types/nextcloud.types'; import type { NextcloudConversation } from '../../types/nextcloud.types';
import { safeOpenUrl } from '../../utils/safeOpenUrl';
const POLL_INTERVAL = 2000; const POLL_INTERVAL = 2000;
const POLL_TIMEOUT = 5 * 60 * 1000; const POLL_TIMEOUT = 5 * 60 * 1000;
@@ -27,7 +28,7 @@ const ConversationRow: React.FC<{ conversation: NextcloudConversation; showDivid
showDivider, showDivider,
}) => { }) => {
const handleClick = () => { const handleClick = () => {
window.open(conversation.url, '_blank', 'noopener,noreferrer'); safeOpenUrl(conversation.url);
}; };
const relativeTime = conversation.lastMessage const relativeTime = conversation.lastMessage

View File

@@ -14,6 +14,7 @@ import { format, isPast } from 'date-fns';
import { de } from 'date-fns/locale'; import { de } from 'date-fns/locale';
import { vikunjaApi } from '../../services/vikunja'; import { vikunjaApi } from '../../services/vikunja';
import type { VikunjaTask } from '../../types/vikunja.types'; import type { VikunjaTask } from '../../types/vikunja.types';
import { safeOpenUrl } from '../../utils/safeOpenUrl';
const PRIORITY_LABELS: Record<number, { label: string; color: 'default' | 'warning' | 'error' }> = { const PRIORITY_LABELS: Record<number, { label: string; color: 'default' | 'warning' | 'error' }> = {
0: { label: 'Keine', color: 'default' }, 0: { label: 'Keine', color: 'default' },
@@ -30,7 +31,7 @@ const TaskRow: React.FC<{ task: VikunjaTask; showDivider: boolean; vikunjaUrl: s
vikunjaUrl, vikunjaUrl,
}) => { }) => {
const handleClick = () => { const handleClick = () => {
window.open(`${vikunjaUrl}/tasks/${task.id}`, '_blank', 'noopener,noreferrer'); safeOpenUrl(`${vikunjaUrl}/tasks/${task.id}`);
}; };
const dueDateStr = task.due_date const dueDateStr = task.due_date

View File

@@ -25,6 +25,20 @@ import type { Notification, NotificationSchwere } from '../../types/notification
const POLL_INTERVAL_MS = 60_000; // 60 seconds const POLL_INTERVAL_MS = 60_000; // 60 seconds
/**
* Only allow window.open for URLs whose origin matches the current app origin.
* External-looking URLs (different host or protocol-relative) are rejected to
* prevent open-redirect / tab-napping via notification link data from the backend.
*/
function isTrustedUrl(url: string): boolean {
try {
const parsed = new URL(url, window.location.origin);
return parsed.origin === window.location.origin;
} catch {
return false;
}
}
function schwerebColor(schwere: NotificationSchwere): 'error' | 'warning' | 'info' { function schwerebColor(schwere: NotificationSchwere): 'error' | 'warning' | 'info' {
if (schwere === 'fehler') return 'error'; if (schwere === 'fehler') return 'error';
if (schwere === 'warnung') return 'warning'; if (schwere === 'warnung') return 'warning';
@@ -103,7 +117,11 @@ const NotificationBell: React.FC = () => {
handleClose(); handleClose();
if (n.link) { if (n.link) {
if (n.link.startsWith('http://') || n.link.startsWith('https://')) { if (n.link.startsWith('http://') || n.link.startsWith('https://')) {
window.open(n.link, '_blank'); if (isTrustedUrl(n.link)) {
window.open(n.link, '_blank');
} else {
console.warn('NotificationBell: blocked navigation to untrusted URL', n.link);
}
} else { } else {
navigate(n.link); navigate(n.link);
} }

View File

@@ -1,7 +1,7 @@
import React, { createContext, useCallback, useContext, useState, useEffect, ReactNode } from 'react'; import React, { createContext, useCallback, useContext, useState, useEffect, ReactNode } from 'react';
import { AuthContextType, AuthState, User } from '../types/auth.types'; import { AuthContextType, AuthState, User } from '../types/auth.types';
import { authService } from '../services/auth'; import { authService } from '../services/auth';
import { getToken, setToken, removeToken, getUser, setUser, removeUser } from '../utils/storage'; import { getToken, setToken, removeToken, getUser, setUser, removeUser, setRefreshToken, removeRefreshToken } from '../utils/storage';
import { useNotification } from './NotificationContext'; import { useNotification } from './NotificationContext';
import { setAuthInitialized } from '../services/api'; import { setAuthInitialized } from '../services/api';
@@ -71,10 +71,11 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
try { try {
setState((prev) => ({ ...prev, isLoading: true })); setState((prev) => ({ ...prev, isLoading: true }));
const { token, user } = await authService.handleCallback(code); const { token, refreshToken, user } = await authService.handleCallback(code);
// Save to localStorage // Save to localStorage
setToken(token); setToken(token);
setRefreshToken(refreshToken);
setUser(user); setUser(user);
// Update state // Update state
@@ -115,6 +116,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
// Clear local state // Clear local state
removeToken(); removeToken();
removeRefreshToken();
removeUser(); removeUser();
setState({ setState({
user: null, user: null,

View File

@@ -23,7 +23,11 @@ function Login() {
useEffect(() => { useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {
setIsRedirecting(true); setIsRedirecting(true);
const from = (location.state as any)?.from || '/dashboard'; const rawFrom = (location.state as any)?.from;
const from =
rawFrom && rawFrom.startsWith('/') && !rawFrom.startsWith('//')
? rawFrom
: '/dashboard';
navigate(from, { replace: true }); navigate(from, { replace: true });
} }
}, [isAuthenticated, navigate, location.state]); }, [isAuthenticated, navigate, location.state]);
@@ -31,10 +35,11 @@ function Login() {
const handleLogin = () => { const handleLogin = () => {
try { try {
// Persist the intended destination so LoginCallback can restore it // Persist the intended destination so LoginCallback can restore it
// after the full-page Authentik redirect round-trip // after the full-page Authentik redirect round-trip.
const from = (location.state as any)?.from; // Validate that from is a safe internal path before storing it.
if (from) { const rawFrom = (location.state as any)?.from;
sessionStorage.setItem('auth_redirect_from', from); if (rawFrom && rawFrom.startsWith('/') && !rawFrom.startsWith('//')) {
sessionStorage.setItem('auth_redirect_from', rawFrom);
} }
const authUrl = authService.getAuthUrl(); const authUrl = authService.getAuthUrl();
window.location.href = authUrl; window.location.href = authUrl;

View File

@@ -1,4 +1,3 @@
import { useState } from 'react';
import { import {
Container, Container,
Typography, Typography,
@@ -10,35 +9,16 @@ import {
Switch, Switch,
Divider, Divider,
Box, Box,
Button,
ToggleButtonGroup, ToggleButtonGroup,
ToggleButton, ToggleButton,
} from '@mui/material'; } from '@mui/material';
import { Settings as SettingsIcon, Notifications, Palette, Language, Save, SettingsBrightness, LightMode, DarkMode } from '@mui/icons-material'; import { Settings as SettingsIcon, Notifications, Palette, Language, SettingsBrightness, LightMode, DarkMode } from '@mui/icons-material';
import DashboardLayout from '../components/dashboard/DashboardLayout'; import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useNotification } from '../contexts/NotificationContext';
import { useThemeMode } from '../contexts/ThemeContext'; import { useThemeMode } from '../contexts/ThemeContext';
function Settings() { function Settings() {
const notification = useNotification();
const { themeMode, setThemeMode } = useThemeMode(); const { themeMode, setThemeMode } = useThemeMode();
// Settings state
const [emailNotifications, setEmailNotifications] = useState(true);
const [alarmNotifications, setAlarmNotifications] = useState(true);
const [maintenanceReminders, setMaintenanceReminders] = useState(false);
const [systemNotifications, setSystemNotifications] = useState(true);
const [compactView, setCompactView] = useState(true);
const [animations, setAnimations] = useState(true);
const handleSaveSettings = () => {
try {
// In a real application, save settings to backend
notification.showSuccess('Einstellungen erfolgreich gespeichert');
} catch (error) {
notification.showError('Fehler beim Speichern der Einstellungen');
}
};
return ( return (
<DashboardLayout> <DashboardLayout>
<Container maxWidth="lg"> <Container maxWidth="lg">
@@ -58,41 +38,24 @@ function Settings() {
<Divider sx={{ mb: 2 }} /> <Divider sx={{ mb: 2 }} />
<FormGroup> <FormGroup>
<FormControlLabel <FormControlLabel
control={ control={<Switch checked={false} disabled />}
<Switch
checked={emailNotifications}
onChange={(e) => setEmailNotifications(e.target.checked)}
/>
}
label="E-Mail-Benachrichtigungen" label="E-Mail-Benachrichtigungen"
/> />
<FormControlLabel <FormControlLabel
control={ control={<Switch checked={false} disabled />}
<Switch
checked={alarmNotifications}
onChange={(e) => setAlarmNotifications(e.target.checked)}
/>
}
label="Einsatz-Alarme" label="Einsatz-Alarme"
/> />
<FormControlLabel <FormControlLabel
control={ control={<Switch checked={false} disabled />}
<Switch
checked={maintenanceReminders}
onChange={(e) => setMaintenanceReminders(e.target.checked)}
/>
}
label="Wartungserinnerungen" label="Wartungserinnerungen"
/> />
<FormControlLabel <FormControlLabel
control={ control={<Switch checked={false} disabled />}
<Switch
checked={systemNotifications}
onChange={(e) => setSystemNotifications(e.target.checked)}
/>
}
label="System-Benachrichtigungen" label="System-Benachrichtigungen"
/> />
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
(Bald verfügbar)
</Typography>
</FormGroup> </FormGroup>
</CardContent> </CardContent>
</Card> </Card>
@@ -109,23 +72,16 @@ function Settings() {
<Divider sx={{ mb: 2 }} /> <Divider sx={{ mb: 2 }} />
<FormGroup> <FormGroup>
<FormControlLabel <FormControlLabel
control={ control={<Switch checked={false} disabled />}
<Switch
checked={compactView}
onChange={(e) => setCompactView(e.target.checked)}
/>
}
label="Kompakte Ansicht" label="Kompakte Ansicht"
/> />
<FormControlLabel <FormControlLabel
control={ control={<Switch checked={false} disabled />}
<Switch
checked={animations}
onChange={(e) => setAnimations(e.target.checked)}
/>
}
label="Animationen" label="Animationen"
/> />
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
(Bald verfügbar)
</Typography>
<Box sx={{ mt: 1 }}> <Box sx={{ mt: 1 }}>
<Typography variant="body2" sx={{ mb: 1 }}> <Typography variant="body2" sx={{ mb: 1 }}>
Farbschema Farbschema
@@ -204,18 +160,6 @@ function Settings() {
wird in zukünftigen Updates implementiert. wird in zukünftigen Updates implementiert.
</Typography> </Typography>
</Box> </Box>
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
color="primary"
size="large"
startIcon={<Save />}
onClick={handleSaveSettings}
>
Einstellungen speichern
</Button>
</Box>
</Container> </Container>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -1,10 +1,27 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'; import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios';
import { API_URL } from '../utils/config'; import { API_URL } from '../utils/config';
import { getToken, removeToken, removeUser } from '../utils/storage'; import { getToken, setToken, removeToken, removeUser, getRefreshToken, removeRefreshToken } from '../utils/storage';
let authInitialized = false; let authInitialized = false;
let isRedirectingToLogin = false; let isRedirectingToLogin = false;
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: any) => void;
}> = [];
function processQueue(error: any, token: string | null = null) {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token!);
}
});
failedQueue = [];
}
export function setAuthInitialized(value: boolean): void { export function setAuthInitialized(value: boolean): void {
authInitialized = value; authInitialized = value;
if (value === true) { if (value === true) {
@@ -54,15 +71,71 @@ class ApiService {
(response) => response, (response) => response,
async (error: AxiosError) => { async (error: AxiosError) => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
if (authInitialized && !isRedirectingToLogin) { const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };
isRedirectingToLogin = true;
// Clear tokens and redirect to login // Don't attempt refresh during auth initialization or if already retried
console.warn('Unauthorized request, redirecting to login'); if (!authInitialized || originalRequest._retry) {
removeToken(); if (authInitialized && !isRedirectingToLogin) {
removeUser(); isRedirectingToLogin = true;
window.location.href = '/login'; removeToken();
removeRefreshToken();
removeUser();
window.location.href = '/login';
}
return Promise.reject(this.handleError(error));
}
if (isRefreshing) {
return new Promise<AxiosResponse>((resolve, reject) => {
failedQueue.push({
resolve: (token: string) => {
originalRequest.headers = { ...originalRequest.headers, Authorization: `Bearer ${token}` };
resolve(this.axiosInstance.request(originalRequest));
},
reject: (err: any) => {
reject(err);
},
});
});
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = getRefreshToken();
if (!refreshToken) {
isRefreshing = false;
if (!isRedirectingToLogin) {
isRedirectingToLogin = true;
removeToken();
removeRefreshToken();
removeUser();
window.location.href = '/login';
}
return Promise.reject(this.handleError(error));
}
try {
// Use a raw axios call (not the intercepted instance) to avoid loops
const response = await axios.post(`${API_URL}/api/auth/refresh`, { refreshToken });
const newToken = response.data.data.accessToken;
setToken(newToken);
processQueue(null, newToken);
originalRequest.headers = { ...originalRequest.headers, Authorization: `Bearer ${newToken}` };
return this.axiosInstance.request(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
if (!isRedirectingToLogin) {
isRedirectingToLogin = true;
removeToken();
removeRefreshToken();
removeUser();
window.location.href = '/login';
}
return Promise.reject(this.handleError(error));
} finally {
isRefreshing = false;
} }
// During initialization, silently reject without redirecting
} }
// Retry on 429 (Too Many Requests) with exponential backoff // Retry on 429 (Too Many Requests) with exponential backoff

View File

@@ -6,6 +6,7 @@ const REDIRECT_URI = `${window.location.origin}/auth/callback`;
export interface AuthCallbackResponse { export interface AuthCallbackResponse {
token: string; token: string;
refreshToken: string;
user: User; user: User;
} }
@@ -51,6 +52,7 @@ export const authService = {
}); });
return { return {
token: response.data.data.accessToken, token: response.data.data.accessToken,
refreshToken: response.data.data.refreshToken,
user: mapBackendUser(response.data.data.user), user: mapBackendUser(response.data.data.user),
}; };
}, },

View File

@@ -1,7 +1,18 @@
const apiUrl: string = import.meta.env.VITE_API_URL;
const authentikUrl: string = import.meta.env.AUTHENTIK_URL || 'https://auth.firesuite.feuerwehr-rems.at';
const clientId: string = import.meta.env.AUTHENTIK_CLIENT_ID;
if (!apiUrl) {
console.error('Missing required environment variable: VITE_API_URL');
}
if (!clientId) {
console.error('Missing required environment variable: AUTHENTIK_CLIENT_ID');
}
export const config = { export const config = {
apiUrl: import.meta.env.VITE_API_URL || 'http://localhost:3000', apiUrl,
authentikUrl: import.meta.env.AUTHENTIK_URL || 'https://auth.firesuite.feuerwehr-rems.at', authentikUrl,
clientId: import.meta.env.AUTHENTIK_CLIENT_ID || 'your_client_id_here', clientId,
}; };
export const API_URL = config.apiUrl; export const API_URL = config.apiUrl;

View File

@@ -0,0 +1,20 @@
/**
* Safely opens a URL in a new tab.
*
* Validates the URL before opening it to prevent malicious URLs (e.g.
* javascript: or data: URIs) from being opened if an API response is
* ever compromised. Only http: and https: URLs are allowed.
*/
export function safeOpenUrl(url: string): void {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
console.warn(`safeOpenUrl: blocked URL with unexpected protocol "${parsed.protocol}": ${url}`);
return;
}
} catch {
console.warn(`safeOpenUrl: blocked invalid URL: ${url}`);
return;
}
window.open(url, '_blank', 'noopener,noreferrer');
}

View File

@@ -28,6 +28,33 @@ export const removeToken = (): void => {
} }
}; };
const REFRESH_TOKEN_KEY = 'auth_refresh_token';
export const getRefreshToken = (): string | null => {
try {
return localStorage.getItem(REFRESH_TOKEN_KEY);
} catch (error) {
console.error('Error getting refresh token from localStorage:', error);
return null;
}
};
export const setRefreshToken = (token: string): void => {
try {
localStorage.setItem(REFRESH_TOKEN_KEY, token);
} catch (error) {
console.error('Error setting refresh token in localStorage:', error);
}
};
export const removeRefreshToken = (): void => {
try {
localStorage.removeItem(REFRESH_TOKEN_KEY);
} catch (error) {
console.error('Error removing refresh token from localStorage:', error);
}
};
export const getUser = (): User | null => { export const getUser = (): User | null => {
try { try {
const userStr = localStorage.getItem(USER_KEY); const userStr = localStorage.getItem(USER_KEY);

View File

@@ -23,6 +23,6 @@ export default defineConfig({
envPrefix: ['VITE_', 'AUTHENTIK_'], envPrefix: ['VITE_', 'AUTHENTIK_'],
build: { build: {
outDir: 'dist', outDir: 'dist',
sourcemap: true, sourcemap: false,
}, },
}); });