security: fix all Critical and High findings from FLAWS_REPORT audit

- Auth: pessimistic locking on login tokens and refresh token rotation,
  backup code attempt counter, rate limiting verification
- Schema: unique constraints on business numbers, FK relations,
  unsigned/signed alignment, attendance duplicate prevention
- Invoices/PDFs: DOMPurify sanitization, bounded queries in stats
  and alerts, VAT rounding, Puppeteer error handling
- Orders/Offers: transactional parent+child creation, Zod NaN
  refinement, status enums, uniqueness checks
- Projects/Files: path traversal protection, streamed uploads,
  permission guards, query param validation
- Attendance/HR: duplicate checks, ownership validation, GPS
  restrictions, trip distance validation
- Frontend: modal lock reference counting, XSS escaping in print
  HTML, ref mutation fixes, accessibility attributes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-04-24 00:58:35 +02:00
parent 122eee175e
commit 528e55991b
57 changed files with 2355 additions and 1010 deletions

View File

@@ -7,6 +7,12 @@ import { nasFinancialsManager } from "../../services/nas-financials-manager";
import { htmlToPdf } from "../../utils/html-to-pdf";
import { getRate } from "../../services/exchange-rates";
import { localDateStr } from "../../utils/date";
import { parseId } from "../../utils/response";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
/* ── Helpers ─────────────────────────────────────────────────────── */
@@ -50,6 +56,7 @@ function cleanQuillHtml(html: string | null | undefined): string {
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(/(&nbsp;)/g, " ");
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
let prev = "";
while (prev !== s) {
prev = s;
@@ -78,7 +85,12 @@ function buildAddressLines(
let fieldOrder: string[] | null = null;
const raw = entity.custom_fields;
if (raw) {
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
let parsed: unknown;
try {
parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
} catch {
parsed = null;
}
if (parsed && typeof parsed === "object") {
if ((parsed as Record<string, unknown>).fields) {
cfData =
@@ -250,185 +262,189 @@ export default async function invoicesPdfRoutes(
"/:id",
{ preHandler: requirePermission("invoices.export") },
async (request, reply) => {
const id = parseInt(request.params.id, 10);
const id = parseId(request.params.id, reply);
if (id === null) return;
const query = request.query as Record<string, string>;
const lang = query.lang === "en" ? "en" : "cs";
const t = translations[lang];
const invoice = await prisma.invoices.findUnique({
where: { id },
});
try {
const lang = query.lang === "en" ? "en" : "cs";
const t = translations[lang];
if (!invoice) {
return reply
.status(404)
.type("text/html")
.send("<html><body><h1>Faktura nenalezena</h1></body></html>");
}
const items = await prisma.invoice_items.findMany({
where: { invoice_id: id },
orderBy: { position: "asc" },
});
let customer: Record<string, unknown> | null = null;
if (invoice.customer_id) {
customer = (await prisma.customers.findUnique({
where: { id: invoice.customer_id },
})) as Record<string, unknown> | null;
}
const settings = (await prisma.company_settings.findFirst()) as Record<
string,
unknown
> | null;
let orderNumber = "";
let orderDate = "";
if (invoice.order_id) {
const orderRow = await prisma.orders.findUnique({
where: { id: invoice.order_id },
select: {
order_number: true,
customer_order_number: true,
created_at: true,
},
const invoice = await prisma.invoices.findUnique({
where: { id },
});
if (orderRow) {
orderNumber = escapeHtml(
String(
orderRow.customer_order_number || orderRow.order_number || "",
),
);
if (orderRow.created_at) {
orderDate = formatDate(orderRow.created_at);
if (!invoice) {
return reply
.status(404)
.type("text/html")
.send("<html><body><h1>Faktura nenalezena</h1></body></html>");
}
const items = await prisma.invoice_items.findMany({
where: { invoice_id: id },
orderBy: { position: "asc" },
});
let customer: Record<string, unknown> | null = null;
if (invoice.customer_id) {
customer = (await prisma.customers.findUnique({
where: { id: invoice.customer_id },
})) as Record<string, unknown> | null;
}
const settings = (await prisma.company_settings.findFirst()) as Record<
string,
unknown
> | null;
let orderNumber = "";
let orderDate = "";
if (invoice.order_id) {
const orderRow = await prisma.orders.findUnique({
where: { id: invoice.order_id },
select: {
order_number: true,
customer_order_number: true,
created_at: true,
},
});
if (orderRow) {
orderNumber = escapeHtml(
String(
orderRow.customer_order_number || orderRow.order_number || "",
),
);
if (orderRow.created_at) {
orderDate = formatDate(orderRow.created_at);
}
}
}
}
let logoImg = "";
if (settings?.logo_data) {
const buf = Buffer.from(settings.logo_data as Buffer);
let mime = "image/png";
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
const b64 = buf.toString("base64");
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
}
const currency = invoice.currency || "CZK";
const applyVat = !!invoice.apply_vat;
const vatSummary: Record<string, { base: number; vat: number }> = {};
let subtotal = 0;
for (const item of items) {
const lineSubtotal = Number(item.quantity) * Number(item.unit_price);
subtotal += lineSubtotal;
const rate = Number(item.vat_rate);
const key = String(rate);
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
vatSummary[key].base += lineSubtotal;
if (applyVat) {
vatSummary[key].vat += (lineSubtotal * rate) / 100;
let logoImg = "";
if (settings?.logo_data) {
const buf = Buffer.from(settings.logo_data as Buffer);
let mime = "image/png";
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
const b64 = buf.toString("base64");
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
}
}
let totalVat = 0;
for (const data of Object.values(vatSummary)) {
totalVat += data.vat;
}
const totalToPay = subtotal + totalVat;
const currency = invoice.currency || "CZK";
const applyVat = !!invoice.apply_vat;
// QR code - SPAYD payment format
let qrSvg = "";
try {
const spaydParts = [
"SPD*1.0",
"ACC:" + (invoice.bank_iban || "").replace(/ /g, ""),
"AM:" + totalToPay.toFixed(2),
"CC:" + currency,
"X-VS:" + (invoice.invoice_number || ""),
"X-KS:" + (invoice.constant_symbol || "0308"),
"MSG:" + t.title + " " + (invoice.invoice_number || ""),
];
const spaydString = spaydParts.join("*");
qrSvg = await QRCode.toString(spaydString, {
type: "svg",
errorCorrectionLevel: "M",
margin: 1,
width: 200,
});
} catch {
// QR generation failed — leave empty
}
const vatSummary: Record<string, { base: number; vat: number }> = {};
let subtotal = 0;
// VAT recapitulation (always in CZK — Czech tax requirement)
const isForeign = currency.toUpperCase() !== "CZK";
const issueDateStr = invoice.issue_date
? localDateStr(new Date(invoice.issue_date))
: undefined;
const cnbRate = isForeign ? await getRate(currency, issueDateStr) : 1.0;
const vatRates = [21, 12, 0];
const vatRecap: Array<{
rate: number;
base: number;
vat: number;
total: number;
}> = [];
for (const rate of vatRates) {
const key = String(rate);
const base = vatSummary[key]?.base ?? 0;
const vat = vatSummary[key]?.vat ?? 0;
vatRecap.push({
rate,
base: Math.round(base * cnbRate * 100) / 100,
vat: Math.round(vat * cnbRate * 100) / 100,
total: Math.round((base + vat) * cnbRate * 100) / 100,
});
}
for (const item of items) {
const lineSubtotal = Number(item.quantity) * Number(item.unit_price);
subtotal += lineSubtotal;
const rate = Number(item.vat_rate);
const key = String(rate);
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
vatSummary[key].base += lineSubtotal;
if (applyVat) {
vatSummary[key].vat +=
Math.round(((lineSubtotal * rate) / 100) * 100) / 100;
}
}
const supp = buildAddressLines(settings, true, t);
const cust = buildAddressLines(customer, false, t);
let totalVat = 0;
for (const data of Object.values(vatSummary)) {
totalVat += data.vat;
}
const totalToPay = subtotal + totalVat;
const suppLinesHtml = supp.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
const custLinesHtml = cust.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
// QR code - SPAYD payment format
let qrSvg = "";
try {
const spaydParts = [
"SPD*1.0",
"ACC:" + (invoice.bank_iban || "").replace(/ /g, ""),
"AM:" + totalToPay.toFixed(2),
"CC:" + currency,
"X-VS:" + (invoice.invoice_number || ""),
"X-KS:" + (invoice.constant_symbol || "0308"),
"MSG:" + t.title + " " + (invoice.invoice_number || ""),
];
const spaydString = spaydParts.join("*");
qrSvg = await QRCode.toString(spaydString, {
type: "svg",
errorCorrectionLevel: "M",
margin: 1,
width: 200,
});
} catch {
// QR generation failed — leave empty
}
// Supplier email/web from custom_fields
let suppEmail = "";
if (settings?.custom_fields) {
const raw = settings.custom_fields;
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
if (parsed && typeof parsed === "object") {
const fields = (parsed as Record<string, unknown>).fields;
if (Array.isArray(fields)) {
for (const f of fields) {
if (f.name && f.name.toLowerCase() === "email" && f.value) {
suppEmail = String(f.value);
// VAT recapitulation (always in CZK — Czech tax requirement)
const isForeign = currency.toUpperCase() !== "CZK";
const issueDateStr = invoice.issue_date
? localDateStr(new Date(invoice.issue_date))
: undefined;
const cnbRate = isForeign ? await getRate(currency, issueDateStr) : 1.0;
const vatRates = [21, 12, 0];
const vatRecap: Array<{
rate: number;
base: number;
vat: number;
total: number;
}> = [];
for (const rate of vatRates) {
const key = String(rate);
const base = vatSummary[key]?.base ?? 0;
const vat = vatSummary[key]?.vat ?? 0;
vatRecap.push({
rate,
base: Math.round(base * cnbRate * 100) / 100,
vat: Math.round(vat * cnbRate * 100) / 100,
total: Math.round((base + vat) * cnbRate * 100) / 100,
});
}
const supp = buildAddressLines(settings, true, t);
const cust = buildAddressLines(customer, false, t);
const suppLinesHtml = supp.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
const custLinesHtml = cust.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
// Supplier email/web from custom_fields
let suppEmail = "";
if (settings?.custom_fields) {
const raw = settings.custom_fields;
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
if (parsed && typeof parsed === "object") {
const fields = (parsed as Record<string, unknown>).fields;
if (Array.isArray(fields)) {
for (const f of fields) {
if (f.name && f.name.toLowerCase() === "email" && f.value) {
suppEmail = String(f.value);
}
}
}
}
}
}
const invoiceNumber = escapeHtml(invoice.invoice_number);
const invoiceNumber = escapeHtml(invoice.invoice_number);
const itemsHtml = items
.map((item, i) => {
const qty = Number(item.quantity);
const unitPrice = Number(item.unit_price);
const lineSubtotal = qty * unitPrice;
const vatRate = Number(item.vat_rate);
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
const lineTotal = lineSubtotal + lineVat;
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
const itemsHtml = items
.map((item, i) => {
const qty = Number(item.quantity);
const unitPrice = Number(item.unit_price);
const lineSubtotal = qty * unitPrice;
const vatRate = Number(item.vat_rate);
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
const lineTotal = lineSubtotal + lineVat;
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
return `<tr>
return `<tr>
<td class="row-num">${i + 1}</td>
<td class="desc">${escapeHtml(item.description)}</td>
<td class="center">${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
@@ -438,55 +454,55 @@ export default async function invoicesPdfRoutes(
<td class="right">${formatNum(lineVat)}</td>
<td class="right total-cell">${formatNum(lineTotal)}</td>
</tr>`;
})
.join("");
})
.join("");
const vatRecapHtml = vatRecap
.map(
(vr) => `<tr>
const vatRecapHtml = vatRecap
.map(
(vr) => `<tr>
<td class="right">${formatNum(vr.base)}</td>
<td class="center">${Math.floor(vr.rate)}%</td>
<td class="right">${formatNum(vr.vat)}</td>
<td class="right">${formatNum(vr.total)}</td>
</tr>`,
)
.join("");
)
.join("");
let vatDetailHtml = "";
if (applyVat) {
for (const [rate, data] of Object.entries(vatSummary)) {
if (data.vat > 0) {
vatDetailHtml += `
let vatDetailHtml = "";
if (applyVat) {
for (const [rate, data] of Object.entries(vatSummary)) {
if (data.vat > 0) {
vatDetailHtml += `
<div class="row">
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
</div>`;
}
}
}
}
const notesRaw = invoice.notes ?? "";
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
const notesHtml = notesStripped
? `
const notesRaw = invoice.notes ?? "";
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
const notesHtml = notesStripped
? `
<!-- Poznamky -->
<div class="invoice-notes">
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
<div class="invoice-notes-content">${cleanQuillHtml(notesRaw)}</div>
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
</div>
`
: "";
: "";
// Quill indent CSS
let indentCSS = "";
for (let n = 1; n <= 9; n++) {
const pad = n * 3;
const liPad = n * 3 + 1.5;
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
}
// Quill indent CSS
let indentCSS = "";
for (let n = 1; n <= 9; n++) {
const pad = n * 3;
const liPad = n * 3 + 1.5;
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
}
const html = `<!DOCTYPE html>
const html = `<!DOCTYPE html>
<html lang="${escapeHtml(lang)}">
<head>
<meta charset="utf-8" />
@@ -1000,33 +1016,36 @@ ${indentCSS}
</body>
</html>`;
// Save PDF to NAS
if (nasFinancialsManager.isConfigured() && invoice.invoice_number) {
const issueDate = invoice.issue_date
? new Date(invoice.issue_date)
: new Date();
const saveMode = query.save === "1";
nasFinancialsManager.cleanIssuedInvoice(invoice.invoice_number!);
const pdfPromise = htmlToPdf(html)
.then((pdfBuffer) => {
nasFinancialsManager.saveIssuedInvoicePdf(
invoice.invoice_number!,
issueDate.getFullYear(),
issueDate.getMonth() + 1,
pdfBuffer,
);
})
.catch((err) => {
request.log.error(err, "Failed to save invoice PDF to NAS");
});
if (saveMode) {
await pdfPromise;
// Save PDF to NAS
if (
saveMode &&
nasFinancialsManager.isConfigured() &&
invoice.invoice_number
) {
const issueDate = invoice.issue_date
? new Date(invoice.issue_date)
: new Date();
nasFinancialsManager.cleanIssuedInvoice(invoice.invoice_number!);
const pdfBuffer = await htmlToPdf(html);
nasFinancialsManager.saveIssuedInvoicePdf(
invoice.invoice_number!,
issueDate.getFullYear(),
issueDate.getMonth() + 1,
pdfBuffer,
);
return reply.send({ success: true, message: "PDF uloženo" });
}
}
return reply.type("text/html").send(html);
return reply.type("text/html").send(html);
} catch (err) {
request.log.error(err, "PDF generation failed");
return reply
.status(500)
.type("text/html")
.send("<html><body><h1>Chyba při generování PDF</h1></body></html>");
}
},
);
}