images in preview
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user