security: fix all Medium findings from FLAWS_REPORT audit

- Auth: TOTP replay protection with counter tracking, constant-time
  backup code comparison, atomic lockout increment, per-token logout
- Invoices/PDFs: net-based VAT calculation, dangerous URL scheme
  stripping in cleanQuillHtml, orders-pdf error handling
- Orders: reject item changes on status transition, cascading
  delete cleanup, take:1 with orderBy
- Projects: atomic rename collision handling, MIME/extension
  validation, empty customer name rejection
- Attendance: Czech public holiday awareness in frontend fund
  calculation, leave_hours 0 handling, invalid date NaN guard,
  bounded per-month queries in workfund
- Users/Admin: profile audit logging + password validation, session
  revocation guard, session ID validation, dashboard DB aggregation,
  soft-deleted record protection in scope templates
- Frontend: FormField label linkage, Pagination ARIA, error
  handling in OrderConfirmationModal, 401 propagation, GPS emoji
  hidden from screen readers, table sort state fix, geolocation
  race/abort cleanup, Leaflet popup DOM safety, Vehicles toggleActive
  minimal body, CompanySettings ref mutation fix, OfferDetail unlock
  abort, AttendanceBalances combined fetches
- Utils: env validation, Puppeteer concurrency mutex, invoice alert
  cron cleanup on shutdown, body limit alignment, TOTP error logging,
  trustProxy from env, symlink rejection, rate cache Map usage

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-04-24 08:24:14 +02:00
parent 528e55991b
commit 4f4b12f039
33 changed files with 442 additions and 211 deletions

View File

@@ -53,7 +53,9 @@ async function loadAuthData(userId: number): Promise<AuthData | null> {
const isAdmin = user.roles?.name === "admin";
const permissions = isAdmin
? (await prisma.permissions.findMany()).map((p: { name: string }) => p.name)
? (await prisma.permissions.findMany({ select: { name: true } })).map(
(p) => p.name,
)
: (user.roles?.role_permissions ?? []).map(
(rp: { permissions: { name: string } }) => rp.permissions.name,
);
@@ -129,21 +131,24 @@ export async function login(
const passwordValid = await bcrypt.compare(password, user.password_hash);
if (!passwordValid) {
const settings = await getSystemSettings();
await prisma.users.update({
where: { id: user.id },
data: { failed_login_attempts: { increment: 1 } },
});
if ((user.failed_login_attempts ?? 0) + 1 >= settings.max_login_attempts) {
await prisma.users.update({
await prisma.$transaction(async (tx) => {
const updated = await tx.users.update({
where: { id: user.id },
data: {
locked_until: new Date(
Date.now() + settings.lockout_minutes * 60_000,
),
},
data: { failed_login_attempts: { increment: 1 } },
select: { failed_login_attempts: true },
});
}
if ((updated.failed_login_attempts ?? 0) >= settings.max_login_attempts) {
await tx.users.update({
where: { id: user.id },
data: {
locked_until: new Date(
Date.now() + settings.lockout_minutes * 60_000,
),
},
});
}
});
return {
type: "error",
@@ -310,26 +315,12 @@ export async function refreshAccessToken(
export async function logout(refreshTokenRaw: string): Promise<void> {
const tokenHash = hashToken(refreshTokenRaw);
const token = await prisma.refresh_tokens.findFirst({
// Delete only the specific token presented, not all sessions
await prisma.refresh_tokens.deleteMany({
where: { token_hash: tokenHash },
});
if (token) {
// Delete all tokens for this user from the same IP + user agent (same browser session)
await prisma.refresh_tokens.deleteMany({
where: {
user_id: token.user_id,
ip_address: token.ip_address,
user_agent: token.user_agent,
},
});
} else {
// Fallback: just delete by hash
await prisma.refresh_tokens.deleteMany({
where: { token_hash: tokenHash },
});
}
await prisma.refresh_tokens.deleteMany({
where: { expires_at: { lt: new Date() } },
});