From 69bffab74570e611bd86c56484767eed7523ee8b Mon Sep 17 00:00:00 2001 From: Zimeng Xiong Date: Sat, 22 Nov 2025 20:38:40 -0800 Subject: [PATCH] fix XSS and Root execution of NPM in docker --- backend/Dockerfile | 18 +- backend/docker-entrypoint.sh | 24 ++- backend/src/index.ts | 145 ++++++++++++-- backend/src/security.ts | 301 ++++++++++++++++++++++++++++++ backend/src/securityTest.ts | 210 +++++++++++++++++++++ frontend/src/utils/importUtils.ts | 5 +- 6 files changed, 685 insertions(+), 18 deletions(-) create mode 100644 backend/src/security.ts create mode 100644 backend/src/securityTest.ts diff --git a/backend/Dockerfile b/backend/Dockerfile index 3f0d30e..e5b7bca 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -25,8 +25,10 @@ RUN npx tsc # Production stage FROM node:20-alpine -# Install OpenSSL for Prisma -RUN apk add --no-cache openssl +# Install OpenSSL for Prisma and create non-root user +RUN apk add --no-cache openssl && \ + addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 WORKDIR /app @@ -49,9 +51,17 @@ COPY --from=builder /app/src/generated ./dist/generated # Generate Prisma Client in production (updates node_modules) RUN npx prisma generate -# Run migrations and start server +# Create necessary directories and set proper ownership +RUN mkdir -p /app/uploads /app/prisma && \ + chown -R nodejs:nodejs /app + +# Copy and set permissions for entrypoint script COPY docker-entrypoint.sh ./ -RUN chmod +x docker-entrypoint.sh +RUN chmod +x docker-entrypoint.sh && \ + chown nodejs:nodejs docker-entrypoint.sh + +# Switch to non-root user +USER nodejs EXPOSE 8000 diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh index 41ca11c..16bfc9d 100644 --- a/backend/docker-entrypoint.sh +++ b/backend/docker-entrypoint.sh @@ -7,8 +7,30 @@ if [ ! -f "/app/prisma/schema.prisma" ]; then cp -R /app/prisma_template/. /app/prisma/ fi -# Run migrations +# Ensure proper ownership and permissions for data directories +echo "Setting up data directory permissions..." +mkdir -p /app/uploads +mkdir -p /app/prisma + +# Set ownership to the node user (UID 1000) +if [ "$(id -u)" = "0" ]; then + # If running as root (for some reason), fix ownership + chown -R nodejs:nodejs /app/uploads + chown -R nodejs:nodejs /app/prisma +fi + +# Ensure database file has proper permissions +if [ -f "/app/prisma/dev.db" ]; then + chmod 664 /app/prisma/dev.db 2>/dev/null || true +fi + +# Set appropriate permissions for uploads directory +chmod 755 /app/uploads + +# Run migrations as the current user +echo "Running database migrations..." npx prisma migrate deploy # Start the application +echo "Starting application as user $(whoami) (UID: $(id -u))" node dist/index.js diff --git a/backend/src/index.ts b/backend/src/index.ts index fa47cc0..48e5c7e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -11,6 +11,14 @@ import Database from "better-sqlite3"; import { z } from "zod"; // @ts-ignore import { PrismaClient } from "./generated/client"; +import { + sanitizeDrawingData, + validateImportedDrawing, + sanitizeText, + sanitizeSvg, + elementSchema, + appStateSchema, +} from "./security"; dotenv.config(); @@ -88,9 +96,57 @@ app.use( app.use(express.json({ limit: "50mb" })); app.use(express.urlencoded({ extended: true, limit: "50mb" })); -const elementsSchema = z.array(z.object({}).passthrough()); +// Security middleware - Add security headers +app.use((req, res, next) => { + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("X-Frame-Options", "DENY"); + res.setHeader("X-XSS-Protection", "1; mode=block"); + res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); + res.setHeader( + "Permissions-Policy", + "geolocation=(), microphone=(), camera=()" + ); -const appStateSchema = z.object({}).passthrough(); + // Content Security Policy - restrict sources + res.setHeader( + "Content-Security-Policy", + "default-src 'self'; " + + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com; " + + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + + "font-src 'self' https://fonts.gstatic.com; " + + "img-src 'self' data: blob: https:; " + + "connect-src 'self' ws: wss:; " + + "frame-ancestors 'none';" + ); + + next(); +}); + +// Rate limiting middleware (basic implementation) +const requestCounts = new Map(); +const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes +const RATE_LIMIT_MAX_REQUESTS = 1000; // Max requests per window + +app.use((req, res, next) => { + const ip = req.ip || req.connection.remoteAddress || "unknown"; + const now = Date.now(); + const clientData = requestCounts.get(ip); + + if (!clientData || now > clientData.resetTime) { + requestCounts.set(ip, { count: 1, resetTime: now + RATE_LIMIT_WINDOW }); + return next(); + } + + if (clientData.count >= RATE_LIMIT_MAX_REQUESTS) { + return res.status(429).json({ + error: "Rate limit exceeded", + message: "Too many requests, please try again later", + }); + } + + clientData.count++; + next(); +}); const filesFieldSchema = z .union([z.record(z.string(), z.any()), z.null()]) @@ -103,17 +159,70 @@ const drawingBaseSchema = z.object({ preview: z.string().nullable().optional(), }); -const drawingCreateSchema = drawingBaseSchema.extend({ - elements: elementsSchema.default([]), - appState: appStateSchema.default({}), - files: filesFieldSchema, -}); +// Use strict schemas from security module with sanitization +const drawingCreateSchema = drawingBaseSchema + .extend({ + elements: elementSchema.array().default([]), + appState: appStateSchema.default({}), + files: filesFieldSchema, + }) + .refine( + (data) => { + // Apply sanitization before database persistence + try { + const sanitized = sanitizeDrawingData(data); + // Merge sanitized data back with original properties + Object.assign(data, sanitized); + return true; + } catch (error) { + console.error("Sanitization failed:", error); + return false; + } + }, + { + message: "Invalid or malicious drawing data detected", + } + ); -const drawingUpdateSchema = drawingBaseSchema.extend({ - elements: elementsSchema.optional(), - appState: appStateSchema.optional(), - files: filesFieldSchema, -}); +const drawingUpdateSchema = drawingBaseSchema + .extend({ + elements: elementSchema.array().optional(), + appState: appStateSchema.optional(), + files: filesFieldSchema, + }) + .refine( + (data) => { + // Apply sanitization before database persistence + try { + // Only sanitize provided fields + const sanitizedData = { ...data }; + if (data.elements !== undefined || data.appState !== undefined) { + const fullData = { + elements: data.elements || [], + appState: data.appState || {}, + files: data.files, + preview: data.preview, + name: data.name, + collectionId: data.collectionId, + }; + const sanitized = sanitizeDrawingData(fullData); + sanitizedData.elements = sanitized.elements; + sanitizedData.appState = sanitized.appState; + if (data.files !== undefined) sanitizedData.files = sanitized.files; + if (data.preview !== undefined) + sanitizedData.preview = sanitized.preview; + Object.assign(data, sanitizedData); + } + return true; + } catch (error) { + console.error("Sanitization failed:", error); + return false; + } + }, + { + message: "Invalid or malicious drawing data detected", + } + ); const respondWithValidationErrors = ( res: express.Response, @@ -312,6 +421,17 @@ app.get("/drawings/:id", async (req, res) => { // POST /drawings app.post("/drawings", async (req, res) => { try { + // Additional security validation for imported data + const isImportedDrawing = req.headers["x-imported-file"] === "true"; + + if (isImportedDrawing && !validateImportedDrawing(req.body)) { + return res.status(400).json({ + error: "Invalid imported drawing file", + message: + "The imported file contains potentially malicious content or invalid structure", + }); + } + const parsed = drawingCreateSchema.safeParse(req.body); if (!parsed.success) { return respondWithValidationErrors(res, parsed.error.issues); @@ -340,6 +460,7 @@ app.post("/drawings", async (req, res) => { files: JSON.parse(newDrawing.files || "{}"), }); } catch (error) { + console.error("Failed to create drawing:", error); res.status(500).json({ error: "Failed to create drawing" }); } }); diff --git a/backend/src/security.ts b/backend/src/security.ts new file mode 100644 index 0000000..4953f03 --- /dev/null +++ b/backend/src/security.ts @@ -0,0 +1,301 @@ +/** + * Security utilities for XSS prevention and data sanitization + */ + +import { z } from "zod"; + +/** + * Sanitize HTML/JS content by removing dangerous patterns + */ +export const sanitizeHtml = (input: string): string => { + if (typeof input !== "string") return ""; + + return input + .replace(/)<[^<]*)*<\/script>/gi, "") // Remove script tags + .replace(/javascript:/gi, "") // Remove javascript: URIs + .replace(/on\w+\s*=\s*["'][^"']*["']/gi, "") // Remove event handlers + .replace(/)<[^<]*)*<\/iframe>/gi, "") // Remove iframes + .replace(/)<[^<]*)*<\/object>/gi, "") // Remove objects + .replace(/)<[^<]*)*<\/embed>/gi, "") // Remove embeds + .replace(/]*>/gi, "") // Remove link tags + .replace(/)<[^<]*)*<\/style>/gi, "") // Remove style tags + .trim(); +}; + +/** + * Sanitize SVG content specifically + */ +export const sanitizeSvg = (svgContent: string): string => { + if (typeof svgContent !== "string") return ""; + + // Remove potentially dangerous SVG elements and attributes + return svgContent + .replace(/)<[^<]*)*<\/script>/gi, "") + .replace(/javascript:/gi, "") + .replace(/on\w+\s*=\s*["'][^"']*["']/gi, "") + .replace( + /)<[^<]*)*<\/foreignObject>/gi, + "" + ) + .replace(/\shref\s*=\s*["'][^"']*(?:javascript:)[^"']*["']/gi, ' href="#"') + .replace( + /\sxlink:href\s*=\s*["'][^"']*(?:javascript:)[^"']*["']/gi, + ' xlink:href="#"' + ) + .trim(); +}; + +/** + * Validate and sanitize text content + */ +export const sanitizeText = ( + input: unknown, + maxLength: number = 1000 +): string => { + if (typeof input !== "string") return ""; + + // Remove null bytes and control characters except newlines and tabs + const cleaned = input.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); + + // Truncate if too long + const truncated = cleaned.slice(0, maxLength); + + // Final HTML sanitization + return sanitizeHtml(truncated); +}; + +/** + * Sanitize URL to prevent javascript: and data: attacks + */ +export const sanitizeUrl = (url: unknown): string => { + if (typeof url !== "string") return ""; + + const trimmed = url.trim(); + + // Block javascript:, data:, vbscript: URLs + if (/^(javascript|data|vbscript):/i.test(trimmed)) { + return ""; + } + + // Basic URL validation + try { + // Allow http, https, mailto, and relative URLs + if (/^(https?:\/\/|mailto:|\/|\.\/|\.\.\/)/i.test(trimmed)) { + return trimmed; + } + return ""; + } catch { + return ""; + } +}; + +/** + * Strict Zod schema for Excalidraw elements with validation + */ +export const elementSchema = z + .object({ + id: z.string().min(1).max(100), + type: z.enum([ + "rectangle", + "ellipse", + "diamond", + "arrow", + "line", + "text", + "image", + "frame", + "embed", + "selection", + "text-container", + ]), + x: z.number().finite().min(-100000).max(100000), + y: z.number().finite().min(-100000).max(100000), + width: z.number().finite().min(0).max(100000), + height: z.number().finite().min(0).max(100000), + angle: z + .number() + .finite() + .min(-2 * Math.PI) + .max(2 * Math.PI), + strokeColor: z.string().optional(), + backgroundColor: z.string().optional(), + fillStyle: z.enum(["solid", "hachure", "cross-hatch", "dots"]).optional(), + strokeWidth: z.number().finite().min(0).max(10).optional(), + strokeStyle: z.enum(["solid", "dashed", "dotted"]).optional(), + roundness: z + .object({ + type: z.enum(["round", "sharp"]), + value: z.number().finite().min(0).max(1), + }) + .optional(), + boundElements: z + .array( + z.object({ + id: z.string(), + type: z.string(), + }) + ) + .optional(), + groupIds: z.array(z.string()).optional(), + frameId: z.string().optional(), + seed: z.number().finite().optional(), + version: z.number().finite().min(0).max(100000), + versionNonce: z.number().finite().min(0).max(100000), + isDeleted: z.boolean().optional(), + opacity: z.number().finite().min(0).max(1).optional(), + link: z.string().optional().transform(sanitizeUrl), + locked: z.boolean().optional(), + // Text-specific properties + text: z + .string() + .optional() + .transform((val) => sanitizeText(val, 5000)), + fontSize: z.number().finite().min(1).max(200).optional(), + fontFamily: z.number().finite().min(1).max(5).optional(), + textAlign: z.enum(["left", "center", "right"]).optional(), + verticalAlign: z.enum(["top", "middle", "bottom"]).optional(), + // Custom properties - whitelist only known safe properties + customData: z.record(z.string(), z.any()).optional(), + }) + .strict(); + +/** + * Strict Zod schema for Excalidraw app state with validation + */ +export const appStateSchema = z + .object({ + gridSize: z.number().finite().min(0).max(100).optional(), + gridStep: z.number().finite().min(1).max(100).optional(), + viewBackgroundColor: z.string().optional(), + currentItemStrokeColor: z.string().optional(), + currentItemBackgroundColor: z.string().optional(), + currentItemFillStyle: z + .enum(["solid", "hachure", "cross-hatch", "dots"]) + .optional(), + currentItemStrokeWidth: z.number().finite().min(0).max(10).optional(), + currentItemStrokeStyle: z.enum(["solid", "dashed", "dotted"]).optional(), + currentItemRoundness: z + .object({ + type: z.enum(["round", "sharp"]), + value: z.number().finite().min(0).max(1), + }) + .optional(), + currentItemFontSize: z.number().finite().min(1).max(200).optional(), + currentItemFontFamily: z.number().finite().min(1).max(5).optional(), + currentItemTextAlign: z.enum(["left", "center", "right"]).optional(), + currentItemVerticalAlign: z.enum(["top", "middle", "bottom"]).optional(), + scrollX: z.number().finite().min(-1000000).max(1000000).optional(), + scrollY: z.number().finite().min(-1000000).max(1000000).optional(), + zoom: z + .object({ + value: z.number().finite().min(0.1).max(10), + }) + .optional(), + selection: z.array(z.string()).optional(), + selectedElementIds: z.record(z.string(), z.boolean()).optional(), + selectedGroupIds: z.record(z.string(), z.boolean()).optional(), + activeEmbeddable: z + .object({ + elementId: z.string(), + state: z.string(), + }) + .optional(), + activeTool: z + .object({ + type: z.string(), + customType: z.string().optional(), + }) + .optional(), + cursorX: z.number().finite().optional(), + cursorY: z.number().finite().optional(), + // Sanitize any string values in appState + }) + .strict() + .catchall( + z.any().refine((val) => { + // Recursively sanitize any string values found in the object + if (typeof val === "string") { + return sanitizeText(val, 1000); + } + return true; + }) + ); + +/** + * Sanitize drawing data before persistence + */ +export const sanitizeDrawingData = (data: { + elements: any[]; + appState: any; + files?: any; + preview?: string | null; +}) => { + try { + // Validate and sanitize elements + const sanitizedElements = elementSchema.array().parse(data.elements); + + // Validate and sanitize app state + const sanitizedAppState = appStateSchema.parse(data.appState); + + // Sanitize preview SVG if present + let sanitizedPreview = data.preview; + if (typeof sanitizedPreview === "string") { + sanitizedPreview = sanitizeSvg(sanitizedPreview); + } + + // Sanitize files object + let sanitizedFiles = data.files; + if (typeof sanitizedFiles === "object" && sanitizedFiles !== null) { + // Recursively sanitize any string values in files + sanitizedFiles = JSON.parse( + JSON.stringify(sanitizedFiles, (key, value) => { + if (typeof value === "string") { + return sanitizeText(value, 10000); + } + return value; + }) + ); + } + + return { + elements: sanitizedElements, + appState: sanitizedAppState, + files: sanitizedFiles, + preview: sanitizedPreview, + }; + } catch (error) { + console.error("Data sanitization failed:", error); + throw new Error("Invalid or malicious drawing data detected"); + } +}; + +/** + * Validate imported .excalidraw file structure + */ +export const validateImportedDrawing = (data: any): boolean => { + try { + // Basic structure validation + if (!data || typeof data !== "object") return false; + + if (!Array.isArray(data.elements)) return false; + if (typeof data.appState !== "object") return false; + + // Check element count to prevent DoS + if (data.elements.length > 10000) { + throw new Error("Drawing contains too many elements (max 10,000)"); + } + + // Sanitize and validate the data + const sanitized = sanitizeDrawingData(data); + + // Additional structural validation + if (sanitized.elements.length !== data.elements.length) { + throw new Error("Element count mismatch after sanitization"); + } + + return true; + } catch (error) { + console.error("Imported drawing validation failed:", error); + return false; + } +}; diff --git a/backend/src/securityTest.ts b/backend/src/securityTest.ts new file mode 100644 index 0000000..8748aeb --- /dev/null +++ b/backend/src/securityTest.ts @@ -0,0 +1,210 @@ +/** + * Security Test Suite for XSS Prevention + * Tests malicious payload detection and sanitization + */ + +import { + sanitizeHtml, + sanitizeSvg, + sanitizeText, + sanitizeUrl, + validateImportedDrawing, + sanitizeDrawingData, +} from "./security"; + +console.log("๐Ÿงช Starting Security Test Suite...\n"); + +// Test 1: HTML/JS Sanitization +console.log("Test 1: HTML/JS Sanitization"); +const maliciousHtml = ` + + + + + + Normal text content +`; +const sanitizedHtml = sanitizeHtml(maliciousHtml); +console.log("โœ… Original:", maliciousHtml.substring(0, 100) + "..."); +console.log("โœ… Sanitized:", sanitizedHtml.substring(0, 100) + "..."); +console.log("โœ… Script tags removed:", !sanitizedHtml.includes(" + + + + + +`; +const sanitizedSvg = sanitizeSvg(maliciousSvg); +console.log("โœ… Original:", maliciousSvg.substring(0, 100) + "..."); +console.log("โœ… Sanitized:", sanitizedSvg.substring(0, 100) + "..."); +console.log("โœ… SVG scripts removed:", !sanitizedSvg.includes("", + "vbscript:msgbox('XSS')", + "https://example.com", + "/relative/path", + "./current/path", + "../parent/path", + "mailto:test@example.com", +]; + +maliciousUrls.forEach((url) => { + const sanitized = sanitizeUrl(url); + const isSafe = sanitized !== ""; + console.log(`โœ… "${url}" -> "${sanitized}" (${isSafe ? "SAFE" : "BLOCKED"})`); +}); +console.log(""); + +// Test 4: Text Sanitization with Length Limits +console.log("Test 4: Text Sanitization with Length Limits"); +const longText = "A".repeat(2000); +const sanitizedLongText = sanitizeText(longText, 500); +console.log( + `โœ… Long text truncated: ${longText.length} -> ${sanitizedLongText.length} chars` +); + +const maliciousText = "Normal text"; +const sanitizedText = sanitizeText(maliciousText); +console.log(`โœ… Text sanitized: "${maliciousText}" -> "${sanitizedText}"`); +console.log( + "โœ… Malicious content removed:", + !sanitizedText.includes("Malicious text", + }, + { + id: "test2", + type: "rectangle", + x: 10, + y: 10, + width: 100, + height: 100, + angle: 0, + version: 1, + versionNonce: 1, + link: "javascript:alert('XSS')", + }, + ], + appState: { + viewBackgroundColor: "", + }, + files: null, + preview: '', +}; + +console.log("Testing malicious drawing validation..."); +const isValidDrawing = validateImportedDrawing(maliciousDrawing); +console.log(`โœ… Malicious drawing rejected: ${!isValidDrawing}`); + +try { + const sanitizedDrawing = sanitizeDrawingData(maliciousDrawing); + console.log("โœ… Sanitization successful"); + console.log(`โœ… Text sanitized: ${sanitizedDrawing.elements[0].text}`); + console.log( + `โœ… Link sanitized: ${sanitizedDrawing.elements[1].link || "null"}` + ); + console.log( + `โœ… SVG sanitized: ${!sanitizedDrawing.preview?.includes("