修复采购附件同源 XSS(07-17-purchase-attachment-xss-hardening)

这个提交包含在:
2026-07-17 20:32:05 +08:00
父节点 4c5eb7e22e
当前提交 f424492751
共修改 16 个文件,包含 2999 行新增和 211 行删除
+53 -7
查看文件
@@ -8,10 +8,10 @@ import {
archivePurchaseOrder,
createPurchaseRequest,
deletePurchaseAttachment,
downloadPurchaseAttachment,
fetchPurchaseOrderArchive,
fetchPurchaseRequestDetail,
fetchPurchaseRequests,
issuePurchaseAttachmentSignedUrl,
precheckPurchaseRequest,
submitPurchaseRequest,
updatePurchaseRequest,
@@ -54,6 +54,7 @@ import { SectionPanel } from "../components/patterns/SectionPanel";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { formatCompactIdentifier, formatDisplayDateTime, formatTextWithCompactLongNumbers, isCompactedIdentifier } from "../lib/displayFormat";
import { downloadBlob } from "../lib/download";
import { buildPrecheckDialogMessage } from "../lib/precheck";
import {
formatApprovalHistoryTime,
@@ -65,6 +66,7 @@ import {
} from "../lib/approvalDisplay";
const PAGE_SIZE = 20;
const MAX_PURCHASE_ATTACHMENT_DOWNLOAD_NAME_LENGTH = 180;
type DraftItem = {
id: string;
@@ -77,7 +79,7 @@ type DraftItem = {
function resolveError(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
return mapApiErrorMessage(error.code, error.details, error.message);
}
if (error instanceof Error) {
return error.message;
@@ -85,6 +87,26 @@ function resolveError(error: unknown) {
return "请求失败,请稍后重试。";
}
function sanitizePurchaseAttachmentDownloadName(value?: string) {
const cleaned = (value ?? "")
.normalize("NFC")
.replace(/[\u0000-\u001f\u007f-\u009f/\\]+/g, "_")
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]+/g, "_")
.replace(/\s+/g, " ")
.replace(/^[\s._]+|[\s._]+$/g, "")
.trim();
const fallback = cleaned || "采购附件";
const characters = Array.from(fallback);
if (characters.length <= MAX_PURCHASE_ATTACHMENT_DOWNLOAD_NAME_LENGTH) {
return fallback;
}
const extension = fallback.match(/\.[a-z0-9]{1,10}$/i)?.[0] ?? "";
const stem = extension ? fallback.slice(0, -extension.length) : fallback;
const stemLength = Math.max(1, MAX_PURCHASE_ATTACHMENT_DOWNLOAD_NAME_LENGTH - Array.from(extension).length);
return `${Array.from(stem).slice(0, stemLength).join("")}${extension}`;
}
function moneyPreview(quantity: string, unitPrice: string) {
const quantityNumber = Number(quantity);
const unitPriceNumber = Number(unitPrice);
@@ -717,13 +739,17 @@ export function PurchaseRequestsPage() {
if (!selectedId) {
return;
}
if (attachment.governanceStatus === "quarantined") {
toast.error("附件已隔离,无法下载,请联系管理员。");
return;
}
const reason = await requestDownloadReason(attachment);
if (reason === null) {
return;
}
try {
const response = await issuePurchaseAttachmentSignedUrl(selectedId, attachment.id, reason);
window.open(response.signedUrl, "_blank", "noopener,noreferrer");
const response = await downloadPurchaseAttachment(selectedId, attachment.id, reason);
downloadBlob(response.blob, sanitizePurchaseAttachmentDownloadName(attachment.originalName));
} catch (error) {
const message = resolveError(error);
setFormError(message);
@@ -1566,7 +1592,9 @@ export function PurchaseRequestsPage() {
<div className="flex items-center justify-between">
<div>
<h4 className="text-base font-semibold text-zinc-900">材料证据</h4>
<p className="text-sm text-zinc-500">用于报价、订单、验收或情况说明;草稿状态可增删,下载会留痕。</p>
<p id="purchase-attachment-format-hint" className="text-sm text-zinc-500">
用于报价、订单、验收或情况说明;支持 PDF、JPG、PNG、WEBP,下载会留痕,OA 内不直接预览。
</p>
</div>
<div className="flex min-w-0 items-center gap-3">
<input
@@ -1574,6 +1602,8 @@ export function PurchaseRequestsPage() {
hidden
ref={attachmentInputRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png,.webp,application/pdf,image/jpeg,image/png,image/webp"
aria-describedby="purchase-attachment-format-hint"
disabled={!canEditDraft || uploadMutation.isPending}
onChange={(event) => {
const file = event.target.files?.[0];
@@ -1624,7 +1654,15 @@ export function PurchaseRequestsPage() {
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button type="button" size="sm" variant="secondary" onClick={() => void handleDownloadAttachment(attachment)}>
<Button
data-testid={`purchase-attachment-download-mobile-${attachment.id}`}
type="button"
size="sm"
variant="secondary"
disabled={attachment.governanceStatus === "quarantined"}
title={attachment.governanceStatus === "quarantined" ? "附件已隔离,无法下载" : undefined}
onClick={() => void handleDownloadAttachment(attachment)}
>
下载
</Button>
<Button
@@ -1666,7 +1704,15 @@ export function PurchaseRequestsPage() {
<TableCell>{formatDisplayDateTime(attachment.createdAt)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button type="button" size="sm" variant="secondary" onClick={() => void handleDownloadAttachment(attachment)}>
<Button
data-testid={`purchase-attachment-download-desktop-${attachment.id}`}
type="button"
size="sm"
variant="secondary"
disabled={attachment.governanceStatus === "quarantined"}
title={attachment.governanceStatus === "quarantined" ? "附件已隔离,无法下载" : undefined}
onClick={() => void handleDownloadAttachment(attachment)}
>
下载
</Button>
<Button