修复采购附件同源 XSS(07-17-purchase-attachment-xss-hardening)
这个提交包含在:
@@ -333,6 +333,52 @@ export function mapApiErrorMessage(
|
||||
return "请假冻结账本状态异常,请联系管理员处理。";
|
||||
}
|
||||
|
||||
if (details?.attachment === "security_validation_failed") {
|
||||
if (details?.reason === "quarantined") {
|
||||
return "附件已隔离,无法下载,请联系管理员。";
|
||||
}
|
||||
if (details?.reason === "missing_object") {
|
||||
return "附件文件不存在或已缺失,请联系管理员。";
|
||||
}
|
||||
return "附件未通过安全校验,请重新上传允许格式或联系管理员。";
|
||||
}
|
||||
|
||||
if (details?.file === "empty_file") {
|
||||
return "附件内容为空,请重新选择文件。";
|
||||
}
|
||||
|
||||
if (details?.file === "size_limit" || details?.file === "file_too_large") {
|
||||
return "附件超过大小限制,请压缩后重新上传。";
|
||||
}
|
||||
|
||||
if (details?.file === "dangerous_filename") {
|
||||
return "附件名称不安全,请重命名后重新上传。";
|
||||
}
|
||||
|
||||
if (details?.file === "unsupported_type") {
|
||||
return "仅支持 PDF、JPG、PNG、WEBP 文件,请重新选择。";
|
||||
}
|
||||
|
||||
if (details?.file === "type_mismatch") {
|
||||
return "附件扩展名、声明类型与实际内容不一致,请重新选择原始文件或转换为支持格式。";
|
||||
}
|
||||
|
||||
if (details?.file === "corrupt_content") {
|
||||
return "附件已损坏或内容不完整,请重新导出后上传。";
|
||||
}
|
||||
|
||||
if (details?.file === "pixel_limit") {
|
||||
return "图片尺寸过大,请压缩后重新上传。";
|
||||
}
|
||||
|
||||
if (details?.file === "multiple_pages") {
|
||||
return "暂不支持多页或动画图片,请转换为 PDF 或单页安全图片。";
|
||||
}
|
||||
|
||||
if (details?.file === "only_single_file_supported") {
|
||||
return "一次只能上传一个附件,请重新选择。";
|
||||
}
|
||||
|
||||
if (details?.file === "required") {
|
||||
return "当前假种要求上传附件后才能提交。";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiRequestData } from "./http";
|
||||
import { apiRequestBlob, apiRequestData } from "./http";
|
||||
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
|
||||
import type { PrecheckResponse, RiskFlag } from "./feeControl";
|
||||
|
||||
@@ -386,11 +386,12 @@ export async function deletePurchaseAttachment(purchaseRequestId: string, attach
|
||||
);
|
||||
}
|
||||
|
||||
export async function issuePurchaseAttachmentSignedUrl(purchaseRequestId: string, attachmentId: string, reason?: string) {
|
||||
return apiRequestData<{ signedUrl: string; expiresAt: string }>(
|
||||
`/api/purchase-requests/${purchaseRequestId}/attachments/${attachmentId}/signed-url`,
|
||||
export async function downloadPurchaseAttachment(purchaseRequestId: string, attachmentId: string, reason?: string) {
|
||||
return apiRequestBlob(
|
||||
`/api/purchase-requests/${purchaseRequestId}/attachments/${attachmentId}/download`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(reason ? { reason } : {}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -134,6 +134,10 @@ const defaultCloseoutReimbursementId = "40876ef3-0652-4972-aa14-1aad1a855ae0";
|
||||
const defaultCloseoutReimbursementCode = "BX-20260514-0016";
|
||||
const wecomUa =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 wxwork/4.1.20 MicroMessenger/8.0.1 Language/zh_CN";
|
||||
const safePurchaseAttachmentPng = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADUlEQVR4nGP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
function nowStamp() {
|
||||
return `${trialrunMarkerPrefix}-${Date.now()}`;
|
||||
@@ -1029,15 +1033,15 @@ test(`${trialrunIssueId} 浏览器业务先行试运行:报销、审批、请
|
||||
await page.locator('[data-testid^="purchase-item-quantity-"]:visible').first().fill("1");
|
||||
await page.locator('[data-testid^="purchase-item-unit-price-"]:visible').first().fill("188");
|
||||
|
||||
const purchaseAttachmentName = `${trialrunSafePrefix.toLowerCase()}-purchase-attachment-${marker}.txt`;
|
||||
const purchaseAttachmentName = `${trialrunSafePrefix.toLowerCase()}-purchase-attachment-${marker}.png`;
|
||||
const uploadAttachmentResponsePromise = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url());
|
||||
return response.request().method() === "POST" && url.pathname === `/api/purchase-requests/${purchaseRequestId}/attachments`;
|
||||
});
|
||||
await page.getByTestId("purchase-attachment-file-input").setInputFiles({
|
||||
name: purchaseAttachmentName,
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from(`${trialrunMarkerPrefix} purchase attachment ${marker}\n`, "utf-8"),
|
||||
mimeType: "image/png",
|
||||
buffer: safePurchaseAttachmentPng,
|
||||
});
|
||||
const uploadAttachmentResponse = await uploadAttachmentResponsePromise;
|
||||
if (!uploadAttachmentResponse.ok()) {
|
||||
|
||||
@@ -45,6 +45,12 @@ const me = {
|
||||
const purchaseId = "purchase-r103";
|
||||
const reimbursementId = "11111111-1111-4111-8111-111111111111";
|
||||
const invoiceId = "invoice-r103";
|
||||
const safePurchaseAttachmentId = "attachment-safe-r103";
|
||||
const quarantinedPurchaseAttachmentId = "attachment-quarantined-r103";
|
||||
const safePngBuffer = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADUlEQVR4nGP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
function navigationItem(key: string, href: string, label: string, icon: string) {
|
||||
return { key, href, label, icon, placement: "main", permissionCodes: [], featureFlagKey: null };
|
||||
@@ -97,7 +103,48 @@ function purchaseDetail() {
|
||||
updatedAt: "2026-05-06T09:10:00.000Z",
|
||||
},
|
||||
],
|
||||
attachments: [],
|
||||
attachments: [
|
||||
{
|
||||
id: safePurchaseAttachmentId,
|
||||
originalName: "采购报价.png",
|
||||
mimeType: "image/png",
|
||||
fileSize: safePngBuffer.length,
|
||||
fileSha256: null,
|
||||
governanceStatus: "approved",
|
||||
sensitivityLevel: "internal",
|
||||
attachmentType: "quote",
|
||||
downloadPolicy: "reason_required",
|
||||
reviewedBy: null,
|
||||
reviewedAt: null,
|
||||
reviewNote: null,
|
||||
archivedBy: null,
|
||||
archivedAt: null,
|
||||
archiveNote: null,
|
||||
uploadedBy: me.id,
|
||||
createdAt: "2026-05-06T09:20:00.000Z",
|
||||
uploader: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
||||
},
|
||||
{
|
||||
id: quarantinedPurchaseAttachmentId,
|
||||
originalName: "历史隔离附件.pdf",
|
||||
mimeType: "application/pdf",
|
||||
fileSize: 128,
|
||||
fileSha256: null,
|
||||
governanceStatus: "quarantined",
|
||||
sensitivityLevel: "internal",
|
||||
attachmentType: "quote",
|
||||
downloadPolicy: "standard",
|
||||
reviewedBy: null,
|
||||
reviewedAt: null,
|
||||
reviewNote: null,
|
||||
archivedBy: null,
|
||||
archivedAt: null,
|
||||
archiveNote: null,
|
||||
uploadedBy: me.id,
|
||||
createdAt: "2026-05-06T09:15:00.000Z",
|
||||
uploader: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
||||
},
|
||||
],
|
||||
pendingWorkflow: null,
|
||||
archiveOrder: null,
|
||||
permissions: {
|
||||
@@ -300,12 +347,38 @@ async function mockApis(page: Page) {
|
||||
}
|
||||
|
||||
if (pathName === `/api/purchase-requests/${purchaseId}/attachments` && request.method() === "POST") {
|
||||
if (request.postDataBuffer()?.includes(Buffer.from('filename="unsupported.txt"'))) {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
requestId: "mock-r103-unsupported-attachment",
|
||||
error: {
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
details: { file: "unsupported_type" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, {
|
||||
attachment: {
|
||||
id: "attachment-r103",
|
||||
originalName: "采购报价.pdf",
|
||||
mimeType: "application/pdf",
|
||||
fileSize: 4,
|
||||
originalName: "采购报价.png",
|
||||
mimeType: "image/png",
|
||||
fileSize: safePngBuffer.length,
|
||||
fileSha256: null,
|
||||
governanceStatus: "approved",
|
||||
sensitivityLevel: "internal",
|
||||
attachmentType: "quote",
|
||||
downloadPolicy: "standard",
|
||||
reviewedBy: null,
|
||||
reviewedAt: null,
|
||||
reviewNote: null,
|
||||
archivedBy: null,
|
||||
archivedAt: null,
|
||||
archiveNote: null,
|
||||
uploadedBy: me.id,
|
||||
createdAt: "2026-05-06T09:20:00.000Z",
|
||||
uploader: { id: me.id, employeeNo: me.employeeNo, name: me.name },
|
||||
@@ -314,6 +387,25 @@ async function mockApis(page: Page) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
pathName === `/api/purchase-requests/${purchaseId}/attachments/${safePurchaseAttachmentId}/download` &&
|
||||
request.method() === "POST"
|
||||
) {
|
||||
expect(request.headers().authorization).toBe("Bearer mock-r103-token");
|
||||
expect(request.headers()["content-type"]).toContain("application/json");
|
||||
expect(request.postDataJSON()).toEqual({ reason: "采购复核" });
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/octet-stream",
|
||||
"content-disposition": 'attachment; filename="purchase-attachment.png"',
|
||||
"x-content-type-options": "nosniff",
|
||||
},
|
||||
body: safePngBuffer,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === "/api/invoice-pool") {
|
||||
await fulfillJson(route, { page: 1, pageSize: 20, total: 1, ownerScope: "own", items: [invoiceItem()] });
|
||||
return;
|
||||
@@ -342,7 +434,7 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
await login(page);
|
||||
});
|
||||
|
||||
test("采购页显示中文标题并使用中文附件上传控件", async ({ page }) => {
|
||||
test("采购页使用安全格式上传并通过认证 Blob 下载", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await page.goto("/purchase-requests");
|
||||
|
||||
@@ -361,14 +453,44 @@ test.describe("R103 cross page UI consistency", () => {
|
||||
await expect(page.getByTestId("purchase-requests-table")).toHaveCount(0);
|
||||
await expect(page.getByTestId("purchase-advanced-filters")).toHaveCount(0);
|
||||
await expect(page.getByTestId("purchase-attachment-upload")).toHaveText("上传附件");
|
||||
await expect(page.getByText("支持 PDF、JPG、PNG、WEBP,下载会留痕,OA 内不直接预览。")).toBeVisible();
|
||||
await expect(page.getByText("Choose File")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("purchase-attachment-file-input").setInputFiles({
|
||||
name: "采购报价.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: Buffer.from("r103"),
|
||||
const attachmentInput = page.getByTestId("purchase-attachment-file-input");
|
||||
await expect(attachmentInput).toHaveAttribute(
|
||||
"accept",
|
||||
".pdf,.jpg,.jpeg,.png,.webp,application/pdf,image/jpeg,image/png,image/webp",
|
||||
);
|
||||
await attachmentInput.setInputFiles({
|
||||
name: "采购报价.png",
|
||||
mimeType: "image/png",
|
||||
buffer: safePngBuffer,
|
||||
});
|
||||
await expect(page.getByTestId("purchase-attachment-selected-name")).toContainText("采购报价.pdf");
|
||||
await expect(page.getByTestId("purchase-attachment-selected-name")).toContainText("采购报价.png");
|
||||
|
||||
await attachmentInput.setInputFiles({
|
||||
name: "unsupported.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("synthetic unsupported attachment", "utf8"),
|
||||
});
|
||||
await expect(page.getByRole("main").getByText("仅支持 PDF、JPG、PNG、WEBP 文件,请重新选择。")).toBeVisible();
|
||||
|
||||
let popupOpened = false;
|
||||
page.on("popup", () => {
|
||||
popupOpened = true;
|
||||
});
|
||||
await page.getByTestId(`purchase-attachment-download-desktop-${safePurchaseAttachmentId}`).click();
|
||||
await expect(page.getByRole("heading", { name: "填写下载用途" })).toBeVisible();
|
||||
await page.getByRole("textbox", { name: "下载用途" }).fill("采购复核");
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "继续下载" }).click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe("采购报价.png");
|
||||
expect(popupOpened).toBe(false);
|
||||
|
||||
await expect(
|
||||
page.getByTestId(`purchase-attachment-download-desktop-${quarantinedPurchaseAttachmentId}`),
|
||||
).toBeDisabled();
|
||||
await page.screenshot({ path: screenshotPath("purchase-desktop.png"), fullPage: true });
|
||||
});
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户