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

@@ -313,7 +313,7 @@ export default function InvoiceDetail() {
const { id } = useParams<{ id: string }>();
const isEdit = Boolean(id);
const keyCounterRef = useRef(0);
const keyCounterRef = useRef(1);
const emptyItem = useCallback(
(): InvoiceItem => ({
_key: `inv-${++keyCounterRef.current}`,
@@ -369,7 +369,16 @@ export default function InvoiceDetail() {
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
const [dueDays, setDueDays] = useState(14);
const [items, setItems] = useState<InvoiceItem[]>([emptyItem()]);
const [items, setItems] = useState<InvoiceItem[]>([
{
_key: "inv-1",
description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
vat_rate: 21,
},
]);
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);

View File

@@ -138,8 +138,18 @@ export default function Invoices() {
const [statsLoading, setStatsLoading] = useState(true);
const hasLoadedOnce = useRef(false);
const slideDirection = useRef(0);
const blobUrlRef = useRef<string | null>(null);
const [slideKey, setSlideKey] = useState(0);
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, []);
const isCurrentMonth =
statsMonth === now.getMonth() + 1 && statsYear === now.getFullYear();
const monthLabel = `${MONTH_NAMES[statsMonth - 1]} ${statsYear}`;
@@ -299,9 +309,11 @@ export default function Invoices() {
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = url;
setTimeout(() => URL.revokeObjectURL(url), 60000);
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
}
blobUrlRef.current = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = blobUrlRef.current;
} catch {
alert.error("Chyba při generování PDF");
} finally {

View File

@@ -55,9 +55,6 @@ interface OfferItem {
is_included_in_total: boolean;
}
let _itemKeyCounter = 0;
const nextItemKey = () => `item-${++_itemKeyCounter}`;
interface ScopeSection {
title: string;
title_cz: string;
@@ -113,16 +110,6 @@ const emptyScopeSection = (): ScopeSection => ({
content: "",
});
const emptyItem = (): OfferItem => ({
_key: nextItemKey(),
description: "",
item_description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
is_included_in_total: true,
});
function SortableItemRow({
item,
index,
@@ -288,11 +275,25 @@ export default function OfferDetail() {
useSensor(KeyboardSensor),
);
const itemKeyCounter = useRef(0);
const emptyItem = useCallback(
(): OfferItem => ({
_key: `item-${++itemKeyCounter.current}`,
description: "",
item_description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
is_included_in_total: true,
}),
[],
);
const [loading, setLoading] = useState(isEdit);
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
const [form, setForm] = useState<OfferForm>(emptyForm);
const [items, setItems] = useState<OfferItem[]>([emptyItem()]);
const [items, setItems] = useState<OfferItem[]>(() => [emptyItem()]);
const [sections, setSections] = useState<ScopeSection[]>([]);
const [scopeTemplates, setScopeTemplates] = useState<
Array<{
@@ -397,7 +398,10 @@ export default function OfferDetail() {
});
setItems(
d.items?.length
? d.items.map((it: any) => ({ ...it, _key: nextItemKey() }))
? d.items.map((it: any) => ({
...it,
_key: `item-${++itemKeyCounter.current}`,
}))
: [emptyItem()],
);
setSections(

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect, useRef } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link, useNavigate } from "react-router-dom";
@@ -63,6 +63,16 @@ export default function Offers() {
quotation: Quotation | null;
}>({ show: false, quotation: null });
const [invalidating, setInvalidating] = useState(false);
const blobUrlRef = useRef<string | null>(null);
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, []);
const [duplicating, setDuplicating] = useState<number | null>(null);
const [pdfLoading, setPdfLoading] = useState<number | null>(null);
const [creatingOrder, setCreatingOrder] = useState<number | null>(null);
@@ -237,9 +247,11 @@ export default function Offers() {
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = url;
setTimeout(() => URL.revokeObjectURL(url), 60000);
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
}
blobUrlRef.current = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = blobUrlRef.current;
} catch {
newWindow?.close();
alert.error("Chyba připojení");

View File

@@ -76,6 +76,7 @@ export default function Settings() {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [roles, setRoles] = useState<Role[]>([]);
const [users, setUsers] = useState<{ role_id: number }[]>([]);
const [, setAllPermissions] = useState<Permission[]>([]);
const [permissionGroups, setPermissionGroups] = useState<
Record<string, Permission[]>
@@ -161,12 +162,14 @@ export default function Settings() {
return;
}
try {
const [rolesRes, permsRes] = await Promise.all([
const [rolesRes, permsRes, usersRes] = await Promise.all([
apiFetch(`${API_BASE}/roles`),
apiFetch(`${API_BASE}/roles/permissions`),
apiFetch(`${API_BASE}/users`),
]);
const rolesResult = await rolesRes.json();
const permsResult = await permsRes.json();
const usersResult = await usersRes.json();
if (rolesResult.success) {
setRoles(Array.isArray(rolesResult.data) ? rolesResult.data : []);
@@ -188,6 +191,10 @@ export default function Settings() {
}
setPermissionGroups(groups);
}
if (usersResult.success) {
setUsers(Array.isArray(usersResult.data) ? usersResult.data : []);
}
} catch {
alert.error("Chyba připojení");
} finally {
@@ -808,7 +815,7 @@ export default function Settings() {
</td>
<td>
<span className="admin-badge admin-badge-secondary">
{0}
{users.filter((u) => u.role_id === role.id).length}
</span>
</td>
<td>
@@ -838,16 +845,21 @@ export default function Settings() {
}
className="admin-btn-icon danger"
title={
0 > 0
users.filter((u) => u.role_id === role.id)
.length > 0
? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat"
}
aria-label={
0 > 0
users.filter((u) => u.role_id === role.id)
.length > 0
? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat"
}
disabled={0 > 0}
disabled={
users.filter((u) => u.role_id === role.id)
.length > 0
}
>
<svg
width="16"