修复采购附件同源 XSS(07-17-purchase-attachment-xss-hardening)
这个提交包含在:
@@ -0,0 +1,450 @@
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const sharp = require("sharp");
|
||||
|
||||
const PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS = 40_000_000;
|
||||
const PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS = 180;
|
||||
|
||||
const PDF_HEADER_PREFIX = Buffer.from("%PDF-", "ascii");
|
||||
const PDF_EOF_MARKER = Buffer.from("%%EOF", "ascii");
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const PNG_IEND_CHUNK = Buffer.from([
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
0x49, 0x45, 0x4e, 0x44,
|
||||
0xae, 0x42, 0x60, 0x82,
|
||||
]);
|
||||
|
||||
const CONTENT_TYPES = Object.freeze({
|
||||
pdf: Object.freeze({
|
||||
extension: ".pdf",
|
||||
mimeType: "application/pdf",
|
||||
declaredMimeTypes: new Set(["application/pdf"]),
|
||||
}),
|
||||
jpeg: Object.freeze({
|
||||
extension: ".jpg",
|
||||
mimeType: "image/jpeg",
|
||||
declaredMimeTypes: new Set(["image/jpeg", "image/jpg"]),
|
||||
}),
|
||||
png: Object.freeze({
|
||||
extension: ".png",
|
||||
mimeType: "image/png",
|
||||
declaredMimeTypes: new Set(["image/png"]),
|
||||
}),
|
||||
webp: Object.freeze({
|
||||
extension: ".webp",
|
||||
mimeType: "image/webp",
|
||||
declaredMimeTypes: new Set(["image/webp"]),
|
||||
}),
|
||||
});
|
||||
|
||||
const TYPE_BY_EXTENSION = new Map([
|
||||
[".pdf", "pdf"],
|
||||
[".jpg", "jpeg"],
|
||||
[".jpeg", "jpeg"],
|
||||
[".png", "png"],
|
||||
[".webp", "webp"],
|
||||
]);
|
||||
const TYPE_BY_DECLARED_MIME = new Map(
|
||||
Object.entries(CONTENT_TYPES).flatMap(([type, descriptor]) =>
|
||||
[...descriptor.declaredMimeTypes].map((mimeType) => [mimeType, type]),
|
||||
),
|
||||
);
|
||||
const CANONICAL_EXTENSIONS = new Set(
|
||||
Object.values(CONTENT_TYPES).map((descriptor) => descriptor.extension),
|
||||
);
|
||||
const DANGEROUS_INTERMEDIATE_EXTENSIONS = new Set([
|
||||
"asp",
|
||||
"aspx",
|
||||
"cjs",
|
||||
"css",
|
||||
"htm",
|
||||
"html",
|
||||
"js",
|
||||
"jsp",
|
||||
"mjs",
|
||||
"php",
|
||||
"phtml",
|
||||
"shtml",
|
||||
"svg",
|
||||
"wasm",
|
||||
"xht",
|
||||
"xhtml",
|
||||
"xml",
|
||||
]);
|
||||
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u;
|
||||
const PATH_SEPARATORS = /[\\/]/u;
|
||||
const BIDI_AND_INVISIBLE_CONTROLS = /[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/u;
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
|
||||
|
||||
class PurchaseAttachmentPolicyError extends Error {
|
||||
constructor(reasonCode) {
|
||||
super("purchase_attachment_rejected");
|
||||
this.name = "PurchaseAttachmentPolicyError";
|
||||
this.code = "PURCHASE_ATTACHMENT_POLICY_ERROR";
|
||||
this.reasonCode = reasonCode;
|
||||
this.details = { file: reasonCode };
|
||||
}
|
||||
}
|
||||
|
||||
function reject(reasonCode) {
|
||||
throw new PurchaseAttachmentPolicyError(reasonCode);
|
||||
}
|
||||
|
||||
function assertMaxBytes(maxBytes) {
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
||||
throw new TypeError("maxBytes must be a positive safe integer");
|
||||
}
|
||||
}
|
||||
|
||||
function truncateCodePoints(value, maxCodePoints) {
|
||||
const codePoints = Array.from(value);
|
||||
return codePoints.length <= maxCodePoints
|
||||
? value
|
||||
: codePoints.slice(0, maxCodePoints).join("");
|
||||
}
|
||||
|
||||
function normalizeAndValidateFileName(fileName) {
|
||||
if (typeof fileName !== "string") {
|
||||
reject("dangerous_filename");
|
||||
}
|
||||
|
||||
const normalized = fileName.normalize("NFC").trim();
|
||||
if (
|
||||
!normalized ||
|
||||
normalized === "." ||
|
||||
normalized === ".." ||
|
||||
CONTROL_CHARACTERS.test(normalized) ||
|
||||
PATH_SEPARATORS.test(normalized) ||
|
||||
BIDI_AND_INVISIBLE_CONTROLS.test(normalized)
|
||||
) {
|
||||
reject("dangerous_filename");
|
||||
}
|
||||
|
||||
const lastDotIndex = normalized.lastIndexOf(".");
|
||||
if (lastDotIndex <= 0 || lastDotIndex === normalized.length - 1) {
|
||||
reject("unsupported_type");
|
||||
}
|
||||
|
||||
const suppliedExtension = normalized.slice(lastDotIndex).toLowerCase();
|
||||
const expectedType = TYPE_BY_EXTENSION.get(suppliedExtension);
|
||||
if (!expectedType) {
|
||||
reject("unsupported_type");
|
||||
}
|
||||
|
||||
const stem = normalized.slice(0, lastDotIndex);
|
||||
const stemSegments = stem.split(".");
|
||||
if (
|
||||
stemSegments.some((segment) => !segment) ||
|
||||
stemSegments.slice(1).some((segment) =>
|
||||
DANGEROUS_INTERMEDIATE_EXTENSIONS.has(segment.toLowerCase()),
|
||||
)
|
||||
) {
|
||||
reject("dangerous_filename");
|
||||
}
|
||||
|
||||
const canonicalExtension = CONTENT_TYPES[expectedType].extension;
|
||||
const maxStemLength = Math.max(
|
||||
1,
|
||||
PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS - Array.from(canonicalExtension).length,
|
||||
);
|
||||
const truncatedStem = truncateCodePoints(stem, maxStemLength).trim();
|
||||
if (!truncatedStem) {
|
||||
reject("dangerous_filename");
|
||||
}
|
||||
|
||||
return {
|
||||
expectedType,
|
||||
originalName: `${truncatedStem}${canonicalExtension}`,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDeclaredMimeType(declaredMimeType) {
|
||||
if (declaredMimeType == null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof declaredMimeType !== "string") {
|
||||
reject("unsupported_type");
|
||||
}
|
||||
|
||||
const normalized = declaredMimeType.trim().toLowerCase();
|
||||
if (!normalized || normalized === "application/octet-stream") {
|
||||
return null;
|
||||
}
|
||||
if (!TYPE_BY_DECLARED_MIME.has(normalized)) {
|
||||
reject("unsupported_type");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function detectSignatureType(buffer) {
|
||||
if (buffer.length >= PDF_HEADER_PREFIX.length && buffer.subarray(0, PDF_HEADER_PREFIX.length).equals(PDF_HEADER_PREFIX)) {
|
||||
return "pdf";
|
||||
}
|
||||
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||
return "jpeg";
|
||||
}
|
||||
if (buffer.length >= PNG_SIGNATURE.length && buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
|
||||
return "png";
|
||||
}
|
||||
if (
|
||||
buffer.length >= 12 &&
|
||||
buffer.toString("ascii", 0, 4) === "RIFF" &&
|
||||
buffer.toString("ascii", 8, 12) === "WEBP"
|
||||
) {
|
||||
return "webp";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validatePdfBoundaries(buffer) {
|
||||
const header = buffer.toString("ascii", 0, Math.min(buffer.length, 16));
|
||||
if (!/^%PDF-(?:1\.[0-7]|2\.0)(?:\r\n|\r|\n)/u.test(header)) {
|
||||
reject("corrupt_content");
|
||||
}
|
||||
|
||||
let lastContentIndex = buffer.length - 1;
|
||||
while (
|
||||
lastContentIndex >= 0 &&
|
||||
[0x09, 0x0a, 0x0c, 0x0d, 0x20].includes(buffer[lastContentIndex])
|
||||
) {
|
||||
lastContentIndex -= 1;
|
||||
}
|
||||
const markerStart = lastContentIndex - PDF_EOF_MARKER.length + 1;
|
||||
if (
|
||||
markerStart < 0 ||
|
||||
!buffer.subarray(markerStart, lastContentIndex + 1).equals(PDF_EOF_MARKER)
|
||||
) {
|
||||
reject("corrupt_content");
|
||||
}
|
||||
}
|
||||
|
||||
function validateJpegBoundaries(buffer) {
|
||||
if (buffer.length < 4 || buffer[buffer.length - 2] !== 0xff || buffer[buffer.length - 1] !== 0xd9) {
|
||||
reject("corrupt_content");
|
||||
}
|
||||
}
|
||||
|
||||
function validatePngBoundaries(buffer) {
|
||||
if (
|
||||
buffer.length < PNG_SIGNATURE.length + PNG_IEND_CHUNK.length ||
|
||||
!buffer.subarray(buffer.length - PNG_IEND_CHUNK.length).equals(PNG_IEND_CHUNK)
|
||||
) {
|
||||
reject("corrupt_content");
|
||||
}
|
||||
|
||||
let offset = PNG_SIGNATURE.length;
|
||||
while (offset < buffer.length) {
|
||||
if (offset + 12 > buffer.length) {
|
||||
reject("corrupt_content");
|
||||
}
|
||||
const dataLength = buffer.readUInt32BE(offset);
|
||||
const chunkEnd = offset + 12 + dataLength;
|
||||
if (chunkEnd > buffer.length) {
|
||||
reject("corrupt_content");
|
||||
}
|
||||
const chunkType = buffer.toString("ascii", offset + 4, offset + 8);
|
||||
if (chunkType === "acTL") {
|
||||
reject("multiple_pages");
|
||||
}
|
||||
offset = chunkEnd;
|
||||
}
|
||||
}
|
||||
|
||||
function validateWebpBoundaries(buffer) {
|
||||
if (buffer.length < 12 || buffer.readUInt32LE(4) + 8 !== buffer.length) {
|
||||
reject("corrupt_content");
|
||||
}
|
||||
}
|
||||
|
||||
function validateFormatBoundaries(type, buffer) {
|
||||
if (type === "pdf") {
|
||||
validatePdfBoundaries(buffer);
|
||||
} else if (type === "jpeg") {
|
||||
validateJpegBoundaries(buffer);
|
||||
} else if (type === "png") {
|
||||
validatePngBoundaries(buffer);
|
||||
} else if (type === "webp") {
|
||||
validateWebpBoundaries(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
function mapSharpFailure(error) {
|
||||
const message = typeof error?.message === "string" ? error.message.toLowerCase() : "";
|
||||
reject(message.includes("pixel limit") ? "pixel_limit" : "corrupt_content");
|
||||
}
|
||||
|
||||
async function inspectImage(buffer, expectedType) {
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await sharp(buffer, {
|
||||
animated: true,
|
||||
failOn: "warning",
|
||||
limitInputPixels: PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS,
|
||||
sequentialRead: true,
|
||||
}).metadata();
|
||||
} catch (error) {
|
||||
mapSharpFailure(error);
|
||||
}
|
||||
|
||||
if (metadata.format !== expectedType || !metadata.width || !metadata.height) {
|
||||
reject(metadata.format ? "type_mismatch" : "corrupt_content");
|
||||
}
|
||||
|
||||
const pages = metadata.pages ?? 1;
|
||||
if (!Number.isSafeInteger(pages) || pages !== 1) {
|
||||
reject("multiple_pages");
|
||||
}
|
||||
|
||||
if (metadata.width * metadata.height > PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS) {
|
||||
reject("pixel_limit");
|
||||
}
|
||||
|
||||
let normalizedBuffer;
|
||||
try {
|
||||
const pipeline = sharp(buffer, {
|
||||
failOn: "warning",
|
||||
limitInputPixels: PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS,
|
||||
pages: 1,
|
||||
sequentialRead: true,
|
||||
}).rotate();
|
||||
|
||||
if (expectedType === "jpeg") {
|
||||
normalizedBuffer = await pipeline.jpeg({ quality: 90 }).toBuffer();
|
||||
} else if (expectedType === "png") {
|
||||
normalizedBuffer = await pipeline.png({ compressionLevel: 9, adaptiveFiltering: true }).toBuffer();
|
||||
} else {
|
||||
normalizedBuffer = await pipeline.webp({ quality: 90, effort: 4 }).toBuffer();
|
||||
}
|
||||
} catch (error) {
|
||||
mapSharpFailure(error);
|
||||
}
|
||||
|
||||
validateFormatBoundaries(expectedType, normalizedBuffer);
|
||||
return normalizedBuffer;
|
||||
}
|
||||
|
||||
async function validateAndNormalizePurchaseAttachment({
|
||||
buffer,
|
||||
fileName,
|
||||
declaredMimeType,
|
||||
maxBytes,
|
||||
normalizeImages = true,
|
||||
}) {
|
||||
assertMaxBytes(maxBytes);
|
||||
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
|
||||
reject("empty_file");
|
||||
}
|
||||
if (buffer.length > maxBytes) {
|
||||
reject("size_limit");
|
||||
}
|
||||
if (typeof normalizeImages !== "boolean") {
|
||||
throw new TypeError("normalizeImages must be a boolean");
|
||||
}
|
||||
|
||||
const { expectedType, originalName } = normalizeAndValidateFileName(fileName);
|
||||
const normalizedDeclaredMimeType = normalizeDeclaredMimeType(declaredMimeType);
|
||||
if (
|
||||
normalizedDeclaredMimeType &&
|
||||
TYPE_BY_DECLARED_MIME.get(normalizedDeclaredMimeType) !== expectedType
|
||||
) {
|
||||
reject("type_mismatch");
|
||||
}
|
||||
|
||||
const detectedType = detectSignatureType(buffer);
|
||||
if (!detectedType) {
|
||||
reject("unsupported_type");
|
||||
}
|
||||
if (detectedType !== expectedType) {
|
||||
reject("type_mismatch");
|
||||
}
|
||||
|
||||
validateFormatBoundaries(detectedType, buffer);
|
||||
|
||||
let normalizedBuffer = buffer;
|
||||
if (detectedType !== "pdf") {
|
||||
const inspectedBuffer = await inspectImage(buffer, detectedType);
|
||||
if (normalizeImages) {
|
||||
if (inspectedBuffer.length > maxBytes) {
|
||||
reject("size_limit");
|
||||
}
|
||||
normalizedBuffer = inspectedBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor = CONTENT_TYPES[detectedType];
|
||||
return {
|
||||
buffer: normalizedBuffer,
|
||||
originalName,
|
||||
extension: descriptor.extension,
|
||||
mimeType: descriptor.mimeType,
|
||||
fileSize: normalizedBuffer.length,
|
||||
fileSha256: crypto.createHash("sha256").update(normalizedBuffer).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
function buildAttachmentStorageKey(purchaseRequestId, extension) {
|
||||
if (typeof purchaseRequestId !== "string" || !UUID_PATTERN.test(purchaseRequestId)) {
|
||||
throw new TypeError("purchaseRequestId must be a UUID");
|
||||
}
|
||||
if (!CANONICAL_EXTENSIONS.has(extension)) {
|
||||
reject("unsupported_type");
|
||||
}
|
||||
return `purchase-requests/${purchaseRequestId.toLowerCase()}/${crypto.randomUUID()}${extension}`;
|
||||
}
|
||||
|
||||
function sanitizeHeaderFileName(fileName) {
|
||||
const source = typeof fileName === "string" && fileName.trim()
|
||||
? fileName.normalize("NFC").trim()
|
||||
: "attachment.bin";
|
||||
const sanitized = truncateCodePoints(
|
||||
source
|
||||
.replace(/[\\/]/gu, "_")
|
||||
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, "_")
|
||||
.replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/gu, "_")
|
||||
.replace(/"+/gu, "_")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim(),
|
||||
PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS,
|
||||
);
|
||||
return sanitized && sanitized !== "." && sanitized !== ".."
|
||||
? sanitized
|
||||
: "attachment.bin";
|
||||
}
|
||||
|
||||
function buildAsciiFallback(fileName) {
|
||||
const lastDotIndex = fileName.lastIndexOf(".");
|
||||
const rawExtension = lastDotIndex > 0 ? fileName.slice(lastDotIndex).toLowerCase() : ".bin";
|
||||
const extension = /^\.[a-z0-9]{1,10}$/u.test(rawExtension) ? rawExtension : ".bin";
|
||||
const rawStem = lastDotIndex > 0 ? fileName.slice(0, lastDotIndex) : fileName;
|
||||
const asciiStem = rawStem
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/gu, "")
|
||||
.replace(/[^a-zA-Z0-9._-]+/gu, "_")
|
||||
.replace(/_+/gu, "_")
|
||||
.replace(/^[._-]+|[._-]+$/gu, "")
|
||||
.slice(0, 100);
|
||||
return `${asciiStem || "attachment"}${extension}`;
|
||||
}
|
||||
|
||||
function encodeRfc5987Value(value) {
|
||||
return encodeURIComponent(value).replace(/[\u0027\u0028\u0029\u002a]/gu, (character) =>
|
||||
`%${character.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildAttachmentContentDisposition(fileName) {
|
||||
const safeFileName = sanitizeHeaderFileName(fileName);
|
||||
const asciiFallback = buildAsciiFallback(safeFileName);
|
||||
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodeRfc5987Value(safeFileName)}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CANONICAL_PURCHASE_ATTACHMENT_EXTENSIONS: Object.freeze([...CANONICAL_EXTENSIONS]),
|
||||
PURCHASE_ATTACHMENT_MAX_FILE_NAME_CODE_POINTS,
|
||||
PURCHASE_ATTACHMENT_MAX_IMAGE_PIXELS,
|
||||
PurchaseAttachmentPolicyError,
|
||||
buildAttachmentContentDisposition,
|
||||
buildAttachmentStorageKey,
|
||||
validateAndNormalizePurchaseAttachment,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户