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:
@@ -153,11 +153,32 @@ export default async function authRoutes(
|
||||
return error(reply, "Uživatel nenalezen", 401);
|
||||
}
|
||||
|
||||
const isValid = OTPAuth.verify(user.totp_secret, totp_code);
|
||||
if (!isValid) {
|
||||
const verifyResult = OTPAuth.verify(user.totp_secret, totp_code);
|
||||
if (!verifyResult.valid) {
|
||||
return error(reply, "Neplatný TOTP kód", 401);
|
||||
}
|
||||
|
||||
// Reject replayed TOTP codes
|
||||
const replayCheck = await prisma.$transaction(async (tx) => {
|
||||
const rows = await tx.$queryRaw<
|
||||
Array<{ totp_last_used_counter: number | null }>
|
||||
>`SELECT totp_last_used_counter FROM users WHERE id = ${user.id} FOR UPDATE`;
|
||||
const lastCounter = rows[0]?.totp_last_used_counter ?? null;
|
||||
if (
|
||||
lastCounter !== null &&
|
||||
verifyResult.counter !== null &&
|
||||
verifyResult.counter <= lastCounter
|
||||
) {
|
||||
return { replay: true };
|
||||
}
|
||||
await tx.$executeRaw`UPDATE users SET totp_last_used_counter = ${verifyResult.counter} WHERE id = ${user.id}`;
|
||||
return { replay: false };
|
||||
});
|
||||
|
||||
if (replayCheck.replay) {
|
||||
return error(reply, "TOTP kód již byl použit", 401);
|
||||
}
|
||||
|
||||
// Reset failed attempts and update last login (TOTP verified = successful login)
|
||||
await prisma.users.update({
|
||||
where: { id: user.id },
|
||||
|
||||
@@ -179,24 +179,25 @@ export default async function dashboardRoutes(
|
||||
|
||||
// Invoices — only for invoices.view
|
||||
if (has("invoices.view")) {
|
||||
const [unpaidCount, issuedThisMonth] = await Promise.all([
|
||||
const [unpaidCount, revenueAgg] = await Promise.all([
|
||||
prisma.invoices.count({ where: { status: "issued" } }),
|
||||
prisma.invoices.findMany({
|
||||
where: { issue_date: { gte: monthStart, lt: monthEnd } },
|
||||
include: { invoice_items: true },
|
||||
}),
|
||||
prisma.$queryRaw<
|
||||
Array<{ currency: string | null; total: string | number | null }>
|
||||
>`
|
||||
SELECT i.currency, SUM(ii.quantity * ii.unit_price) as total
|
||||
FROM invoices i
|
||||
JOIN invoice_items ii ON i.id = ii.invoice_id
|
||||
WHERE i.issue_date >= ${monthStart} AND i.issue_date < ${monthEnd}
|
||||
GROUP BY i.currency
|
||||
`,
|
||||
]);
|
||||
|
||||
const revenueByCurrency: Record<string, number> = {};
|
||||
for (const inv of issuedThisMonth) {
|
||||
const currency = inv.currency ?? "CZK";
|
||||
let total = 0;
|
||||
for (const item of inv.invoice_items) {
|
||||
total +=
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
||||
}
|
||||
for (const row of revenueAgg) {
|
||||
const currency = row.currency || "CZK";
|
||||
const amount = Number(row.total) || 0;
|
||||
revenueByCurrency[currency] =
|
||||
(revenueByCurrency[currency] ?? 0) + total;
|
||||
(revenueByCurrency[currency] || 0) + amount;
|
||||
}
|
||||
|
||||
result.invoices = {
|
||||
|
||||
@@ -54,7 +54,14 @@ function cleanQuillHtml(html: string | null | undefined): string {
|
||||
);
|
||||
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
|
||||
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
|
||||
s = s.replace(
|
||||
/href\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
|
||||
'href="#"',
|
||||
);
|
||||
s = s.replace(
|
||||
/src\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
|
||||
'src=""',
|
||||
);
|
||||
s = s.replace(/( )/g, " ");
|
||||
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
|
||||
let prev = "";
|
||||
|
||||
@@ -63,19 +63,19 @@ export default async function profileRoutes(
|
||||
config.security.bcryptCost,
|
||||
);
|
||||
data.password_changed_at = new Date();
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "password_change",
|
||||
entityType: "user",
|
||||
entityId: userId,
|
||||
description: "Změna hesla",
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.users.update({ where: { id: userId }, data });
|
||||
|
||||
await logAudit({
|
||||
request,
|
||||
authData: request.authData,
|
||||
action: "update",
|
||||
entityType: "user",
|
||||
entityId: userId,
|
||||
description: data.password_hash ? "Změna hesla" : "Aktualizace profilu",
|
||||
});
|
||||
|
||||
if (body.current_password && body.new_password) {
|
||||
await prisma.refresh_tokens.updateMany({
|
||||
where: { user_id: userId, replaced_at: null },
|
||||
|
||||
@@ -263,10 +263,10 @@ export default async function receivedInvoicesRoutes(
|
||||
const meta = invoicesMeta[i] || {};
|
||||
const amount = Number(meta.amount ?? 0);
|
||||
const vatRate = Number(meta.vat_rate ?? 21);
|
||||
// Amount includes VAT — extract VAT portion: amount - amount/(1 + rate/100)
|
||||
// Amount is net — VAT = amount * rate / 100
|
||||
const vatAmount =
|
||||
vatRate > 0
|
||||
? Math.round((amount - amount / (1 + vatRate / 100)) * 100) / 100
|
||||
? Math.round(((amount * vatRate) / 100) * 100) / 100
|
||||
: 0;
|
||||
|
||||
const issueDate = meta.issue_date
|
||||
@@ -434,13 +434,9 @@ export default async function receivedInvoicesRoutes(
|
||||
body.vat_rate !== undefined
|
||||
? Number(body.vat_rate)
|
||||
: Number(existing.vat_rate);
|
||||
// Amount includes VAT — extract VAT portion: amount - amount/(1 + rate/100)
|
||||
// Amount is net — VAT = amount * rate / 100
|
||||
const computedVat =
|
||||
finalVatRate > 0
|
||||
? roundMoney(
|
||||
finalAmount - roundMoney(finalAmount / (1 + finalVatRate / 100)),
|
||||
)
|
||||
: 0;
|
||||
finalVatRate > 0 ? roundMoney((finalAmount * finalVatRate) / 100) : 0;
|
||||
|
||||
// Auto-set paid_date when status transitions to paid (matching PHP)
|
||||
const newStatus =
|
||||
|
||||
@@ -70,12 +70,12 @@ export default async function scopeTemplatesRoutes(
|
||||
};
|
||||
|
||||
if (body.id) {
|
||||
const existingItem = await prisma.item_templates.findUnique({
|
||||
where: { id: Number(body.id) },
|
||||
const existingItem = await prisma.item_templates.findFirst({
|
||||
where: { id: Number(body.id), is_deleted: false },
|
||||
});
|
||||
if (!existingItem) return error(reply, "Šablona nenalezena", 404);
|
||||
await prisma.item_templates.update({
|
||||
where: { id: Number(body.id) },
|
||||
await prisma.item_templates.updateMany({
|
||||
where: { id: Number(body.id), is_deleted: false },
|
||||
data: { ...itemData, modified_at: new Date() },
|
||||
});
|
||||
return success(
|
||||
|
||||
@@ -86,6 +86,7 @@ export default async function sessionsRoutes(
|
||||
{ preHandler: requireAuth },
|
||||
async (request, reply) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
if (Number.isNaN(id)) return error(reply, "Neplatné ID relace", 400);
|
||||
const authData = request.authData!;
|
||||
|
||||
const session = await prisma.refresh_tokens.findFirst({
|
||||
@@ -111,11 +112,15 @@ export default async function sessionsRoutes(
|
||||
const currentToken = request.cookies?.refresh_token;
|
||||
const currentHash = currentToken ? hashToken(currentToken) : null;
|
||||
|
||||
if (!currentHash) {
|
||||
return error(reply, "Nelze identifikovat aktuální relaci", 400);
|
||||
}
|
||||
|
||||
await prisma.refresh_tokens.updateMany({
|
||||
where: {
|
||||
user_id: authData.userId,
|
||||
replaced_at: null,
|
||||
...(currentHash ? { token_hash: { not: currentHash } } : {}),
|
||||
token_hash: { not: currentHash },
|
||||
},
|
||||
data: { replaced_at: new Date() },
|
||||
});
|
||||
|
||||
@@ -67,11 +67,11 @@ export default async function totpRoutes(
|
||||
400,
|
||||
);
|
||||
}
|
||||
const isValid = OTPAuth.verify(
|
||||
const verifyResult = OTPAuth.verify(
|
||||
user.totp_secret!,
|
||||
String(body.current_code),
|
||||
);
|
||||
if (!isValid) {
|
||||
if (!verifyResult.valid) {
|
||||
return error(reply, "Neplatný aktuální TOTP kód", 400);
|
||||
}
|
||||
} else {
|
||||
@@ -153,8 +153,8 @@ export default async function totpRoutes(
|
||||
return error(reply, "2FA není aktivní", 400);
|
||||
}
|
||||
|
||||
const isValid = OTPAuth.verify(user.totp_secret, String(body.code));
|
||||
if (!isValid) {
|
||||
const verifyResult = OTPAuth.verify(user.totp_secret, String(body.code));
|
||||
if (!verifyResult.valid) {
|
||||
return error(reply, "Neplatný TOTP kód", 400);
|
||||
}
|
||||
|
||||
@@ -308,7 +308,6 @@ export default async function totpRoutes(
|
||||
const isMatch = await bcrypt.compare(String(code), backupCodes[i]);
|
||||
if (isMatch) {
|
||||
matchIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user