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

@@ -517,12 +517,6 @@ export async function getWorkfund(year: number) {
};
}
const yearStart = new Date(year, 0, 1);
const yearEnd = new Date(year, maxMonth + 1, 0, 23, 59, 59);
const allRecords = await prisma.attendance.findMany({
where: { shift_date: { gte: yearStart, lte: yearEnd } },
});
const months: Record<
string,
{
@@ -553,6 +547,19 @@ export async function getWorkfund(year: number) {
const fundToDate = bizDaysToDate * 8;
const monthStart = new Date(year, m, 1);
const monthEnd = new Date(year, m + 1, 0, 23, 59, 59);
const monthRecords = await prisma.attendance.findMany({
where: { shift_date: { gte: monthStart, lte: monthEnd } },
select: {
user_id: true,
shift_date: true,
leave_type: true,
arrival_time: true,
departure_time: true,
break_start: true,
break_end: true,
leave_hours: true,
},
});
const monthUsers: Record<
string,
@@ -566,12 +573,7 @@ export async function getWorkfund(year: number) {
> = {};
for (const u of users) {
const recs = allRecords.filter(
(r) =>
r.user_id === u.id &&
r.shift_date >= monthStart &&
r.shift_date <= monthEnd,
);
const recs = monthRecords.filter((r) => r.user_id === u.id);
let worked = 0;
let vacationHours = 0;
let sickHours = 0;

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() } },
});

View File

@@ -10,13 +10,13 @@ interface CnbRate {
amount: number;
}
const rateCache: Record<string, Record<string, number>> = {};
const rateCache = new Map<string, Record<string, number>>();
async function fetchRatesForDate(
date?: string,
): Promise<Record<string, number>> {
const key = date || "today";
if (rateCache[key]) return rateCache[key];
if (rateCache.has(key)) return rateCache.get(key)!;
try {
let url = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN";
@@ -32,11 +32,11 @@ async function fetchRatesForDate(
rates[r.currencyCode] = r.rate / r.amount;
}
rateCache[key] = rates;
rateCache.set(key, rates);
return rates;
} catch (err) {
console.error("Failed to fetch CNB exchange rates:", err);
if (rateCache["today"]) return rateCache["today"];
if (rateCache.has("today")) return rateCache.get("today")!;
throw new Error("Nepodařilo se získat aktuální kurzy z ČNB");
}
}

View File

@@ -337,9 +337,16 @@ export class NasFileManager {
try {
const typeResult = await FileType.fromFile(tempPath);
if (typeResult && this.isSuspiciousMime(typeResult.mime, ext)) {
await fs.promises.unlink(tempPath).catch(() => {});
return "Obsah souboru neodpovídá jeho příponě";
if (typeResult) {
if (this.isSuspiciousMime(typeResult.mime)) {
await fs.promises.unlink(tempPath).catch(() => {});
return "Obsah souboru neodpovídá jeho příponě";
}
const expectedMime = ext ? MIME_MAP[ext] : null;
if (expectedMime && typeResult.mime !== expectedMime) {
await fs.promises.unlink(tempPath).catch(() => {});
return "Obsah souboru neodpovídá jeho příponě";
}
}
} catch {
// If file-type fails, continue without MIME check
@@ -347,27 +354,29 @@ export class NasFileManager {
let destPath = dirPath + "/" + safeName;
try {
await fs.promises.stat(destPath);
const base = path.basename(safeName, ext ? "." + ext : "");
let counter = 1;
do {
safeName = base + "_" + counter + (ext ? "." + ext : "");
destPath = dirPath + "/" + safeName;
counter++;
} while (
await fs.promises
.stat(destPath)
.then(() => true)
.catch(() => false)
);
} catch {
// destPath does not exist, continue
}
// Attempt atomic rename; if destination exists, append counter
let renamed = false;
let attempts = 0;
const maxAttempts = 1000;
do {
try {
await fs.promises.rename(tempPath, destPath);
renamed = true;
break;
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code === "EEXIST") {
const base = path.basename(safeName, ext ? "." + ext : "");
attempts++;
safeName = base + "_" + attempts + (ext ? "." + ext : "");
destPath = dirPath + "/" + safeName;
} else {
break;
}
}
} while (!renamed && attempts < maxAttempts);
try {
await fs.promises.rename(tempPath, destPath);
} catch {
if (!renamed) {
await fs.promises.unlink(tempPath).catch(() => {});
return "Nepodařilo se uložit soubor";
}
@@ -514,8 +523,8 @@ export class NasFileManager {
}
try {
const stat = await fs.promises.stat(dirPath);
if (!stat.isDirectory()) {
const stat = await fs.promises.lstat(dirPath);
if (stat.isSymbolicLink() || !stat.isDirectory()) {
return "Nadřazená složka neexistuje";
}
} catch {
@@ -703,7 +712,7 @@ export class NasFileManager {
return Math.round((bytes / 1073741824) * 10) / 10 + " GB";
}
private isSuspiciousMime(mime: string, ext: string): boolean {
private isSuspiciousMime(mime: string): boolean {
if (SUSPICIOUS_MIMES.includes(mime)) {
return true;
}

View File

@@ -97,7 +97,11 @@ export async function listOrders(params: ListOrdersParams) {
order_items: { orderBy: { position: "asc" } },
order_sections: { orderBy: { position: "asc" } },
quotations: { select: { quotation_number: true, project_code: true } },
invoices: { select: { id: true, invoice_number: true }, take: 1 },
invoices: {
select: { id: true, invoice_number: true },
take: 1,
orderBy: { id: "desc" },
},
},
}),
prisma.orders.count({ where }),
@@ -410,6 +414,16 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
status: 400,
} as const;
}
if (
body.status !== undefined &&
(String(body.status) === "dokoncena" ||
String(body.status) === "stornovana")
) {
return {
error: "Nelze upravit položky při změně stavu na dokončeno/storno",
status: 400,
} as const;
}
await prisma.$transaction(async (tx) => {
await tx.orders.update({ where: { id }, data });
@@ -504,6 +518,10 @@ export async function deleteOrder(id: number) {
await prisma.projects.deleteMany({ where: { order_id: id } });
}
// Explicitly clean up child rows
await prisma.order_items.deleteMany({ where: { order_id: id } });
await prisma.order_sections.deleteMany({ where: { order_id: id } });
await prisma.orders.delete({ where: { id } });
const releasedYears = new Set<number>();