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:
@@ -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;
|
||||
|
||||
@@ -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() } },
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user