Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fe136ae5a |
@@ -1,9 +1,8 @@
|
|||||||
<img src="logoExcaliDash.png" alt="ExcaliDash Logo" width="80" height="88">
|
<img src="logoExcaliDash.png" alt="ExcaliDash Logo" width="80" height="88">
|
||||||
|
|
||||||
# ExcaliDash v0.1.6
|
# ExcaliDash v0.1.0
|
||||||
|
|
||||||

|
[](LICENSE)
|
||||||

|
|
||||||
[](https://hub.docker.com)
|
[](https://hub.docker.com)
|
||||||
|
|
||||||
A self-hosted dashboard and organizer for [Excalidraw](https://github.com/excalidraw/excalidraw) with live collaboration features.
|
A self-hosted dashboard and organizer for [Excalidraw](https://github.com/excalidraw/excalidraw) with live collaboration features.
|
||||||
@@ -75,10 +74,7 @@ See [release notes](https://github.com/ZimengXiong/ExcaliDash/releases) for a sp
|
|||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> NOT for production use. While attempts have been made at hardening (XSS/dompurify, CORS, rate-limiting, sanitization), they are inadequate for public deployment. Do not expose any ports. Currently lacking CSRF.
|
> NOT for production use. This is just a side project (and also the first release), and it likely contains some bugs. DO NOT open ports to the internet (e.g. CORS is set to allow all)
|
||||||
|
|
||||||
> [!CAUTION]
|
|
||||||
> ExcaliDash is in BETA. Please backup your data regularly (e.g. with cron).
|
|
||||||
|
|
||||||
## Docker Hub (Recommended)
|
## Docker Hub (Recommended)
|
||||||
|
|
||||||
|
|||||||
-30
@@ -1,30 +0,0 @@
|
|||||||
# ExcaliDash v0.1.5
|
|
||||||
|
|
||||||
Date: 2025-11-23
|
|
||||||
|
|
||||||
Compatibility: v0.1.x (Backward Compatible)
|
|
||||||
|
|
||||||
# Security
|
|
||||||
|
|
||||||
- RCE: implemented strict Zod schema validation and input sanitization on file uploads; added path traversal guards to file handling logic
|
|
||||||
|
|
||||||
- XSS: used DOMPurify for HTML sanitization; blocked execution-capable SVG attributes and enforces CSP headers.
|
|
||||||
|
|
||||||
- DoS: moved CPU-intensive operations to worker threads to prevent event loop blocking; request rate limiting (1,000 req/15 min per IP) and streaming for large files
|
|
||||||
|
|
||||||
# Infras & Deployment
|
|
||||||
|
|
||||||
- non-root execution (uid 1001) in containers
|
|
||||||
- migrated to multi-stage Docker builds
|
|
||||||
|
|
||||||
# Database
|
|
||||||
|
|
||||||
- migrated to better-sqlite3, converted all DB interactions to non-blocking async operations and offloaded integrity checks to worker threads.
|
|
||||||
|
|
||||||
- implemented SQLite magic header validation; added automatic backup triggers preceding data import
|
|
||||||
|
|
||||||
- input validation logic
|
|
||||||
|
|
||||||
# Frontend
|
|
||||||
|
|
||||||
- updated Settings UI to show version
|
|
||||||
@@ -2,4 +2,3 @@
|
|||||||
PORT=8000
|
PORT=8000
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
DATABASE_URL=file:/app/prisma/dev.db
|
DATABASE_URL=file:/app/prisma/dev.db
|
||||||
FRONTEND_URL=http://localhost:6767
|
|
||||||
+4
-14
@@ -8,10 +8,7 @@ COPY package*.json ./
|
|||||||
COPY tsconfig.json ./
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
# Install build deps required for compiling native modules like better-sqlite3
|
RUN npm ci
|
||||||
RUN apk add --no-cache python3 make g++ build-base sqlite-dev && \
|
|
||||||
npm ci
|
|
||||||
ENV PYTHON=/usr/bin/python3
|
|
||||||
|
|
||||||
# Copy prisma schema
|
# Copy prisma schema
|
||||||
COPY prisma ./prisma/
|
COPY prisma ./prisma/
|
||||||
@@ -28,10 +25,8 @@ RUN npx tsc
|
|||||||
# Production stage
|
# Production stage
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
# Install OpenSSL for Prisma and su-exec, create non-root user
|
# Install OpenSSL for Prisma
|
||||||
RUN apk add --no-cache openssl su-exec sqlite-libs && \
|
RUN apk add --no-cache openssl
|
||||||
addgroup -g 1001 -S nodejs && \
|
|
||||||
adduser -S nodejs -u 1001
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -54,15 +49,10 @@ COPY --from=builder /app/src/generated ./dist/generated
|
|||||||
# Generate Prisma Client in production (updates node_modules)
|
# Generate Prisma Client in production (updates node_modules)
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Create necessary directories (ownership will be set in entrypoint)
|
# Run migrations and start server
|
||||||
RUN mkdir -p /app/uploads /app/prisma
|
|
||||||
|
|
||||||
# Copy and set permissions for entrypoint script
|
|
||||||
COPY docker-entrypoint.sh ./
|
COPY docker-entrypoint.sh ./
|
||||||
RUN chmod +x docker-entrypoint.sh
|
RUN chmod +x docker-entrypoint.sh
|
||||||
|
|
||||||
# REMOVED: USER nodejs (We must stay root to fix permissions in entrypoint)
|
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||||
|
|||||||
@@ -1,34 +1,14 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# 1. Hydrate volume if empty (Running as root)
|
# Auto-hydrate prisma directory when bind-mounted volume is empty
|
||||||
if [ ! -f "/app/prisma/schema.prisma" ]; then
|
if [ ! -f "/app/prisma/schema.prisma" ]; then
|
||||||
echo "Mount is empty. Hydrating /app/prisma..."
|
echo "Mount is empty. Hydrating /app/prisma from /app/prisma_template..."
|
||||||
cp -R /app/prisma_template/. /app/prisma/
|
cp -R /app/prisma_template/. /app/prisma/
|
||||||
else
|
|
||||||
# Volume exists but may be missing new migrations from an upgrade
|
|
||||||
# Always sync schema and migrations from template to ensure upgrades work
|
|
||||||
echo "Syncing schema and migrations from template..."
|
|
||||||
cp /app/prisma_template/schema.prisma /app/prisma/schema.prisma
|
|
||||||
cp -R /app/prisma_template/migrations/. /app/prisma/migrations/
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. Fix permissions unconditionally (Running as root)
|
# Run migrations
|
||||||
echo "Fixing filesystem permissions..."
|
npx prisma migrate deploy
|
||||||
chown -R nodejs:nodejs /app/uploads
|
|
||||||
chown -R nodejs:nodejs /app/prisma
|
|
||||||
chmod 755 /app/uploads
|
|
||||||
|
|
||||||
# Ensure database file has proper permissions
|
# Start the application
|
||||||
if [ -f "/app/prisma/dev.db" ]; then
|
node dist/index.js
|
||||||
echo "Database file found, ensuring write permissions..."
|
|
||||||
chmod 666 /app/prisma/dev.db
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 3. Run Migrations (Drop privileges to nodejs)
|
|
||||||
echo "Running database migrations..."
|
|
||||||
su-exec nodejs npx prisma migrate deploy
|
|
||||||
|
|
||||||
# 4. Start Application (Drop privileges to nodejs)
|
|
||||||
echo "Starting application as nodejs..."
|
|
||||||
exec su-exec nodejs node dist/index.js
|
|
||||||
|
|||||||
Generated
+8
-591
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "0.1.6",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,18 +14,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
"@types/jsdom": "^27.0.0",
|
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/socket.io": "^3.0.1",
|
"@types/socket.io": "^3.0.1",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"better-sqlite3": "^12.4.6",
|
"better-sqlite3": "^12.4.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dompurify": "^3.3.0",
|
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"jsdom": "^27.2.0",
|
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"prisma": "^5.22.0",
|
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
@@ -34,6 +30,7 @@
|
|||||||
"@types/express": "^5.0.5",
|
"@types/express": "^5.0.5",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"nodemon": "^3.1.11",
|
"nodemon": "^3.1.11",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1,7 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "Library" (
|
|
||||||
"id" TEXT NOT NULL PRIMARY KEY DEFAULT 'default',
|
|
||||||
"items" TEXT NOT NULL DEFAULT '[]',
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" DATETIME NOT NULL
|
|
||||||
);
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "PrivateVault" (
|
|
||||||
"id" TEXT NOT NULL PRIMARY KEY DEFAULT 'vault',
|
|
||||||
"passwordHash" TEXT NOT NULL,
|
|
||||||
"salt" TEXT NOT NULL,
|
|
||||||
"hint" TEXT,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" DATETIME NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- RedefineTables
|
|
||||||
PRAGMA defer_foreign_keys=ON;
|
|
||||||
PRAGMA foreign_keys=OFF;
|
|
||||||
CREATE TABLE "new_Drawing" (
|
|
||||||
"id" TEXT NOT NULL PRIMARY KEY,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"elements" TEXT NOT NULL,
|
|
||||||
"appState" TEXT NOT NULL,
|
|
||||||
"files" TEXT NOT NULL DEFAULT '{}',
|
|
||||||
"preview" TEXT,
|
|
||||||
"version" INTEGER NOT NULL DEFAULT 1,
|
|
||||||
"collectionId" TEXT,
|
|
||||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" DATETIME NOT NULL,
|
|
||||||
"isPrivate" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"encryptedData" TEXT,
|
|
||||||
"iv" TEXT,
|
|
||||||
CONSTRAINT "Drawing_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
INSERT INTO "new_Drawing" ("appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", "updatedAt", "version") SELECT "appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", "updatedAt", "version" FROM "Drawing";
|
|
||||||
DROP TABLE "Drawing";
|
|
||||||
ALTER TABLE "new_Drawing" RENAME TO "Drawing";
|
|
||||||
PRAGMA foreign_keys=ON;
|
|
||||||
PRAGMA defer_foreign_keys=OFF;
|
|
||||||
Binary file not shown.
@@ -32,26 +32,4 @@ model Drawing {
|
|||||||
collection Collection? @relation(fields: [collectionId], references: [id])
|
collection Collection? @relation(fields: [collectionId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Privacy/Encryption fields
|
|
||||||
isPrivate Boolean @default(false)
|
|
||||||
encryptedData String? // Encrypted blob containing elements, appState, files when isPrivate=true
|
|
||||||
iv String? // Initialization vector for AES-GCM decryption
|
|
||||||
}
|
|
||||||
|
|
||||||
// Singleton model for storing vault password hash and settings
|
|
||||||
model PrivateVault {
|
|
||||||
id String @id @default("vault") // Singleton pattern
|
|
||||||
passwordHash String // bcrypt hash for password verification
|
|
||||||
salt String // Salt for client-side key derivation (hex encoded)
|
|
||||||
hint String? // Optional password hint
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
model Library {
|
|
||||||
id String @id @default("default") // Singleton pattern - use "default" ID
|
|
||||||
items String @default("[]") // Stored as JSON string array of library items
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -136,25 +136,6 @@ exports.Prisma.DrawingScalarFieldEnum = {
|
|||||||
version: 'version',
|
version: 'version',
|
||||||
collectionId: 'collectionId',
|
collectionId: 'collectionId',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt',
|
|
||||||
isPrivate: 'isPrivate',
|
|
||||||
encryptedData: 'encryptedData',
|
|
||||||
iv: 'iv'
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.Prisma.PrivateVaultScalarFieldEnum = {
|
|
||||||
id: 'id',
|
|
||||||
passwordHash: 'passwordHash',
|
|
||||||
salt: 'salt',
|
|
||||||
hint: 'hint',
|
|
||||||
createdAt: 'createdAt',
|
|
||||||
updatedAt: 'updatedAt'
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.Prisma.LibraryScalarFieldEnum = {
|
|
||||||
id: 'id',
|
|
||||||
items: 'items',
|
|
||||||
createdAt: 'createdAt',
|
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -171,9 +152,7 @@ exports.Prisma.NullsOrder = {
|
|||||||
|
|
||||||
exports.Prisma.ModelName = {
|
exports.Prisma.ModelName = {
|
||||||
Collection: 'Collection',
|
Collection: 'Collection',
|
||||||
Drawing: 'Drawing',
|
Drawing: 'Drawing'
|
||||||
PrivateVault: 'PrivateVault',
|
|
||||||
Library: 'Library'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+3
-2363
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "prisma-client-2894d2d911743eb6e6a7a673efae7513c10b6ce609edeb9caf76ed42f4728682",
|
"name": "prisma-client-6afe3d9baa793154c8d01c79f8418d91423e5ccaec794547bf848a451459cf53",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"types": "index.d.ts",
|
"types": "index.d.ts",
|
||||||
"browser": "index-browser.js",
|
"browser": "index-browser.js",
|
||||||
|
|||||||
@@ -32,26 +32,4 @@ model Drawing {
|
|||||||
collection Collection? @relation(fields: [collectionId], references: [id])
|
collection Collection? @relation(fields: [collectionId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
// Privacy/Encryption fields
|
|
||||||
isPrivate Boolean @default(false)
|
|
||||||
encryptedData String? // Encrypted blob containing elements, appState, files when isPrivate=true
|
|
||||||
iv String? // Initialization vector for AES-GCM decryption
|
|
||||||
}
|
|
||||||
|
|
||||||
// Singleton model for storing vault password hash and settings
|
|
||||||
model PrivateVault {
|
|
||||||
id String @id @default("vault") // Singleton pattern
|
|
||||||
passwordHash String // bcrypt hash for password verification
|
|
||||||
salt String // Salt for client-side key derivation (hex encoded)
|
|
||||||
hint String? // Optional password hint
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
model Library {
|
|
||||||
id String @id @default("default") // Singleton pattern - use "default" ID
|
|
||||||
items String @default("[]") // Stored as JSON string array of library items
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,25 +136,6 @@ exports.Prisma.DrawingScalarFieldEnum = {
|
|||||||
version: 'version',
|
version: 'version',
|
||||||
collectionId: 'collectionId',
|
collectionId: 'collectionId',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt',
|
|
||||||
isPrivate: 'isPrivate',
|
|
||||||
encryptedData: 'encryptedData',
|
|
||||||
iv: 'iv'
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.Prisma.PrivateVaultScalarFieldEnum = {
|
|
||||||
id: 'id',
|
|
||||||
passwordHash: 'passwordHash',
|
|
||||||
salt: 'salt',
|
|
||||||
hint: 'hint',
|
|
||||||
createdAt: 'createdAt',
|
|
||||||
updatedAt: 'updatedAt'
|
|
||||||
};
|
|
||||||
|
|
||||||
exports.Prisma.LibraryScalarFieldEnum = {
|
|
||||||
id: 'id',
|
|
||||||
items: 'items',
|
|
||||||
createdAt: 'createdAt',
|
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -171,9 +152,7 @@ exports.Prisma.NullsOrder = {
|
|||||||
|
|
||||||
exports.Prisma.ModelName = {
|
exports.Prisma.ModelName = {
|
||||||
Collection: 'Collection',
|
Collection: 'Collection',
|
||||||
Drawing: 'Drawing',
|
Drawing: 'Drawing'
|
||||||
PrivateVault: 'PrivateVault',
|
|
||||||
Library: 'Library'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+52
-715
File diff suppressed because it is too large
Load Diff
@@ -1,495 +0,0 @@
|
|||||||
/**
|
|
||||||
* Security utilities for XSS prevention and data sanitization
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { z } from "zod";
|
|
||||||
import DOMPurify from "dompurify";
|
|
||||||
import { JSDOM } from "jsdom";
|
|
||||||
|
|
||||||
// Create a DOM environment for DOMPurify (Node.js compatibility)
|
|
||||||
const window = new JSDOM("").window;
|
|
||||||
const purify = DOMPurify(window);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize HTML/JS content using DOMPurify (battle-tested library)
|
|
||||||
*/
|
|
||||||
export const sanitizeHtml = (input: string): string => {
|
|
||||||
if (typeof input !== "string") return "";
|
|
||||||
|
|
||||||
return purify
|
|
||||||
.sanitize(input, {
|
|
||||||
ALLOWED_TAGS: [
|
|
||||||
// Allow basic text formatting that might be in drawings
|
|
||||||
"b",
|
|
||||||
"i",
|
|
||||||
"u",
|
|
||||||
"em",
|
|
||||||
"strong",
|
|
||||||
"p",
|
|
||||||
"br",
|
|
||||||
"span",
|
|
||||||
"div",
|
|
||||||
],
|
|
||||||
ALLOWED_ATTR: [], // No attributes allowed by default for security
|
|
||||||
FORBID_TAGS: [
|
|
||||||
// Explicitly forbid dangerous tags
|
|
||||||
"script",
|
|
||||||
"iframe",
|
|
||||||
"object",
|
|
||||||
"embed",
|
|
||||||
"link",
|
|
||||||
"style",
|
|
||||||
"form",
|
|
||||||
"input",
|
|
||||||
"button",
|
|
||||||
"select",
|
|
||||||
"textarea",
|
|
||||||
"svg",
|
|
||||||
"foreignObject",
|
|
||||||
],
|
|
||||||
FORBID_ATTR: [
|
|
||||||
// Explicitly forbid dangerous attributes
|
|
||||||
"onload",
|
|
||||||
"onclick",
|
|
||||||
"onerror",
|
|
||||||
"onmouseover",
|
|
||||||
"onfocus",
|
|
||||||
"onblur",
|
|
||||||
"onchange",
|
|
||||||
"onsubmit",
|
|
||||||
"onreset",
|
|
||||||
"onkeydown",
|
|
||||||
"onkeyup",
|
|
||||||
"onkeypress",
|
|
||||||
"href",
|
|
||||||
"src",
|
|
||||||
"action",
|
|
||||||
"formaction",
|
|
||||||
],
|
|
||||||
KEEP_CONTENT: true, // Keep content even if tags are removed
|
|
||||||
})
|
|
||||||
.trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize SVG content using DOMPurify with strict SVG restrictions
|
|
||||||
*/
|
|
||||||
export const sanitizeSvg = (svgContent: string): string => {
|
|
||||||
if (typeof svgContent !== "string") return "";
|
|
||||||
|
|
||||||
// For SVG content, we'll be very restrictive since SVG can execute JavaScript
|
|
||||||
// We only allow basic geometric shapes without any scripts or external references
|
|
||||||
return purify
|
|
||||||
.sanitize(svgContent, {
|
|
||||||
ALLOWED_TAGS: [
|
|
||||||
// Allow only safe SVG geometric elements
|
|
||||||
"svg",
|
|
||||||
"g",
|
|
||||||
"rect",
|
|
||||||
"circle",
|
|
||||||
"ellipse",
|
|
||||||
"line",
|
|
||||||
"polyline",
|
|
||||||
"polygon",
|
|
||||||
"path",
|
|
||||||
"text",
|
|
||||||
"tspan",
|
|
||||||
],
|
|
||||||
ALLOWED_ATTR: [
|
|
||||||
// Allow only safe geometric attributes
|
|
||||||
"x",
|
|
||||||
"y",
|
|
||||||
"width",
|
|
||||||
"height",
|
|
||||||
"cx",
|
|
||||||
"cy",
|
|
||||||
"r",
|
|
||||||
"rx",
|
|
||||||
"ry",
|
|
||||||
"x1",
|
|
||||||
"y1",
|
|
||||||
"x2",
|
|
||||||
"y2",
|
|
||||||
"points",
|
|
||||||
"d",
|
|
||||||
"fill",
|
|
||||||
"stroke",
|
|
||||||
"stroke-width",
|
|
||||||
"opacity",
|
|
||||||
"transform",
|
|
||||||
"font-size",
|
|
||||||
"font-family",
|
|
||||||
"text-anchor",
|
|
||||||
"dominant-baseline",
|
|
||||||
],
|
|
||||||
FORBID_TAGS: [
|
|
||||||
// Completely forbid any script-related or external content
|
|
||||||
"script",
|
|
||||||
"foreignObject",
|
|
||||||
"iframe",
|
|
||||||
"object",
|
|
||||||
"embed",
|
|
||||||
"use",
|
|
||||||
"image",
|
|
||||||
"style",
|
|
||||||
"link",
|
|
||||||
"defs",
|
|
||||||
"symbol",
|
|
||||||
"marker",
|
|
||||||
"clipPath",
|
|
||||||
"mask",
|
|
||||||
"filter",
|
|
||||||
],
|
|
||||||
FORBID_ATTR: [
|
|
||||||
// Forbid any attributes that could execute code or load external content
|
|
||||||
"onload",
|
|
||||||
"onclick",
|
|
||||||
"onerror",
|
|
||||||
"onmouseover",
|
|
||||||
"onfocus",
|
|
||||||
"onblur",
|
|
||||||
"href",
|
|
||||||
"xlink:href",
|
|
||||||
"src",
|
|
||||||
"action",
|
|
||||||
"style",
|
|
||||||
"class",
|
|
||||||
"id",
|
|
||||||
],
|
|
||||||
KEEP_CONTENT: true,
|
|
||||||
})
|
|
||||||
.trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate and sanitize text content using DOMPurify
|
|
||||||
*/
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Use DOMPurify for text content - more permissive than HTML but still safe
|
|
||||||
return purify
|
|
||||||
.sanitize(truncated, {
|
|
||||||
ALLOWED_TAGS: [
|
|
||||||
// Allow basic text formatting that might be in drawing text
|
|
||||||
"b",
|
|
||||||
"i",
|
|
||||||
"u",
|
|
||||||
"em",
|
|
||||||
"strong",
|
|
||||||
"br",
|
|
||||||
"span",
|
|
||||||
],
|
|
||||||
ALLOWED_ATTR: [], // No attributes allowed for text content
|
|
||||||
FORBID_TAGS: [
|
|
||||||
// Block potentially dangerous tags
|
|
||||||
"script",
|
|
||||||
"iframe",
|
|
||||||
"object",
|
|
||||||
"embed",
|
|
||||||
"link",
|
|
||||||
"style",
|
|
||||||
"form",
|
|
||||||
"input",
|
|
||||||
"button",
|
|
||||||
"select",
|
|
||||||
"textarea",
|
|
||||||
"svg",
|
|
||||||
"foreignObject",
|
|
||||||
],
|
|
||||||
FORBID_ATTR: [
|
|
||||||
// Block all event handlers and dangerous attributes
|
|
||||||
"onload",
|
|
||||||
"onclick",
|
|
||||||
"onerror",
|
|
||||||
"onmouseover",
|
|
||||||
"onfocus",
|
|
||||||
"onblur",
|
|
||||||
"onchange",
|
|
||||||
"onsubmit",
|
|
||||||
"onreset",
|
|
||||||
"onkeydown",
|
|
||||||
"onkeyup",
|
|
||||||
"onkeypress",
|
|
||||||
"href",
|
|
||||||
"src",
|
|
||||||
"action",
|
|
||||||
"formaction",
|
|
||||||
"style",
|
|
||||||
],
|
|
||||||
KEEP_CONTENT: true,
|
|
||||||
})
|
|
||||||
.trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Very flexible Zod schema for Excalidraw elements
|
|
||||||
*/
|
|
||||||
export const elementSchema = z
|
|
||||||
.object({
|
|
||||||
id: z.string().min(1).max(200).optional().nullable(),
|
|
||||||
type: z.string().optional().nullable(),
|
|
||||||
x: z.number().optional().nullable(),
|
|
||||||
y: z.number().optional().nullable(),
|
|
||||||
width: z.number().optional().nullable(),
|
|
||||||
height: z.number().optional().nullable(),
|
|
||||||
angle: z.number().optional().nullable(),
|
|
||||||
strokeColor: z.string().optional().nullable(),
|
|
||||||
backgroundColor: z.string().optional().nullable(),
|
|
||||||
fillStyle: z.string().optional().nullable(),
|
|
||||||
strokeWidth: z.number().optional().nullable(),
|
|
||||||
strokeStyle: z.string().optional().nullable(),
|
|
||||||
roundness: z.any().optional().nullable(),
|
|
||||||
boundElements: z.array(z.any()).optional().nullable(),
|
|
||||||
groupIds: z.array(z.string()).optional().nullable(),
|
|
||||||
frameId: z.string().optional().nullable(),
|
|
||||||
seed: z.number().optional().nullable(),
|
|
||||||
version: z.number().optional().nullable(),
|
|
||||||
versionNonce: z.number().optional().nullable(),
|
|
||||||
isDeleted: z.boolean().optional().nullable(),
|
|
||||||
opacity: z.number().optional().nullable(),
|
|
||||||
link: z.string().optional().nullable(),
|
|
||||||
locked: z.boolean().optional().nullable(),
|
|
||||||
text: z.string().optional().nullable(),
|
|
||||||
fontSize: z.number().optional().nullable(),
|
|
||||||
fontFamily: z.number().optional().nullable(),
|
|
||||||
textAlign: z.string().optional().nullable(),
|
|
||||||
verticalAlign: z.string().optional().nullable(),
|
|
||||||
customData: z.record(z.string(), z.any()).optional().nullable(),
|
|
||||||
})
|
|
||||||
.passthrough()
|
|
||||||
.transform((element) => {
|
|
||||||
// Apply basic sanitization to string values only
|
|
||||||
const sanitized = { ...element };
|
|
||||||
|
|
||||||
if (typeof sanitized.text === "string") {
|
|
||||||
sanitized.text = sanitizeText(sanitized.text, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof sanitized.link === "string") {
|
|
||||||
sanitized.link = sanitizeUrl(sanitized.link);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sanitized;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flexible Zod schema for Excalidraw app state with validation
|
|
||||||
*/
|
|
||||||
export const appStateSchema = z
|
|
||||||
.object({
|
|
||||||
gridSize: z.number().finite().min(0).max(1000).optional().nullable(),
|
|
||||||
gridStep: z.number().finite().min(1).max(1000).optional().nullable(),
|
|
||||||
viewBackgroundColor: z.string().optional().nullable(),
|
|
||||||
currentItemStrokeColor: z.string().optional().nullable(),
|
|
||||||
currentItemBackgroundColor: z.string().optional().nullable(),
|
|
||||||
currentItemFillStyle: z
|
|
||||||
.enum(["solid", "hachure", "cross-hatch", "dots"])
|
|
||||||
.optional()
|
|
||||||
.nullable(),
|
|
||||||
currentItemStrokeWidth: z
|
|
||||||
.number()
|
|
||||||
.finite()
|
|
||||||
.min(0)
|
|
||||||
.max(50)
|
|
||||||
.optional()
|
|
||||||
.nullable(),
|
|
||||||
currentItemStrokeStyle: z
|
|
||||||
.enum(["solid", "dashed", "dotted"])
|
|
||||||
.optional()
|
|
||||||
.nullable(),
|
|
||||||
currentItemRoundness: z
|
|
||||||
.object({
|
|
||||||
type: z.enum(["round", "sharp"]),
|
|
||||||
value: z.number().finite().min(0).max(1),
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.nullable(),
|
|
||||||
currentItemFontSize: z
|
|
||||||
.number()
|
|
||||||
.finite()
|
|
||||||
.min(1)
|
|
||||||
.max(500)
|
|
||||||
.optional()
|
|
||||||
.nullable(),
|
|
||||||
currentItemFontFamily: z
|
|
||||||
.number()
|
|
||||||
.finite()
|
|
||||||
.min(1)
|
|
||||||
.max(10)
|
|
||||||
.optional()
|
|
||||||
.nullable(),
|
|
||||||
currentItemTextAlign: z
|
|
||||||
.enum(["left", "center", "right"])
|
|
||||||
.optional()
|
|
||||||
.nullable(),
|
|
||||||
currentItemVerticalAlign: z
|
|
||||||
.enum(["top", "middle", "bottom"])
|
|
||||||
.optional()
|
|
||||||
.nullable(),
|
|
||||||
scrollX: z
|
|
||||||
.number()
|
|
||||||
.finite()
|
|
||||||
.min(-10000000)
|
|
||||||
.max(10000000)
|
|
||||||
.optional()
|
|
||||||
.nullable(),
|
|
||||||
scrollY: z
|
|
||||||
.number()
|
|
||||||
.finite()
|
|
||||||
.min(-10000000)
|
|
||||||
.max(10000000)
|
|
||||||
.optional()
|
|
||||||
.nullable(),
|
|
||||||
zoom: z
|
|
||||||
.object({
|
|
||||||
value: z.number().finite().min(0.01).max(100),
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.nullable(),
|
|
||||||
selection: z.array(z.string()).optional().nullable(),
|
|
||||||
selectedElementIds: z.record(z.string(), z.boolean()).optional().nullable(),
|
|
||||||
selectedGroupIds: z.record(z.string(), z.boolean()).optional().nullable(),
|
|
||||||
activeEmbeddable: z
|
|
||||||
.object({
|
|
||||||
elementId: z.string(),
|
|
||||||
state: z.string(),
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.nullable(),
|
|
||||||
activeTool: z
|
|
||||||
.object({
|
|
||||||
type: z.string(),
|
|
||||||
customType: z.string().optional().nullable(),
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.nullable(),
|
|
||||||
cursorX: z.number().finite().optional().nullable(),
|
|
||||||
cursorY: z.number().finite().optional().nullable(),
|
|
||||||
// Add common Excalidraw app state properties
|
|
||||||
collaborators: z.record(z.string(), z.any()).optional().nullable(),
|
|
||||||
})
|
|
||||||
// Allow any additional properties
|
|
||||||
.catchall(
|
|
||||||
z.any().refine((val) => {
|
|
||||||
// Sanitize string values, but be more permissive for other types
|
|
||||||
if (typeof val === "string") {
|
|
||||||
return sanitizeText(val, 1000);
|
|
||||||
}
|
|
||||||
// Allow numbers, booleans, objects, arrays, null, undefined
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 = `
|
|
||||||
<script>alert('XSS')</script>
|
|
||||||
<img src="x" onerror="alert('XSS')">
|
|
||||||
<iframe src="javascript:alert('XSS')"></iframe>
|
|
||||||
<object data="javascript:alert('XSS')"></object>
|
|
||||||
<embed src="javascript:alert('XSS')"></embed>
|
|
||||||
Normal text content
|
|
||||||
`;
|
|
||||||
const sanitizedHtml = sanitizeHtml(maliciousHtml);
|
|
||||||
console.log("PASS: Original:", maliciousHtml.substring(0, 100) + "...");
|
|
||||||
console.log("PASS: Sanitized:", sanitizedHtml.substring(0, 100) + "...");
|
|
||||||
console.log("PASS: Script tags removed:", !sanitizedHtml.includes("<script>"));
|
|
||||||
console.log(
|
|
||||||
"PASS: Event handlers removed:",
|
|
||||||
!sanitizedHtml.includes("onerror=")
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
"PASS: Malicious URLs blocked:",
|
|
||||||
!sanitizedHtml.includes("javascript:")
|
|
||||||
);
|
|
||||||
console.log("");
|
|
||||||
|
|
||||||
// Test 2: SVG Sanitization
|
|
||||||
console.log("Test 2: SVG Sanitization");
|
|
||||||
const maliciousSvg = `
|
|
||||||
<svg>
|
|
||||||
<script>alert('SVG XSS')</script>
|
|
||||||
<rect href="javascript:alert('XSS')" />
|
|
||||||
<foreignObject>
|
|
||||||
<script>alert('XSS')</script>
|
|
||||||
</foreignObject>
|
|
||||||
</svg>
|
|
||||||
`;
|
|
||||||
const sanitizedSvg = sanitizeSvg(maliciousSvg);
|
|
||||||
console.log("PASS: Original:", maliciousSvg.substring(0, 100) + "...");
|
|
||||||
console.log("PASS: Sanitized:", sanitizedSvg.substring(0, 100) + "...");
|
|
||||||
console.log("PASS: SVG scripts removed:", !sanitizedSvg.includes("<script>"));
|
|
||||||
console.log(
|
|
||||||
"PASS: Malicious hrefs sanitized:",
|
|
||||||
!sanitizedSvg.includes("javascript:")
|
|
||||||
);
|
|
||||||
console.log("");
|
|
||||||
|
|
||||||
// Test 3: URL Sanitization
|
|
||||||
console.log("Test 3: URL Sanitization");
|
|
||||||
const maliciousUrls = [
|
|
||||||
"javascript:alert('XSS')",
|
|
||||||
"data:text/html,<script>alert('XSS')</script>",
|
|
||||||
"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(
|
|
||||||
`PASS: "${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(
|
|
||||||
`PASS: Long text truncated: ${longText.length} -> ${sanitizedLongText.length} chars`
|
|
||||||
);
|
|
||||||
|
|
||||||
const maliciousText = "<script>alert('XSS')</script>Normal text";
|
|
||||||
const sanitizedText = sanitizeText(maliciousText);
|
|
||||||
console.log(`PASS: Text sanitized: "${maliciousText}" -> "${sanitizedText}"`);
|
|
||||||
console.log(
|
|
||||||
"PASS: Malicious content removed:",
|
|
||||||
!sanitizedText.includes("<script>")
|
|
||||||
);
|
|
||||||
console.log("");
|
|
||||||
|
|
||||||
// Test 5: Drawing Validation
|
|
||||||
console.log("Test 5: Drawing Data Validation");
|
|
||||||
const maliciousDrawing = {
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
id: "test1",
|
|
||||||
type: "text",
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: 100,
|
|
||||||
height: 50,
|
|
||||||
angle: 0,
|
|
||||||
version: 1,
|
|
||||||
versionNonce: 1,
|
|
||||||
text: "<script>alert('XSS')</script>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: "<script>alert('XSS')</script>",
|
|
||||||
},
|
|
||||||
files: null,
|
|
||||||
preview: '<svg><script>alert("XSS")</script></svg>',
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log("Testing malicious drawing validation...");
|
|
||||||
const isValidDrawing = validateImportedDrawing(maliciousDrawing);
|
|
||||||
console.log(`PASS: Malicious drawing rejected: ${!isValidDrawing}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sanitizedDrawing = sanitizeDrawingData(maliciousDrawing);
|
|
||||||
console.log("PASS: Sanitization successful");
|
|
||||||
console.log(`PASS: Text sanitized: ${sanitizedDrawing.elements[0].text}`);
|
|
||||||
console.log(
|
|
||||||
`PASS: Link sanitized: ${sanitizedDrawing.elements[1].link || "null"}`
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`PASS: SVG sanitized: ${!sanitizedDrawing.preview?.includes("<script>")}`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.log("PASS: Sanitization failed as expected:", error.message);
|
|
||||||
}
|
|
||||||
console.log("");
|
|
||||||
|
|
||||||
// Test 6: Legitimate Drawing Should Pass
|
|
||||||
console.log("Test 6: Legitimate Drawing Validation");
|
|
||||||
const legitimateDrawing = {
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
id: "legit1",
|
|
||||||
type: "text",
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: 100,
|
|
||||||
height: 50,
|
|
||||||
angle: 0,
|
|
||||||
version: 1,
|
|
||||||
versionNonce: 1,
|
|
||||||
text: "Normal text content",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "legit2",
|
|
||||||
type: "rectangle",
|
|
||||||
x: 10,
|
|
||||||
y: 10,
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
angle: 0,
|
|
||||||
version: 1,
|
|
||||||
versionNonce: 1,
|
|
||||||
link: "https://example.com",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
appState: {
|
|
||||||
viewBackgroundColor: "#ffffff",
|
|
||||||
},
|
|
||||||
files: null,
|
|
||||||
preview: '<svg><rect width="100" height="100" fill="blue"/></svg>',
|
|
||||||
};
|
|
||||||
|
|
||||||
const isValidLegitimate = validateImportedDrawing(legitimateDrawing);
|
|
||||||
console.log(`PASS: Legitimate drawing accepted: ${isValidLegitimate}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sanitizedLegitimate = sanitizeDrawingData(legitimateDrawing);
|
|
||||||
console.log("PASS: Legitimate drawing sanitization successful");
|
|
||||||
console.log(
|
|
||||||
`PASS: Text preserved: "${sanitizedLegitimate.elements[0].text}"`
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
`PASS: Safe URL preserved: "${sanitizedLegitimate.elements[1].link}"`
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.log("FAIL: Legitimate drawing should not fail:", error.message);
|
|
||||||
}
|
|
||||||
console.log("");
|
|
||||||
|
|
||||||
console.log("Completed! Security Test Suite Completed!");
|
|
||||||
console.log("\nSummary: Test Summary:");
|
|
||||||
console.log("PASS: HTML/JS injection prevention - WORKING");
|
|
||||||
console.log("PASS: SVG malicious content blocking - WORKING");
|
|
||||||
console.log("PASS: URL scheme validation - WORKING");
|
|
||||||
console.log("PASS: Text sanitization with limits - WORKING");
|
|
||||||
console.log("PASS: Malicious drawing rejection - WORKING");
|
|
||||||
console.log("PASS: Legitimate content preservation - WORKING");
|
|
||||||
console.log("\nSecurity: XSS Prevention: IMPLEMENTED & FUNCTIONAL");
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user