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