diff --git a/README.md b/README.md index 3f94003..d7a777f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ExcaliDash Logo -# ExcaliDash v0.1.0 +# ExcaliDash v0.1.5 ![License](https://img.shields.io/github/license/zimengxiong/ExcaliDash) [![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](https://hub.docker.com) @@ -74,7 +74,10 @@ See [release notes](https://github.com/ZimengXiong/ExcaliDash/releases) for a sp # Installation > [!CAUTION] -> 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) +> NOT for production use. While attempts have been made at hardening (XSS/dompurify, CORS, rate-limiting, sanitization) have been made, they are inadequate for public deployment. Do not expose any ports. Currently lacking CSRF. + +> [!CAUTION] +> ExcaliDash is in BETA. Please backup your data regularly (e.g. with cron). ## Docker Hub (Recommended) diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..04bf419 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,30 @@ +# 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 diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..def9a01 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.5 \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example index da1157d..da110e9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,3 +2,4 @@ PORT=8000 NODE_ENV=production DATABASE_URL=file:/app/prisma/dev.db +FRONTEND_URL=http://localhost:6767 \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index 3f0d30e..53589ec 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 su-exec, create non-root user +RUN apk add --no-cache openssl su-exec && \ + addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 WORKDIR /app @@ -49,10 +51,15 @@ 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 (ownership will be set in entrypoint) +RUN mkdir -p /app/uploads /app/prisma + +# Copy and set permissions for entrypoint script COPY docker-entrypoint.sh ./ RUN chmod +x docker-entrypoint.sh +# REMOVED: USER nodejs (We must stay root to fix permissions in entrypoint) + EXPOSE 8000 ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh index 41ca11c..d1a311a 100644 --- a/backend/docker-entrypoint.sh +++ b/backend/docker-entrypoint.sh @@ -1,14 +1,28 @@ #!/bin/sh set -e -# Auto-hydrate prisma directory when bind-mounted volume is empty +# 1. Hydrate volume if empty (Running as root) if [ ! -f "/app/prisma/schema.prisma" ]; then - echo "Mount is empty. Hydrating /app/prisma from /app/prisma_template..." - cp -R /app/prisma_template/. /app/prisma/ + echo "Mount is empty. Hydrating /app/prisma..." + cp -R /app/prisma_template/. /app/prisma/ fi -# Run migrations -npx prisma migrate deploy +# 2. Fix permissions unconditionally (Running as root) +echo "Fixing filesystem permissions..." +chown -R nodejs:nodejs /app/uploads +chown -R nodejs:nodejs /app/prisma +chmod 755 /app/uploads -# Start the application -node dist/index.js +# Ensure database file has proper permissions +if [ -f "/app/prisma/dev.db" ]; then + 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 diff --git a/backend/package-lock.json b/backend/package-lock.json index 07f23d9..e2c7b77 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,14 +11,18 @@ "dependencies": { "@prisma/client": "^5.22.0", "@types/archiver": "^7.0.0", + "@types/jsdom": "^27.0.0", "@types/multer": "^2.0.0", "@types/socket.io": "^3.0.1", "archiver": "^7.0.1", "better-sqlite3": "^12.4.6", "cors": "^2.8.5", + "dompurify": "^3.3.0", "dotenv": "^17.2.3", "express": "^5.1.0", + "jsdom": "^27.2.0", "multer": "^2.0.2", + "prisma": "^5.22.0", "socket.io": "^4.8.1", "zod": "^4.1.12" }, @@ -27,11 +31,66 @@ "@types/express": "^5.0.5", "@types/node": "^24.10.1", "nodemon": "^3.1.11", - "prisma": "^5.22.0", "ts-node": "^10.9.2", "typescript": "^5.9.3" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.24", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.24.tgz", + "integrity": "sha512-5YjgMmAiT2rjJZU7XK1SNI7iqTy92DpaYVgG6x63FxkJ11UpYfLndHJATtinWJClAXiOlW9XWaUyAQf8pMrQPg==", + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.0.tgz", + "integrity": "sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.2" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.4.tgz", + "integrity": "sha512-buQDjkm+wDPXd6c13534URWZqbz0RP5PAhXZ+LIoa5LgwInT9HVJvGIJivg75vi8I13CxDGdTnz+aY5YUJlIAA==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.2" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "license": "MIT" + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -45,6 +104,137 @@ "node": ">=12" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.17.tgz", + "integrity": "sha512-LCC++2h8pLUSPY+EsZmrrJ1EOUu+5iClpEiDhhdw3zRJpPbABML/N5lmRuBHjxtKm9VnRcsUzioyD0sekFMF0A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -122,14 +312,12 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", - "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -143,14 +331,12 @@ "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", - "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "5.22.0", @@ -162,7 +348,6 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", - "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "5.22.0" @@ -268,6 +453,17 @@ "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", "license": "MIT" }, + "node_modules/@types/jsdom": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-27.0.0.tgz", + "integrity": "sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -353,6 +549,19 @@ "socket.io": "*" } }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -404,6 +613,15 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -606,6 +824,15 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1019,6 +1246,46 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.3.tgz", + "integrity": "sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.0.3", + "@csstools/css-syntax-patches-for-csstree": "^1.0.14", + "css-tree": "^3.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1036,6 +1303,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -1088,6 +1361,15 @@ "node": ">=0.3.1" } }, + "node_modules/dompurify": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz", + "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "17.2.3", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", @@ -1239,6 +1521,18 @@ "node": ">= 0.6" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1448,7 +1742,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1620,6 +1913,18 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1640,6 +1945,32 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -1755,6 +2086,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -1800,6 +2137,78 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jsdom": { + "version": "27.2.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.2.0.tgz", + "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.23", + "@asamuzakjp/dom-selector": "^6.7.4", + "cssstyle": "^5.3.3", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", @@ -1870,6 +2279,12 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -2157,6 +2572,18 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2244,7 +2671,6 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", - "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "peer": true, @@ -2306,6 +2732,15 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -2433,6 +2868,15 @@ "node": ">=8.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2475,6 +2919,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -2828,6 +3284,15 @@ "node": ">= 0.6" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2983,6 +3448,12 @@ "node": ">=4" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", @@ -3031,6 +3502,24 @@ "b4a": "^1.6.4" } }, + "node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3063,6 +3552,30 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -3198,6 +3711,61 @@ "node": ">= 0.8" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3331,6 +3899,21 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 1f16800..384c855 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "1.0.0", + "version": "0.1.5", "description": "", "main": "index.js", "scripts": { @@ -14,14 +14,18 @@ "dependencies": { "@prisma/client": "^5.22.0", "@types/archiver": "^7.0.0", + "@types/jsdom": "^27.0.0", "@types/multer": "^2.0.0", "@types/socket.io": "^3.0.1", "archiver": "^7.0.1", "better-sqlite3": "^12.4.6", "cors": "^2.8.5", + "dompurify": "^3.3.0", "dotenv": "^17.2.3", "express": "^5.1.0", + "jsdom": "^27.2.0", "multer": "^2.0.2", + "prisma": "^5.22.0", "socket.io": "^4.8.1", "zod": "^4.1.12" }, @@ -30,7 +34,6 @@ "@types/express": "^5.0.5", "@types/node": "^24.10.1", "nodemon": "^3.1.11", - "prisma": "^5.22.0", "ts-node": "^10.9.2", "typescript": "^5.9.3" } diff --git a/backend/prisma/dev.db.backup b/backend/prisma/dev.db.backup index 8c402fb..9f71e00 100644 Binary files a/backend/prisma/dev.db.backup and b/backend/prisma/dev.db.backup differ diff --git a/backend/prisma/prisma/dev.db b/backend/prisma/prisma/dev.db index d745181..78817f7 100644 Binary files a/backend/prisma/prisma/dev.db and b/backend/prisma/prisma/dev.db differ diff --git a/backend/prisma/prisma/dev.db-journal b/backend/prisma/prisma/dev.db-journal new file mode 100644 index 0000000..1b10b30 Binary files /dev/null and b/backend/prisma/prisma/dev.db-journal differ diff --git a/backend/src/index.ts b/backend/src/index.ts index fa47cc0..74f3833 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -3,14 +3,23 @@ import cors from "cors"; import dotenv from "dotenv"; import path from "path"; import fs from "fs"; +import { promises as fsPromises } from "fs"; import { createServer } from "http"; import { Server } from "socket.io"; +import { Worker } from "worker_threads"; import multer from "multer"; import archiver from "archiver"; -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(); @@ -60,9 +69,38 @@ const allowedOrigins = normalizeOrigins(process.env.FRONTEND_URL); console.log("Allowed origins:", allowedOrigins); const uploadDir = path.resolve(__dirname, "../uploads"); -if (!fs.existsSync(uploadDir)) { - fs.mkdirSync(uploadDir, { recursive: true }); -} + +const moveFile = async (source: string, destination: string) => { + try { + await fsPromises.rename(source, destination); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (!err || err.code !== "EXDEV") { + throw error; + } + + // Cross-device rename fallback: copy then delete source + await fsPromises + .unlink(destination) + .catch((unlinkError: NodeJS.ErrnoException) => { + if (unlinkError && unlinkError.code !== "ENOENT") { + throw unlinkError; + } + }); + + await fsPromises.copyFile(source, destination); + await fsPromises.unlink(source); + } +}; + +// Initialize upload directory asynchronously +const initializeUploadDir = async () => { + try { + await fsPromises.mkdir(uploadDir, { recursive: true }); + } catch (error) { + console.error("Failed to create upload directory:", error); + } +}; const app = express(); const httpServer = createServer(app); @@ -76,8 +114,26 @@ const io = new Server(httpServer, { const prisma = new PrismaClient(); const PORT = process.env.PORT || 8000; -// Multer setup for file uploads -const upload = multer({ dest: uploadDir }); +// Multer setup for file uploads with streaming support +const upload = multer({ + dest: uploadDir, + limits: { + fileSize: 100 * 1024 * 1024, // 100MB limit + files: 1, // Only one file per upload + }, + fileFilter: (req, file, cb) => { + // Only allow SQLite database extensions for database imports + if (file.fieldname === "db") { + const isSqliteDb = + file.originalname.endsWith(".db") || + file.originalname.endsWith(".sqlite"); + if (!isSqliteDb) { + return cb(new Error("Only .db or .sqlite files are allowed")); + } + } + cb(null, true); + }, +}); app.use( cors({ @@ -88,9 +144,73 @@ app.use( app.use(express.json({ limit: "50mb" })); app.use(express.urlencoded({ extended: true, limit: "50mb" })); -const elementsSchema = z.array(z.object({}).passthrough()); +// Log large requests for monitoring and debugging +app.use((req, res, next) => { + const contentLength = req.headers["content-length"]; + if (contentLength) { + const sizeInMB = parseInt(contentLength, 10) / 1024 / 1024; + if (sizeInMB > 10) { + console.log( + `[LARGE REQUEST] ${req.method} ${req.path} - ${sizeInMB.toFixed( + 2 + )}MB - Content-Length: ${contentLength} bytes` + ); + } + } + next(); +}); -const appStateSchema = 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=()" + ); + + // 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 +223,84 @@ 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: Array.isArray(data.elements) ? data.elements : [], + appState: + typeof data.appState === "object" && data.appState !== null + ? 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); + // For updates, if sanitization fails but we have minimal data, allow it to pass + // This prevents legitimate empty drawings from failing + if ( + data.elements === undefined && + data.appState === undefined && + (data.name !== undefined || + data.preview !== undefined || + data.collectionId !== undefined) + ) { + return true; + } + return false; + } + }, + { + message: "Invalid or malicious drawing data detected", + } + ); const respondWithValidationErrors = ( res: express.Response, @@ -125,29 +312,90 @@ const respondWithValidationErrors = ( }); }; -const runIntegrityCheck = (filePath: string): boolean => { - let dbInstance: Database.Database | undefined; +const validateSqliteHeader = (filePath: string): boolean => { try { - dbInstance = new Database(filePath, { - readonly: true, - fileMustExist: true, - }); - const result = dbInstance.prepare("PRAGMA integrity_check;").get(); - return result?.integrity_check === "ok"; + const buffer = Buffer.alloc(16); + const fd = fs.openSync(filePath, "r"); + const bytesRead = fs.readSync(fd, buffer, 0, 16, 0); + fs.closeSync(fd); + + if (bytesRead < 16) { + console.warn("File too small to be a valid SQLite database"); + return false; + } + + // SQLite format 3 header: "SQLite format 3\0" (16 bytes) + // Hex: 53 51 4c 69 74 65 20 66 6f 72 6d 61 74 20 33 00 + const expectedHeader = Buffer.from([ + 0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, + 0x74, 0x20, 0x33, 0x00, + ]); + + const isValid = buffer.equals(expectedHeader); + if (!isValid) { + console.warn("Invalid SQLite file header detected", { + filePath, + header: buffer.toString("hex"), + expected: expectedHeader.toString("hex"), + }); + } + + return isValid; } catch (error) { - console.error("Integrity check failed:", error); + console.error("Failed to validate SQLite header:", error); return false; - } finally { - dbInstance?.close(); } }; +// Non-blocking CPU check using worker threads while still verifying headers +const verifyDatabaseIntegrityAsync = (filePath: string): Promise => { + if (!validateSqliteHeader(filePath)) { + return Promise.resolve(false); + } -const removeFileIfExists = (filePath?: string) => { + return new Promise((resolve) => { + const worker = new Worker( + path.resolve(__dirname, "./workers/db-verify.js"), + { + workerData: { filePath }, + } + ); + let timeoutHandle: NodeJS.Timeout; + let settled = false; + + const finish = (result: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timeoutHandle); + resolve(result); + }; + + worker.on("message", (isValid: boolean) => finish(isValid)); + worker.on("error", (err) => { + console.error("Worker error:", err); + finish(false); + }); + worker.on("exit", (code) => { + if (code !== 0) { + finish(false); + } + }); + + timeoutHandle = setTimeout(() => { + console.warn("Integrity check worker timed out", { filePath }); + worker.terminate(); + finish(false); + }, 10000); // 10 second timeout + }); +}; + +const removeFileIfExists = async (filePath?: string) => { if (!filePath) return; try { - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - } + await fsPromises.access(filePath).catch(() => { + // File doesn't exist, nothing to remove + return; + }); + await fsPromises.unlink(filePath); } catch (error) { console.error("Failed to remove file", { filePath, error }); } @@ -312,6 +560,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 +599,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" }); } }); @@ -348,8 +608,32 @@ app.post("/drawings", async (req, res) => { app.put("/drawings/:id", async (req, res) => { try { const { id } = req.params; + + console.log("[API] Update request received", { + id, + bodyKeys: Object.keys(req.body || {}), + hasElements: req.body?.elements !== undefined, + elementCount: Array.isArray(req.body?.elements) + ? req.body.elements.length + : undefined, + hasAppState: req.body?.appState !== undefined, + appStateKeys: req.body?.appState ? Object.keys(req.body.appState) : [], + hasFiles: req.body?.files !== undefined, + hasPreview: req.body?.preview !== undefined, + }); + const parsed = drawingUpdateSchema.safeParse(req.body); if (!parsed.success) { + console.error("[API] Validation failed", { + id, + errorCount: parsed.error.issues.length, + errors: parsed.error.issues.map((issue) => ({ + path: issue.path, + message: issue.message, + received: + issue.path.length > 0 ? req.body?.[issue.path.join(".")] : "root", + })), + }); return respondWithValidationErrors(res, parsed.error.issues); } @@ -404,6 +688,7 @@ app.put("/drawings/:id", async (req, res) => { files: JSON.parse(updatedDrawing.files || "{}"), }); } catch (error) { + console.error("[CRITICAL] Update failed:", error); res.status(500).json({ error: "Failed to update drawing" }); } }); @@ -518,12 +803,19 @@ app.delete("/collections/:id", async (req, res) => { // --- Export/Import Endpoints --- -// GET /export - Export SQLite database +// GET /export - Export SQLite database (supports .sqlite and .db extensions) app.get("/export", async (req, res) => { try { + const formatParam = + typeof req.query.format === "string" + ? req.query.format.toLowerCase() + : undefined; + const extension = formatParam === "db" ? "db" : "sqlite"; const dbPath = path.resolve(__dirname, "../prisma/dev.db"); - if (!fs.existsSync(dbPath)) { + try { + await fsPromises.access(dbPath); + } catch { return res.status(404).json({ error: "Database file not found" }); } @@ -532,7 +824,7 @@ app.get("/export", async (req, res) => { "Content-Disposition", `attachment; filename="excalidash-db-${ new Date().toISOString().split("T")[0] - }.sqlite"` + }.${extension}"` ); const fileStream = fs.createReadStream(dbPath); @@ -645,18 +937,18 @@ app.post("/import/sqlite/verify", upload.single("db"), async (req, res) => { } const stagedPath = req.file.path; - const isValid = runIntegrityCheck(stagedPath); - removeFileIfExists(stagedPath); + const isValid = await verifyDatabaseIntegrityAsync(stagedPath); + await removeFileIfExists(stagedPath); if (!isValid) { - return res.status(400).json({ error: "Invalid SQLite file" }); + return res.status(400).json({ error: "Invalid database format" }); } res.json({ valid: true, message: "Database file is valid" }); } catch (error) { console.error(error); if (req.file) { - removeFileIfExists(req.file.path); + await removeFileIfExists(req.file.path); } res.status(500).json({ error: "Failed to verify database file" }); } @@ -676,17 +968,17 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => { ); try { - fs.renameSync(originalPath, stagedPath); + await moveFile(originalPath, stagedPath); } catch (error) { console.error("Failed to stage uploaded database", error); - removeFileIfExists(originalPath); - removeFileIfExists(stagedPath); + await removeFileIfExists(originalPath); + await removeFileIfExists(stagedPath); return res.status(500).json({ error: "Failed to stage uploaded file" }); } - const isValid = runIntegrityCheck(stagedPath); + const isValid = await verifyDatabaseIntegrityAsync(stagedPath); if (!isValid) { - removeFileIfExists(stagedPath); + await removeFileIfExists(stagedPath); return res .status(400) .json({ error: "Uploaded database failed integrity check" }); @@ -696,13 +988,20 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => { const backupPath = path.resolve(__dirname, "../prisma/dev.db.backup"); try { - if (fs.existsSync(dbPath)) { - fs.copyFileSync(dbPath, backupPath); + // Use async file operations instead of blocking ones + try { + await fsPromises.access(dbPath); + // Database exists, create backup + await fsPromises.copyFile(dbPath, backupPath); + } catch { + // Database doesn't exist, skip backup } - fs.renameSync(stagedPath, dbPath); + + // Move staged file to final location, supporting cross-device mounts + await moveFile(stagedPath, dbPath); } catch (error) { console.error("Failed to replace database", error); - removeFileIfExists(stagedPath); + await removeFileIfExists(stagedPath); return res.status(500).json({ error: "Failed to replace database" }); } @@ -713,7 +1012,7 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => { } catch (error) { console.error(error); if (req.file) { - removeFileIfExists(req.file.path); + await removeFileIfExists(req.file.path); } res.status(500).json({ error: "Failed to import database" }); } @@ -737,6 +1036,8 @@ const ensureTrashCollection = async () => { }; httpServer.listen(PORT, async () => { + // Initialize upload directory asynchronously to avoid blocking startup + await initializeUploadDir(); await ensureTrashCollection(); console.log(`Server running on port ${PORT}`); }); diff --git a/backend/src/security.ts b/backend/src/security.ts new file mode 100644 index 0000000..e0c230a --- /dev/null +++ b/backend/src/security.ts @@ -0,0 +1,495 @@ +/** + * 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; + } +}; diff --git a/backend/src/securityTest.ts b/backend/src/securityTest.ts new file mode 100644 index 0000000..311a008 --- /dev/null +++ b/backend/src/securityTest.ts @@ -0,0 +1,217 @@ +/** + * 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("PASS: Original:", maliciousHtml.substring(0, 100) + "..."); +console.log("PASS: Sanitized:", sanitizedHtml.substring(0, 100) + "..."); +console.log("PASS: Script tags removed:", !sanitizedHtml.includes(" + + + + + +`; +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("", + "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 = "Normal text"; +const sanitizedText = sanitizeText(maliciousText); +console.log(`PASS: Text sanitized: "${maliciousText}" -> "${sanitizedText}"`); +console.log( + "PASS: 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(`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("