images in preview

This commit is contained in:
Zimeng Xiong
2026-02-07 17:21:58 -08:00
parent 2aa749a2f0
commit 35bbbb9599
15 changed files with 654 additions and 77 deletions
@@ -267,6 +267,90 @@ describe("Security Sanitization - Image Data URLs", () => {
});
});
describe("sanitizeDrawingData - preview svg handling", () => {
it("should preserve safe SVG layout attributes needed for thumbnail rendering", () => {
const preview = [
'<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 728.39453125 606.908203125" width="1456.7890625" height="1213.81640625" preserveAspectRatio="xMidYMid meet">',
'<rect x="0" y="0" width="728.39453125" height="606.908203125" fill="#ffffff"></rect>',
'<path d="M0 0 L20 20" stroke="#000" stroke-linecap="round"></path>',
"</svg>",
].join("");
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: {},
preview,
});
expect(result.preview).toContain('viewBox="0 0 728.39453125 606.908203125"');
expect(result.preview).toContain('preserveAspectRatio="xMidYMid meet"');
expect(result.preview).toContain('stroke-linecap="round"');
expect(result.preview).toContain('xmlns="http://www.w3.org/2000/svg"');
});
it("should preserve safe embedded image previews", () => {
const preview = [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">',
'<image x="0" y="0" width="40" height="40" href="data:image/png;base64,AAAA"></image>',
"</svg>",
].join("");
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: {},
preview,
});
expect(result.preview).toContain("<image");
expect(result.preview).toContain('href="data:image/png;base64,AAAA"');
});
it("should remove embedded images with unsafe href values", () => {
const preview = [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">',
'<image x="0" y="0" width="40" height="40" href="javascript:alert(1)"></image>',
'<rect x="0" y="0" width="10" height="10" fill="#000"></rect>',
"</svg>",
].join("");
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: {},
preview,
});
expect(result.preview).not.toContain("<image");
expect(result.preview).toContain("<rect");
});
it("should preserve safe defs/pattern image structures used by Excalidraw exports", () => {
const preview = [
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">',
'<defs><pattern id="p1" width="1" height="1" patternUnits="objectBoundingBox">',
'<image href="data:image/png;base64,AAAA" width="100" height="100"></image>',
"</pattern></defs>",
'<rect x="0" y="0" width="100" height="100" fill="url(#p1)"></rect>',
"</svg>",
].join("");
const result = sanitizeDrawingData({
elements: [],
appState: { viewBackgroundColor: "#ffffff" },
files: {},
preview,
});
expect(result.preview).toContain("<defs>");
expect(result.preview).toContain("<pattern");
expect(result.preview).toContain('id="p1"');
expect(result.preview).toContain("<image");
expect(result.preview).toContain('fill="url(#p1)"');
});
});
describe("validateImportedDrawing - with files", () => {
it("should validate drawing with embedded images", () => {
const files = createSampleFilesObject(2, "large");
+21 -9
View File
@@ -267,10 +267,15 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
expiresAt,
},
});
} catch {
if (process.env.NODE_ENV === "development") {
console.debug("Refresh token storage skipped (feature disabled or table missing)");
} catch (error) {
if (isMissingRefreshTokenTableError(error)) {
console.error("Refresh token rotation is enabled but refresh token storage is unavailable");
return res.status(503).json({
error: "Service unavailable",
message: "Refresh token storage is unavailable. Please run database migrations.",
});
}
throw error;
}
}
@@ -380,10 +385,15 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
expiresAt,
},
});
} catch {
if (process.env.NODE_ENV === "development") {
console.debug("Refresh token rotation skipped (feature disabled or table missing)");
} catch (error) {
if (isMissingRefreshTokenTableError(error)) {
console.error("Refresh token rotation is enabled but refresh token storage is unavailable");
return res.status(503).json({
error: "Service unavailable",
message: "Refresh token storage is unavailable. Please run database migrations.",
});
}
throw error;
}
}
@@ -514,9 +524,11 @@ export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => {
}
if (isMissingRefreshTokenTableError(error)) {
if (process.env.NODE_ENV === "development") {
console.debug("Refresh token rotation skipped (feature disabled or table missing)");
}
console.error("Refresh token rotation is enabled but refresh token storage is unavailable");
return res.status(503).json({
error: "Service unavailable",
message: "Refresh token storage is unavailable. Please run database migrations.",
});
} else {
console.error("Refresh token rotation error:", error);
return res.status(500).json({
+2 -2
View File
@@ -707,8 +707,8 @@ export const sanitizeDrawingUpdateData = (
collectionId: data.collectionId,
};
const sanitized = sanitizeDrawingData(fullData);
sanitizedData.elements = sanitized.elements;
sanitizedData.appState = sanitized.appState;
if (data.elements !== undefined) sanitizedData.elements = sanitized.elements;
if (data.appState !== undefined) sanitizedData.appState = sanitized.appState;
if (data.files !== undefined) sanitizedData.files = sanitized.files;
if (data.preview !== undefined) sanitizedData.preview = sanitized.preview;
Object.assign(data, sanitizedData);
+3 -16
View File
@@ -53,23 +53,10 @@ const getAuthEnabled = async (): Promise<boolean> => {
};
const getBootstrapActingUser = async () => {
const user = await prisma.user.findUnique({
return prisma.user.upsert({
where: { id: BOOTSTRAP_USER_ID },
select: {
id: true,
username: true,
email: true,
name: true,
role: true,
mustResetPassword: true,
isActive: true,
},
});
if (user) return user;
return prisma.user.create({
data: {
update: {},
create: {
id: BOOTSTRAP_USER_ID,
email: "bootstrap@excalidash.local",
username: null,
+58 -6
View File
@@ -101,11 +101,45 @@ export const sanitizeHtml = (input: string): string => {
export const sanitizeSvg = (svgContent: string): string => {
if (typeof svgContent !== "string") return "";
return purify
const safeImageDataUrlPattern =
/^data:image\/(?:png|jpe?g|gif|webp|avif|bmp);base64,[a-z0-9+/=\s]+$/i;
const sanitizeSvgImageTags = (content: string): string =>
content.replace(/<image\b[^>]*>/gi, (imageTag) => {
const hrefMatch =
imageTag.match(/\shref\s*=\s*"([^"]*)"/i) ??
imageTag.match(/\shref\s*=\s*'([^']*)'/i) ??
imageTag.match(/\sxlink:href\s*=\s*"([^"]*)"/i) ??
imageTag.match(/\sxlink:href\s*=\s*'([^']*)'/i);
const hrefValue = hrefMatch?.[1]?.trim();
if (!hrefValue || !safeImageDataUrlPattern.test(hrefValue)) {
return "";
}
const withoutXlinkHref = imageTag.replace(
/\sxlink:href\s*=\s*(?:"[^"]*"|'[^']*')/gi,
""
);
if (/\shref\s*=/i.test(withoutXlinkHref)) {
return withoutXlinkHref.replace(
/\shref\s*=\s*(?:"[^"]*"|'[^']*')/i,
` href="${hrefValue}"`
);
}
return withoutXlinkHref.replace(/<image\b/i, `<image href="${hrefValue}"`);
});
const sanitized = purify
.sanitize(svgContent, {
ALLOWED_TAGS: [
"svg",
"defs",
"pattern",
"g",
"image",
"rect",
"circle",
"ellipse",
@@ -117,6 +151,12 @@ export const sanitizeSvg = (svgContent: string): string => {
"tspan",
],
ALLOWED_ATTR: [
"xmlns",
"xmlns:xlink",
"version",
"id",
"viewBox",
"preserveAspectRatio",
"x",
"y",
"width",
@@ -133,14 +173,29 @@ export const sanitizeSvg = (svgContent: string): string => {
"points",
"d",
"fill",
"fill-opacity",
"fill-rule",
"stroke",
"stroke-width",
"stroke-opacity",
"stroke-linecap",
"stroke-linejoin",
"stroke-miterlimit",
"stroke-dasharray",
"stroke-dashoffset",
"opacity",
"transform",
"vector-effect",
"patternUnits",
"patternContentUnits",
"font-size",
"font-family",
"font-weight",
"letter-spacing",
"text-anchor",
"dominant-baseline",
"href",
"xlink:href",
],
FORBID_TAGS: [
"script",
@@ -149,10 +204,8 @@ export const sanitizeSvg = (svgContent: string): string => {
"object",
"embed",
"use",
"image",
"style",
"link",
"defs",
"symbol",
"marker",
"clipPath",
@@ -166,17 +219,16 @@ export const sanitizeSvg = (svgContent: string): string => {
"onmouseover",
"onfocus",
"onblur",
"href",
"xlink:href",
"src",
"action",
"style",
"class",
"id",
],
KEEP_CONTENT: true,
})
.trim();
return sanitizeSvgImageTags(sanitized).trim();
};
export const sanitizeText = (