Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e97fbbdf27 | |||
| 2e40deb82c | |||
| 4ebc99152a | |||
| 44317c4981 | |||
| 0253ebb6b8 | |||
| 1e617025df | |||
| e4941ad77f | |||
| 2e370f9821 | |||
| b075a0cf9e | |||
| 7977a3eb09 | |||
| 40a645b823 | |||
| dd966f6d01 | |||
| d832e55dfd | |||
| 887818c9b4 | |||
| bc13cc3483 | |||
| da299d00d5 | |||
| 302d9bd94b | |||
| d68fe6a2c0 | |||
| 7a54123e93 | |||
| 75a1f11a96 | |||
| 700e153740 | |||
| fd3b97225f | |||
| 0d1fe8e0e5 | |||
| b6d0150d44 | |||
| 55cd816cca | |||
| d67bd1daf8 | |||
| 4b56d3cfc6 | |||
| 88ed4360c0 | |||
| 7dfa69de2a | |||
| 4f53b899c9 | |||
| 9fe3a2193d | |||
| 804adb7347 | |||
| 9c6b7dd727 | |||
| f6e337aa98 | |||
| cbe83efe1f | |||
| 112d58a92a | |||
| b834f777b5 | |||
| 5f476542e2 | |||
| f1a1ff3a8a | |||
| 29af9fac62 | |||
| 2998fad8e7 | |||
| b6e9514eb3 | |||
| b175706da1 | |||
| 381dd95543 | |||
| 78ab52b762 | |||
| d9013b8f7a | |||
| 5d29cd919d | |||
| 9170930e8e | |||
| f7c9a1ab80 | |||
| af07a73a07 | |||
| 865285fbb7 | |||
| 77c22916a8 | |||
| 08d1479a01 | |||
| 7ea1c3ebf0 | |||
| 81918b00cd | |||
| 3b384dc5fb | |||
| 5d819b0234 | |||
| 260a898e3e | |||
| 15ac634d15 | |||
| 1a52fe80f3 | |||
| 20ef4ee295 | |||
| d1dbde95e4 | |||
| 7c238701b7 | |||
| c5c8b15e75 | |||
| 9bc3c7c8fc | |||
| 0476315322 |
@@ -31,7 +31,9 @@ backend/dist/
|
|||||||
# E2E Testing
|
# E2E Testing
|
||||||
e2e/node_modules/
|
e2e/node_modules/
|
||||||
e2e/test-results/
|
e2e/test-results/
|
||||||
|
e2e/test-results-user/
|
||||||
e2e/playwright-report/
|
e2e/playwright-report/
|
||||||
|
e2e/playwright-report-user/
|
||||||
e2e/.playwright/
|
e2e/.playwright/
|
||||||
|
|
||||||
# Temporary files
|
# Temporary files
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ ExcaliDash/
|
|||||||
**Backend (.env):**
|
**Backend (.env):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DATABASE_URL="file:./prisma/dev.db"
|
DATABASE_URL="file:./dev.db"
|
||||||
PORT=8000
|
PORT=8000
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Fork Summary
|
||||||
|
|
||||||
|
This fork adds optional security features and UX improvements with **zero breaking changes** and **minimal migration overhead**. All security features are **disabled by default** via feature flags.
|
||||||
|
|
||||||
|
## Security Features Added
|
||||||
|
|
||||||
|
1. **Password Reset** - Token-based password reset flow (`/auth/password-reset-request`, `/auth/password-reset-confirm`)
|
||||||
|
2. **Refresh Token Rotation** - Prevents token reuse by rotating refresh tokens on each use
|
||||||
|
3. **Audit Logging** - Logs security events (logins, password changes, deletions) for compliance
|
||||||
|
|
||||||
|
## UX Improvements Added
|
||||||
|
|
||||||
|
1. **Profile Page** - View and edit personal information, change password (`/profile`)
|
||||||
|
2. **Select All Button** - Quick selection of all drawings in current view
|
||||||
|
3. **Sort Dropdown** - Improved sort controls with icons and separate direction toggle
|
||||||
|
4. **Auto-hide Header** - Editor header auto-hides to maximize drawing space (with toggle)
|
||||||
|
|
||||||
|
## Backward Compatibility
|
||||||
|
|
||||||
|
✅ All security features disabled by default
|
||||||
|
✅ No breaking changes to existing code
|
||||||
|
✅ Graceful degradation (missing tables don't cause errors)
|
||||||
|
✅ Optional database migration
|
||||||
|
|
||||||
|
## Enable Security Features
|
||||||
|
|
||||||
|
Set in `backend/.env`:
|
||||||
|
```bash
|
||||||
|
ENABLE_PASSWORD_RESET=true
|
||||||
|
ENABLE_REFRESH_TOKEN_ROTATION=true
|
||||||
|
ENABLE_AUDIT_LOGGING=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run migration:
|
||||||
|
```bash
|
||||||
|
cd backend && npx prisma migrate deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
|
||||||
|
**For base project:** Keep features disabled (default) - no migration needed, zero risk.
|
||||||
|
|
||||||
|
**For this fork:** Enable features via environment variables when ready.
|
||||||
|
|
||||||
|
## Database Changes
|
||||||
|
|
||||||
|
Migration adds 3 optional tables (only used when features enabled):
|
||||||
|
- `PasswordResetToken` - For password reset flow
|
||||||
|
- `RefreshToken` - For token rotation tracking
|
||||||
|
- `AuditLog` - For security event logging
|
||||||
|
|
||||||
|
## Code Changes
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- Feature flags in `backend/src/config.ts`
|
||||||
|
- Conditional logic in auth endpoints
|
||||||
|
- Graceful error handling for missing tables
|
||||||
|
- New endpoints: `/auth/profile` (PUT), `/auth/change-password` (POST)
|
||||||
|
- Audit logging utility (`backend/src/utils/audit.ts`)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- Password reset pages (`/reset-password`, `/reset-password-confirm`)
|
||||||
|
- Profile page (`/profile`)
|
||||||
|
- Select All button in Dashboard
|
||||||
|
- Sort dropdown with icons
|
||||||
|
- Auto-hide header in Editor with toggle
|
||||||
|
- Updated API client for token rotation
|
||||||
|
|
||||||
|
All changes are backward compatible and optional.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<img src="logoExcaliDash.png" alt="ExcaliDash Logo" width="80" height="88">
|
<img src="logoExcaliDash.png" alt="ExcaliDash Logo" width="80" height="88">
|
||||||
|
|
||||||
# ExcaliDash v0.1.8
|
# ExcaliDash
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
@@ -22,6 +22,8 @@ A self-hosted dashboard and organizer for [Excalidraw](https://github.com/excali
|
|||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
- [Docker Hub (Recommended)](#dockerhub-recommended)
|
- [Docker Hub (Recommended)](#dockerhub-recommended)
|
||||||
- [Docker Build](#docker-build)
|
- [Docker Build](#docker-build)
|
||||||
|
- [Reverse Proxy / Traefik Setups](#reverse-proxy--traefik-setups-docker)
|
||||||
|
- [Multi-Container / Kubernetes Deployments](#multi-container--kubernetes-deployments)
|
||||||
- [Development](#development)
|
- [Development](#development)
|
||||||
- [Clone the Repository](#clone-the-repository)
|
- [Clone the Repository](#clone-the-repository)
|
||||||
- [Frontend](#frontend)
|
- [Frontend](#frontend)
|
||||||
@@ -75,7 +77,7 @@ See [release notes](https://github.com/ZimengXiong/ExcaliDash/releases) for a sp
|
|||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> NOT for production use. While attempts have been made at hardening (XSS/dompurify, CORS, rate-limiting, sanitization), they are inadequate for public deployment. Do not expose any ports. Currently lacking CSRF.
|
> NOT for production use. While attempts have been made at hardening (XSS/dompurify, CORS, rate-limiting, sanitization), they are inadequate for public deployment. Do not expose any ports.
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> ExcaliDash is in BETA. Please backup your data regularly (e.g. with cron).
|
> ExcaliDash is in BETA. Please backup your data regularly (e.g. with cron).
|
||||||
@@ -118,14 +120,17 @@ docker compose up -d
|
|||||||
|
|
||||||
When running ExcaliDash behind Traefik, Nginx, or another reverse proxy, configure both containers so that API + WebSocket calls resolve correctly:
|
When running ExcaliDash behind Traefik, Nginx, or another reverse proxy, configure both containers so that API + WebSocket calls resolve correctly:
|
||||||
|
|
||||||
- `FRONTEND_URL` (backend) must match the public URL that users hit (e.g. `https://excalidash.example.com`). This controls CORS and Socket.IO origin checks.
|
- `FRONTEND_URL` (backend) must match the public URL that users hit (e.g. `https://excalidash.example.com`). This controls CORS and Socket.IO origin checks. **Supports multiple comma-separated URLs** for accessing from different addresses.
|
||||||
- `BACKEND_URL` (frontend) tells the Nginx container how to reach the backend from inside Docker/Kubernetes. Override it if your reverse proxy exposes the backend under a different hostname.
|
- `BACKEND_URL` (frontend) tells the Nginx container how to reach the backend from inside Docker/Kubernetes. Override it if your reverse proxy exposes the backend under a different hostname.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# docker-compose.yml example
|
# docker-compose.yml example
|
||||||
backend:
|
backend:
|
||||||
environment:
|
environment:
|
||||||
|
# Single URL
|
||||||
- FRONTEND_URL=https://excalidash.example.com
|
- FRONTEND_URL=https://excalidash.example.com
|
||||||
|
# Or multiple URLs (comma-separated) for local + network access
|
||||||
|
# - FRONTEND_URL=http://localhost:6767,http://192.168.1.100:6767,http://nas.local:6767
|
||||||
frontend:
|
frontend:
|
||||||
environment:
|
environment:
|
||||||
# For standard Docker Compose (default)
|
# For standard Docker Compose (default)
|
||||||
@@ -134,6 +139,24 @@ frontend:
|
|||||||
- BACKEND_URL=excalidash-backend.default.svc.cluster.local:8000
|
- BACKEND_URL=excalidash-backend.default.svc.cluster.local:8000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Multi-Container / Kubernetes Deployments
|
||||||
|
|
||||||
|
When running multiple backend replicas (e.g., Kubernetes, Docker Swarm, or load-balanced containers), you **must** set the `CSRF_SECRET` environment variable to the same value across all instances.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate a secure secret
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml or k8s deployment
|
||||||
|
backend:
|
||||||
|
environment:
|
||||||
|
- CSRF_SECRET=your-generated-secret-here
|
||||||
|
```
|
||||||
|
|
||||||
|
Without this, each container generates its own ephemeral CSRF secret, causing token validation failures when requests are routed to different replicas. Single-container deployments work without this setting.
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
|
|
||||||
## Clone the Repository
|
## Clone the Repository
|
||||||
|
|||||||
+31
-18
@@ -1,30 +1,43 @@
|
|||||||
# ExcaliDash v0.1.5
|
CSRF Protection (8a78b2b)
|
||||||
|
|
||||||
Date: 2025-11-23
|
- Implemented comprehensive CSRF (Cross-Site Request Forgery) protection for enhanced security
|
||||||
|
- Added new backend/src/security.ts module for security utilities
|
||||||
|
- Frontend API layer now handles CSRF tokens automatically
|
||||||
|
- Added integration tests for CSRF validation
|
||||||
|
|
||||||
Compatibility: v0.1.x (Backward Compatible)
|
Upload Progress Indicator (8f9b9b4)
|
||||||
|
|
||||||
# Security
|
- Added a visual upload progress bar when users upload files
|
||||||
|
- New UploadContext for managing upload state across components
|
||||||
|
- New UploadStatus component displaying real-time upload progress
|
||||||
|
- Save status indicator when navigating back from the editor
|
||||||
|
- Improved error handling and recovery for failed uploads
|
||||||
|
|
||||||
- RCE: implemented strict Zod schema validation and input sanitization on file uploads; added path traversal guards to file handling logic
|
Bug Fixes
|
||||||
|
|
||||||
- XSS: used DOMPurify for HTML sanitization; blocked execution-capable SVG attributes and enforces CSP headers.
|
- Fixed broken e2e tests (cae8f3c)
|
||||||
|
- Replaced deprecated substr() with substring()
|
||||||
|
- Fixed stale state issues in error handling
|
||||||
|
- Fixed missing useEffect dependencies
|
||||||
|
- Fixed CSS class conflicts in progress bar styling
|
||||||
|
- Added error recovery for save state in Editor
|
||||||
|
|
||||||
- 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
|
Infrastructure
|
||||||
|
|
||||||
# Infras & Deployment
|
- Updated docker-compose configurations with new environment variables
|
||||||
|
- E2E test suite improvements and reliability fixes
|
||||||
|
- Added Kubernetes deployment note in README
|
||||||
|
|
||||||
- non-root execution (uid 1001) in containers
|
### Kubernetes
|
||||||
- migrated to multi-stage Docker builds
|
|
||||||
|
|
||||||
# Database
|
A `CSRF_SECRET` environment variable is now required for CSRF protection. Generate a secure 32+ character random string:
|
||||||
|
|
||||||
- migrated to better-sqlite3, converted all DB interactions to non-blocking async operations and offloaded integrity checks to worker threads.
|
```bash
|
||||||
|
openssl rand -base64 32
|
||||||
|
|
||||||
- implemented SQLite magic header validation; added automatic backup triggers preceding data import
|
Add it to your deployment:
|
||||||
|
- Docker Compose: Add CSRF_SECRET=<your-secret> to the backend service environment
|
||||||
|
- Kubernetes: Add to your ConfigMap/Secret and reference in the backend deployment
|
||||||
|
|
||||||
- input validation logic
|
If not set, the backend will refuse to start.
|
||||||
|
```
|
||||||
# Frontend
|
|
||||||
|
|
||||||
- updated Settings UI to show version
|
|
||||||
|
|||||||
@@ -2,4 +2,11 @@
|
|||||||
PORT=8000
|
PORT=8000
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
DATABASE_URL=file:/app/prisma/dev.db
|
DATABASE_URL=file:/app/prisma/dev.db
|
||||||
FRONTEND_URL=http://localhost:6767
|
FRONTEND_URL=http://localhost:6767
|
||||||
|
JWT_SECRET=change-this-secret-in-production-min-32-chars
|
||||||
|
|
||||||
|
# Optional Feature Flags (all default to false for backward compatibility)
|
||||||
|
# Set to "true" or "1" to enable:
|
||||||
|
# ENABLE_PASSWORD_RESET=false
|
||||||
|
# ENABLE_REFRESH_TOKEN_ROTATION=false
|
||||||
|
# ENABLE_AUDIT_LOGGING=false
|
||||||
|
|||||||
Generated
+503
-376
File diff suppressed because it is too large
Load Diff
+16
-3
@@ -1,10 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "0.1.8",
|
"version": "0.3.2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"predev": "node scripts/predev-migrate.cjs",
|
||||||
"dev": "nodemon src/index.ts",
|
"dev": "nodemon src/index.ts",
|
||||||
|
"admin:recover": "node scripts/admin-recover.cjs",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
@@ -16,19 +18,30 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
"@types/jsdom": "^27.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/jsdom": "^21.1.7",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/ms": "^2.1.0",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/socket.io": "^3.0.1",
|
"@types/socket.io": "^3.0.1",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"better-sqlite3": "^12.4.6",
|
"better-sqlite3": "^12.4.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dompurify": "^3.3.0",
|
"dompurify": "^3.3.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"jsdom": "^27.2.0",
|
"express-rate-limit": "^8.2.1",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
|
"jsdom": "^22.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"ms": "^2.1.3",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"prisma": "^5.22.0",
|
"prisma": "^5.22.0",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
-- NOTE:
|
||||||
|
-- This migration assigns all pre-existing data to a bootstrap admin user so that
|
||||||
|
-- upgrading an existing (non-empty) database doesn't fail and the data remains accessible.
|
||||||
|
-- The bootstrap admin user starts inactive and must be activated via the app's
|
||||||
|
-- initial registration flow.
|
||||||
|
|
||||||
|
-- Constants
|
||||||
|
-- Keep in sync with backend/src/auth.ts
|
||||||
|
-- (SQLite doesn't support variables; we inline the values instead.)
|
||||||
|
-- BOOTSTRAP_USER_ID = 'bootstrap-admin'
|
||||||
|
-- BOOTSTRAP_LIBRARY_ID = 'user_bootstrap-admin'
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"username" TEXT,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"role" TEXT NOT NULL DEFAULT 'USER',
|
||||||
|
"mustResetPassword" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SystemConfig" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY DEFAULT 'default',
|
||||||
|
"registrationEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Bootstrap state:
|
||||||
|
-- - Insert a singleton config row (registration disabled by default)
|
||||||
|
-- - Insert an inactive bootstrap admin user and assign all existing data to it
|
||||||
|
INSERT INTO "SystemConfig" ("id", "registrationEnabled", "createdAt", "updatedAt")
|
||||||
|
VALUES ('default', false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||||
|
|
||||||
|
INSERT INTO "User" ("id", "username", "email", "passwordHash", "name", "role", "mustResetPassword", "isActive", "createdAt", "updatedAt")
|
||||||
|
VALUES ('bootstrap-admin', NULL, 'bootstrap@excalidash.local', '', 'Bootstrap Admin', 'ADMIN', true, false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||||
|
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_Collection" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "Collection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Collection" ("createdAt", "id", "name", "userId", "updatedAt")
|
||||||
|
SELECT "createdAt", "id", "name", 'bootstrap-admin', "updatedAt" FROM "Collection";
|
||||||
|
DROP TABLE "Collection";
|
||||||
|
ALTER TABLE "new_Collection" RENAME TO "Collection";
|
||||||
|
CREATE TABLE "new_Drawing" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"elements" TEXT NOT NULL,
|
||||||
|
"appState" TEXT NOT NULL,
|
||||||
|
"files" TEXT NOT NULL DEFAULT '{}',
|
||||||
|
"preview" TEXT,
|
||||||
|
"version" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"collectionId" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "Drawing_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "Drawing_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Drawing" ("appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", "userId", "updatedAt", "version")
|
||||||
|
SELECT "appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", 'bootstrap-admin', "updatedAt", "version" FROM "Drawing";
|
||||||
|
DROP TABLE "Drawing";
|
||||||
|
ALTER TABLE "new_Drawing" RENAME TO "Drawing";
|
||||||
|
CREATE TABLE "new_Library" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"items" TEXT NOT NULL DEFAULT '[]',
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
-- Migrate the singleton library to the bootstrap user's library key.
|
||||||
|
INSERT INTO "new_Library" ("createdAt", "id", "items", "updatedAt")
|
||||||
|
SELECT "createdAt", 'user_bootstrap-admin', "items", "updatedAt" FROM "Library" WHERE "id" = 'default';
|
||||||
|
DROP TABLE "Library";
|
||||||
|
ALTER TABLE "new_Library" RENAME TO "Library";
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PasswordResetToken" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"expiresAt" DATETIME NOT NULL,
|
||||||
|
"used" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RefreshToken" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"expiresAt" DATETIME NOT NULL,
|
||||||
|
"revoked" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AuditLog" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"resource" TEXT,
|
||||||
|
"ipAddress" TEXT,
|
||||||
|
"userAgent" TEXT,
|
||||||
|
"details" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token");
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Add authEnabled flag to SystemConfig to support single-user mode by default.
|
||||||
|
|
||||||
|
-- SQLite supports simple ADD COLUMN for non-null with default.
|
||||||
|
ALTER TABLE "SystemConfig" ADD COLUMN "authEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "SystemConfig" ADD COLUMN "authLoginRateLimitEnabled" BOOLEAN NOT NULL DEFAULT 1;
|
||||||
|
ALTER TABLE "SystemConfig" ADD COLUMN "authLoginRateLimitWindowMs" INTEGER NOT NULL DEFAULT 900000;
|
||||||
|
ALTER TABLE "SystemConfig" ADD COLUMN "authLoginRateLimitMax" INTEGER NOT NULL DEFAULT 20;
|
||||||
|
|
||||||
@@ -12,9 +12,40 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
username String? @unique
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
name String
|
||||||
|
role String @default("USER")
|
||||||
|
mustResetPassword Boolean @default(false)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
drawings Drawing[]
|
||||||
|
collections Collection[]
|
||||||
|
passwordResetTokens PasswordResetToken[]
|
||||||
|
refreshTokens RefreshToken[]
|
||||||
|
auditLogs AuditLog[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model SystemConfig {
|
||||||
|
id String @id @default("default")
|
||||||
|
authEnabled Boolean @default(false)
|
||||||
|
registrationEnabled Boolean @default(false)
|
||||||
|
authLoginRateLimitEnabled Boolean @default(true)
|
||||||
|
authLoginRateLimitWindowMs Int @default(900000) // 15 minutes
|
||||||
|
authLoginRateLimitMax Int @default(20)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
model Collection {
|
model Collection {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
drawings Drawing[]
|
drawings Drawing[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -28,6 +59,8 @@ model Drawing {
|
|||||||
files String @default("{}") // Stored as JSON string
|
files String @default("{}") // Stored as JSON string
|
||||||
preview String? // SVG string for thumbnail
|
preview String? // SVG string for thumbnail
|
||||||
version Int @default(1)
|
version Int @default(1)
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
collectionId String?
|
collectionId String?
|
||||||
collection Collection? @relation(fields: [collectionId], references: [id])
|
collection Collection? @relation(fields: [collectionId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -35,8 +68,40 @@ model Drawing {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Library {
|
model Library {
|
||||||
id String @id @default("default") // Singleton pattern - use "default" ID
|
id String @id // User-specific library ID (e.g., "user_<userId>")
|
||||||
items String @default("[]") // Stored as JSON string array of library items
|
items String @default("[]") // Stored as JSON string array of library items
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PasswordResetToken {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
token String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
used Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model RefreshToken {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
token String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
revoked Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String?
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
action String // e.g., "login", "login_failed", "password_reset", "password_changed", "drawing_deleted"
|
||||||
|
resource String? // e.g., "drawing:123", "collection:456"
|
||||||
|
ipAddress String?
|
||||||
|
userAgent String?
|
||||||
|
details String? // JSON string for additional details
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI admin password recovery for ExcaliDash.
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* node scripts/admin-recover.cjs --identifier admin@example.com --password "NewStrongPassword!"
|
||||||
|
* node scripts/admin-recover.cjs --identifier admin@example.com --generate
|
||||||
|
*
|
||||||
|
* Notes:
|
||||||
|
* - Works with SQLite DATABASE_URL (default: file:./prisma/dev.db).
|
||||||
|
* - Sets the password hash and clears mustResetPassword by default.
|
||||||
|
* - If there are no active admins, this script can promote the target user to ADMIN.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require("dotenv").config();
|
||||||
|
|
||||||
|
const path = require("path");
|
||||||
|
process.env.DATABASE_URL =
|
||||||
|
process.env.DATABASE_URL ||
|
||||||
|
`file:${path.resolve(__dirname, "../prisma/dev.db")}`;
|
||||||
|
|
||||||
|
const { PrismaClient } = require("../src/generated/client");
|
||||||
|
const bcrypt = require("bcrypt");
|
||||||
|
|
||||||
|
const parseArgs = (argv) => {
|
||||||
|
const args = {};
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const token = argv[i];
|
||||||
|
if (!token.startsWith("--")) continue;
|
||||||
|
const key = token.slice(2);
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (!next || next.startsWith("--")) {
|
||||||
|
args[key] = true;
|
||||||
|
} else {
|
||||||
|
args[key] = next;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generatePassword = () => {
|
||||||
|
// 24 chars base64url-ish
|
||||||
|
const buf = require("crypto").randomBytes(18);
|
||||||
|
return buf.toString("base64").replace(/[+/=]/g, "").slice(0, 24);
|
||||||
|
};
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
const identifier = typeof args.identifier === "string" ? args.identifier.trim() : "";
|
||||||
|
const providedPassword = typeof args.password === "string" ? args.password : null;
|
||||||
|
const generate = Boolean(args.generate);
|
||||||
|
const setMustReset = Boolean(args["must-reset"]);
|
||||||
|
const activate = Boolean(args.activate);
|
||||||
|
const promote = Boolean(args.promote);
|
||||||
|
const disableLoginRateLimit = Boolean(args["disable-login-rate-limit"]);
|
||||||
|
|
||||||
|
if (!identifier) {
|
||||||
|
console.error("Missing --identifier (email or username).");
|
||||||
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newPassword = providedPassword;
|
||||||
|
if (!newPassword) {
|
||||||
|
if (!generate) {
|
||||||
|
console.error('Provide --password "<new password>" or pass --generate.');
|
||||||
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
newPassword = generatePassword();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
console.error("Password must be at least 8 characters.");
|
||||||
|
process.exitCode = 2;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activeAdminCount = await prisma.user.count({
|
||||||
|
where: { role: "ADMIN", isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const trimmed = identifier.toLowerCase();
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [{ email: trimmed }, { username: identifier }],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
role: true,
|
||||||
|
isActive: true,
|
||||||
|
mustResetPassword: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.error("User not found:", identifier);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldPromote = promote || activeAdminCount === 0;
|
||||||
|
|
||||||
|
if (user.role !== "ADMIN" && !shouldPromote) {
|
||||||
|
console.error("Target user is not an ADMIN. Refusing to reset password for non-admin user.");
|
||||||
|
console.error("Tip: pass --promote to promote this user to ADMIN, or use it only when there are 0 active admins.");
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saltRounds = 10;
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, saltRounds);
|
||||||
|
|
||||||
|
if (disableLoginRateLimit) {
|
||||||
|
await prisma.systemConfig.upsert({
|
||||||
|
where: { id: "default" },
|
||||||
|
update: { authLoginRateLimitEnabled: false },
|
||||||
|
create: {
|
||||||
|
id: "default",
|
||||||
|
authEnabled: true,
|
||||||
|
registrationEnabled: false,
|
||||||
|
authLoginRateLimitEnabled: false,
|
||||||
|
authLoginRateLimitWindowMs: 15 * 60 * 1000,
|
||||||
|
authLoginRateLimitMax: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
passwordHash,
|
||||||
|
mustResetPassword: setMustReset ? true : false,
|
||||||
|
isActive: activate ? true : user.isActive,
|
||||||
|
role: shouldPromote ? "ADMIN" : user.role,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
role: true,
|
||||||
|
isActive: true,
|
||||||
|
mustResetPassword: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Updated admin account:");
|
||||||
|
console.log(`- id: ${updated.id}`);
|
||||||
|
console.log(`- email: ${updated.email}`);
|
||||||
|
console.log(`- username: ${updated.username || ""}`);
|
||||||
|
console.log(`- isActive: ${updated.isActive}`);
|
||||||
|
console.log(`- mustResetPassword: ${updated.mustResetPassword}`);
|
||||||
|
console.log(`- role: ${updated.role}`);
|
||||||
|
if (disableLoginRateLimit) {
|
||||||
|
console.log("");
|
||||||
|
console.log("Login rate limiting: DISABLED (SystemConfig.authLoginRateLimitEnabled=false).");
|
||||||
|
console.log("Remember to re-enable it from the Admin dashboard after you regain access.");
|
||||||
|
}
|
||||||
|
if (generate || !providedPassword) {
|
||||||
|
console.log("");
|
||||||
|
console.log("New password:");
|
||||||
|
console.log(newPassword);
|
||||||
|
} else {
|
||||||
|
console.log("");
|
||||||
|
console.log("Password updated.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect().catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Admin recovery failed:", err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
const { execSync } = require("child_process");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const backendRoot = path.resolve(__dirname, "..");
|
||||||
|
|
||||||
|
const resolveDatabaseUrl = (rawUrl) => {
|
||||||
|
const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db");
|
||||||
|
|
||||||
|
if (!rawUrl || String(rawUrl).trim().length === 0) {
|
||||||
|
return `file:${defaultDbPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!String(rawUrl).startsWith("file:")) {
|
||||||
|
return String(rawUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = String(rawUrl).replace(/^file:/, "");
|
||||||
|
const prismaDir = path.resolve(backendRoot, "prisma");
|
||||||
|
const normalizedRelative = filePath.replace(/^\.\/?/, "");
|
||||||
|
const hasLeadingPrismaDir =
|
||||||
|
normalizedRelative === "prisma" || normalizedRelative.startsWith("prisma/");
|
||||||
|
|
||||||
|
const absolutePath = path.isAbsolute(filePath)
|
||||||
|
? filePath
|
||||||
|
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
|
||||||
|
|
||||||
|
return `file:${absolutePath}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const databaseUrl = resolveDatabaseUrl(process.env.DATABASE_URL);
|
||||||
|
process.env.DATABASE_URL = databaseUrl;
|
||||||
|
|
||||||
|
const nodeEnv = process.env.NODE_ENV || "development";
|
||||||
|
|
||||||
|
const runCapture = (cmd) => {
|
||||||
|
try {
|
||||||
|
const stdout = execSync(cmd, {
|
||||||
|
cwd: backendRoot,
|
||||||
|
encoding: "utf8",
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
env: { ...process.env, DATABASE_URL: databaseUrl },
|
||||||
|
});
|
||||||
|
return { ok: true, stdout: stdout || "", stderr: "" };
|
||||||
|
} catch (error) {
|
||||||
|
const err = error;
|
||||||
|
const stderr =
|
||||||
|
err && err.stderr
|
||||||
|
? Buffer.isBuffer(err.stderr)
|
||||||
|
? err.stderr.toString("utf8")
|
||||||
|
: String(err.stderr)
|
||||||
|
: "";
|
||||||
|
const stdout =
|
||||||
|
err && err.stdout
|
||||||
|
? Buffer.isBuffer(err.stdout)
|
||||||
|
? err.stdout.toString("utf8")
|
||||||
|
: String(err.stdout)
|
||||||
|
: "";
|
||||||
|
return { ok: false, stdout, stderr, error: err };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = (cmd) => {
|
||||||
|
execSync(cmd, {
|
||||||
|
cwd: backendRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
env: { ...process.env, DATABASE_URL: databaseUrl },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDbFilePath = () => {
|
||||||
|
if (!databaseUrl.startsWith("file:")) return null;
|
||||||
|
return databaseUrl.replace(/^file:/, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const backupDbIfPresent = () => {
|
||||||
|
const dbPath = getDbFilePath();
|
||||||
|
if (!dbPath) return null;
|
||||||
|
if (!fs.existsSync(dbPath)) return null;
|
||||||
|
|
||||||
|
const dir = path.dirname(dbPath);
|
||||||
|
const base = path.basename(dbPath, path.extname(dbPath));
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
const backupPath = path.join(dir, `${base}.${stamp}.backup`);
|
||||||
|
|
||||||
|
fs.copyFileSync(dbPath, backupPath);
|
||||||
|
return backupPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isNonProd = nodeEnv !== "production";
|
||||||
|
const isFileDb = databaseUrl.startsWith("file:");
|
||||||
|
|
||||||
|
const deploy = runCapture("npx prisma migrate deploy");
|
||||||
|
if (deploy.ok) {
|
||||||
|
if (deploy.stdout) process.stdout.write(deploy.stdout);
|
||||||
|
} else {
|
||||||
|
if (deploy.stdout) process.stdout.write(deploy.stdout);
|
||||||
|
if (deploy.stderr) process.stderr.write(deploy.stderr);
|
||||||
|
|
||||||
|
const stderr = deploy.stderr || "";
|
||||||
|
const isP3005 = stderr.includes("P3005");
|
||||||
|
|
||||||
|
// Common when an older dev.db exists but migrations weren't used previously.
|
||||||
|
if (isNonProd && isFileDb && isP3005) {
|
||||||
|
const backupPath = backupDbIfPresent();
|
||||||
|
console.warn(
|
||||||
|
`[predev] Prisma migrate baseline required (P3005). Resetting local SQLite database.\n` +
|
||||||
|
` DATABASE_URL=${databaseUrl}\n` +
|
||||||
|
(backupPath ? ` Backup: ${backupPath}\n` : "") +
|
||||||
|
` If you need to preserve local data, restore the backup and baseline manually.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
run("npx prisma migrate reset --force --skip-seed");
|
||||||
|
} else {
|
||||||
|
throw deploy.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Issue #38: CSRF fails with multiple reverse proxies
|
||||||
|
*
|
||||||
|
* This test demonstrates how trust proxy settings affect CSRF validation
|
||||||
|
* when ExcaliDash is behind multiple proxy layers (e.g., Traefik, Synology NAS)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
import {
|
||||||
|
createCsrfToken,
|
||||||
|
validateCsrfToken,
|
||||||
|
getCsrfTokenHeader,
|
||||||
|
} from "../security";
|
||||||
|
|
||||||
|
// mock the getClientId function behavior
|
||||||
|
const getClientIdFromRequest = (req: express.Request): string => {
|
||||||
|
const ip = req.ip || req.connection.remoteAddress || "unknown";
|
||||||
|
const userAgent = req.headers["user-agent"] || "unknown";
|
||||||
|
return `${ip}:${userAgent}`.slice(0, 256);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Issue #38: CSRF with trust proxy settings", () => {
|
||||||
|
let app: express.Application;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("demonstrates the trust proxy issue with multiple proxies", async () => {
|
||||||
|
// ext proxy -> frontend nginx -> backend
|
||||||
|
// X-Forwarded-For: 203.0.113.42 (client), 10.0.0.5 (external proxy), 172.17.0.3 (frontend nginx)
|
||||||
|
|
||||||
|
// With trust proxy: 1 (current setting)
|
||||||
|
const app1 = express();
|
||||||
|
app1.set("trust proxy", 1);
|
||||||
|
app1.use(express.json());
|
||||||
|
|
||||||
|
app1.get("/test-ip", (req, res) => {
|
||||||
|
res.json({
|
||||||
|
ip: req.ip,
|
||||||
|
clientId: getClientIdFromRequest(req),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate request through multiple proxies
|
||||||
|
const response1 = await request(app1)
|
||||||
|
.get("/test-ip")
|
||||||
|
.set("X-Forwarded-For", "203.0.113.42, 10.0.0.5, 172.17.0.3")
|
||||||
|
.set("User-Agent", "Mozilla/5.0 Test");
|
||||||
|
|
||||||
|
// With trust proxy: 1 in supertest (no real socket), Express takes the last IP
|
||||||
|
// In production with a real connection, behavior differs - the key point is it's NOT the client IP
|
||||||
|
expect(response1.body.ip).toBe("172.17.0.3");
|
||||||
|
console.log(
|
||||||
|
"trust proxy: 1 → IP:",
|
||||||
|
response1.body.ip,
|
||||||
|
"(not the real client IP)",
|
||||||
|
);
|
||||||
|
|
||||||
|
// With trust proxy: true
|
||||||
|
const app2 = express();
|
||||||
|
app2.set("trust proxy", true);
|
||||||
|
app2.use(express.json());
|
||||||
|
|
||||||
|
app2.get("/test-ip", (req, res) => {
|
||||||
|
res.json({
|
||||||
|
ip: req.ip,
|
||||||
|
clientId: getClientIdFromRequest(req),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const response2 = await request(app2)
|
||||||
|
.get("/test-ip")
|
||||||
|
.set("X-Forwarded-For", "203.0.113.42, 10.0.0.5, 172.17.0.3")
|
||||||
|
.set("User-Agent", "Mozilla/5.0 Test");
|
||||||
|
|
||||||
|
// With trust proxy: true, Express takes leftmost IP
|
||||||
|
expect(response2.body.ip).toBe("203.0.113.42");
|
||||||
|
console.log(
|
||||||
|
"trust proxy: true → IP:",
|
||||||
|
response2.body.ip,
|
||||||
|
"(real client IP - CORRECT)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("simulates CSRF failure scenario from issue #38", async () => {
|
||||||
|
const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)";
|
||||||
|
|
||||||
|
// Request 1: Fetch CSRF token
|
||||||
|
// X-Forwarded-For shows: client, external-proxy-1, frontend-nginx
|
||||||
|
const clientIp1 = "203.0.113.42";
|
||||||
|
const externalProxyIp1 = "10.0.0.5"; // External proxy IP on first request
|
||||||
|
|
||||||
|
// With trust proxy: 1, Express sees the external proxy IP
|
||||||
|
const clientId1 = `${externalProxyIp1}:${userAgent}`;
|
||||||
|
const token = createCsrfToken(clientId1);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
" X-Forwarded-For:",
|
||||||
|
`${clientIp1}, ${externalProxyIp1}, 172.17.0.3`,
|
||||||
|
);
|
||||||
|
console.log(" Express sees IP:", externalProxyIp1);
|
||||||
|
console.log(" ClientId:", clientId1.slice(0, 50) + "...");
|
||||||
|
|
||||||
|
// Request 2: Try to create drawing with token
|
||||||
|
// External proxy IP might differ slightly
|
||||||
|
const externalProxyIp2 = "10.0.0.6";
|
||||||
|
|
||||||
|
const clientId2 = `${externalProxyIp2}:${userAgent}`;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
" X-Forwarded-For:",
|
||||||
|
`${clientIp1}, ${externalProxyIp2}, 172.17.0.3`,
|
||||||
|
);
|
||||||
|
console.log(" Express sees IP:", externalProxyIp2);
|
||||||
|
console.log(" ClientId:", clientId2.slice(0, 50) + "...");
|
||||||
|
|
||||||
|
// CSRF validation fails because clientId changed
|
||||||
|
const isValid = validateCsrfToken(clientId2, token);
|
||||||
|
|
||||||
|
expect(isValid).toBe(false);
|
||||||
|
console.log(" Expected:", clientId1.slice(0, 50) + "...");
|
||||||
|
console.log(" Got:", clientId2.slice(0, 50) + "...");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the fix works with trust proxy: true", async () => {
|
||||||
|
const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)";
|
||||||
|
const realClientIp = "203.0.113.42";
|
||||||
|
|
||||||
|
const clientId1 = `${realClientIp}:${userAgent}`;
|
||||||
|
const token = createCsrfToken(clientId1);
|
||||||
|
|
||||||
|
console.log(" X-Forwarded-For:", `${realClientIp}, 10.0.0.5, 172.17.0.3`);
|
||||||
|
console.log(" Express sees IP:", realClientIp);
|
||||||
|
|
||||||
|
// Request 2: Use token (even if middle proxy IPs differ)
|
||||||
|
const clientId2 = `${realClientIp}:${userAgent}`;
|
||||||
|
|
||||||
|
console.log("Create drawing");
|
||||||
|
console.log("X-Forwarded-For:", `${realClientIp}, 10.0.0.6, 172.17.0.3`);
|
||||||
|
console.log("Express sees IP:", realClientIp, "(same!)");
|
||||||
|
|
||||||
|
const isValid = validateCsrfToken(clientId2, token);
|
||||||
|
|
||||||
|
expect(isValid).toBe(true);
|
||||||
|
console.log("\nCSRF Validation: SUCCESS");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("demonstrates the Synology NAS scenario from issue #38", async () => {
|
||||||
|
const app = express();
|
||||||
|
app.set("trust proxy", 1);
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
let seenIp: string | undefined;
|
||||||
|
app.get("/test", (req, res) => {
|
||||||
|
seenIp = req.ip;
|
||||||
|
res.json({ ip: req.ip });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Client -> Synology (192.168.1.x) -> Docker frontend (192.168.11.x) -> Backend
|
||||||
|
// In supertest without real socket, trust proxy: 1 returns last IP
|
||||||
|
// Key point: it's NOT the real client IP (192.168.0.100)
|
||||||
|
await request(app)
|
||||||
|
.get("/test")
|
||||||
|
.set("X-Forwarded-For", "192.168.0.100, 192.168.1.4, 192.168.11.166");
|
||||||
|
console.log(" With trust proxy: 1, Express sees:", seenIp);
|
||||||
|
expect(seenIp).toBe("192.168.11.166"); // Not the real client IP
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* CSRF Tests - Horizontal Scaling (K8s) Validation
|
||||||
|
*
|
||||||
|
* PR #20 review concern:
|
||||||
|
* "Worried that in memory token store might not work on horizontal scaling"
|
||||||
|
*
|
||||||
|
* Fix:
|
||||||
|
* - CSRF tokens are now stateless and HMAC-signed using a shared `CSRF_SECRET`.
|
||||||
|
* - Any pod can validate any token as long as all pods share the same secret.
|
||||||
|
*
|
||||||
|
* These tests prove:
|
||||||
|
* - Tokens validate correctly for the issuing client id
|
||||||
|
* - Tokens do NOT validate for a different client id
|
||||||
|
* - Tokens expire after 24 hours
|
||||||
|
* - Tokens validate across separate module instances (simulated pods)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, afterEach, vi } from "vitest";
|
||||||
|
|
||||||
|
const SHARED_SECRET = "test-shared-csrf-secret";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
// Must be shared across instances/pods for horizontal scaling.
|
||||||
|
process.env.CSRF_SECRET = SHARED_SECRET;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CSRF - stateless HMAC tokens", () => {
|
||||||
|
it("creates a token in payload.signature format and validates for same client id", async () => {
|
||||||
|
const { createCsrfToken, validateCsrfToken } = await import("../security");
|
||||||
|
|
||||||
|
const clientId = "test-client-1";
|
||||||
|
const token = createCsrfToken(clientId);
|
||||||
|
|
||||||
|
expect(typeof token).toBe("string");
|
||||||
|
// base64url(payload).base64url(signature)
|
||||||
|
expect(token).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
|
||||||
|
expect(validateCsrfToken(clientId, token)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects validation for a different client id (token binding)", async () => {
|
||||||
|
const { createCsrfToken, validateCsrfToken } = await import("../security");
|
||||||
|
|
||||||
|
const token = createCsrfToken("client-a");
|
||||||
|
expect(validateCsrfToken("client-b", token)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed tokens", async () => {
|
||||||
|
const { validateCsrfToken } = await import("../security");
|
||||||
|
|
||||||
|
expect(validateCsrfToken("client", "not-a-token")).toBe(false);
|
||||||
|
expect(validateCsrfToken("client", "a.b.c")).toBe(false);
|
||||||
|
expect(validateCsrfToken("client", "")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("revokeCsrfToken is a no-op for stateless tokens (does not break callers)", async () => {
|
||||||
|
const { createCsrfToken, validateCsrfToken, revokeCsrfToken } = await import(
|
||||||
|
"../security"
|
||||||
|
);
|
||||||
|
|
||||||
|
const clientId = "client-revoke";
|
||||||
|
const token = createCsrfToken(clientId);
|
||||||
|
|
||||||
|
expect(validateCsrfToken(clientId, token)).toBe(true);
|
||||||
|
revokeCsrfToken(clientId);
|
||||||
|
// Stateless token remains valid until expiry
|
||||||
|
expect(validateCsrfToken(clientId, token)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expires tokens after 24 hours", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2025-01-01T00:00:00.000Z"));
|
||||||
|
|
||||||
|
const { createCsrfToken, validateCsrfToken } = await import("../security");
|
||||||
|
|
||||||
|
const clientId = "client-expiry";
|
||||||
|
const token = createCsrfToken(clientId);
|
||||||
|
expect(validateCsrfToken(clientId, token)).toBe(true);
|
||||||
|
|
||||||
|
// 24h + 1ms later
|
||||||
|
vi.setSystemTime(new Date("2025-01-02T00:00:00.001Z"));
|
||||||
|
expect(validateCsrfToken(clientId, token)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CSRF - horizontal scaling (simulated pods)", () => {
|
||||||
|
it("validates across module instances (pod A issues, pod B validates)", async () => {
|
||||||
|
const clientId = "user-123";
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
const podA = await import("../security");
|
||||||
|
const token = podA.createCsrfToken(clientId);
|
||||||
|
|
||||||
|
// Simulate a different pod (new Node.js process / fresh module state)
|
||||||
|
vi.resetModules();
|
||||||
|
const podB = await import("../security");
|
||||||
|
|
||||||
|
expect(podB.validateCsrfToken(clientId, token)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has 0% failure rate under round-robin validation across 3 pods", async () => {
|
||||||
|
const clientId = "user-round-robin";
|
||||||
|
|
||||||
|
const pods: Array<{
|
||||||
|
createCsrfToken: (clientId: string) => string;
|
||||||
|
validateCsrfToken: (clientId: string, token: string) => boolean;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
vi.resetModules();
|
||||||
|
pods.push(await import("../security"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token issued on one pod
|
||||||
|
const token = pods[0].createCsrfToken(clientId);
|
||||||
|
|
||||||
|
// Validate on alternating pods (simulates a non-sticky load balancer)
|
||||||
|
const attempts = 60;
|
||||||
|
let failures = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < attempts; i++) {
|
||||||
|
const pod = pods[i % pods.length];
|
||||||
|
if (!pod.validateCsrfToken(clientId, token)) failures++;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(failures).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CSRF - referer origin parsing", () => {
|
||||||
|
it("extracts exact origin from a referer URL", async () => {
|
||||||
|
const { getOriginFromReferer } = await import("../security");
|
||||||
|
|
||||||
|
expect(getOriginFromReferer("https://example.com/path?x=1")).toBe(
|
||||||
|
"https://example.com"
|
||||||
|
);
|
||||||
|
expect(getOriginFromReferer("http://localhost:5173/some/page")).toBe(
|
||||||
|
"http://localhost:5173"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not allow prefix tricks (origin must be parsed)", async () => {
|
||||||
|
const { getOriginFromReferer } = await import("../security");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getOriginFromReferer("https://example.com.evil.com/anything")
|
||||||
|
).toBe("https://example.com.evil.com");
|
||||||
|
|
||||||
|
// `startsWith("https://example.com")` would incorrectly allow this.
|
||||||
|
expect(getOriginFromReferer("https://example.com@evil.com/anything")).toBe(
|
||||||
|
"https://evil.com"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for invalid or non-http(s) referers", async () => {
|
||||||
|
const { getOriginFromReferer } = await import("../security");
|
||||||
|
|
||||||
|
expect(getOriginFromReferer("")).toBeNull();
|
||||||
|
expect(getOriginFromReferer("not a url")).toBeNull();
|
||||||
|
expect(getOriginFromReferer("file:///etc/passwd")).toBeNull();
|
||||||
|
expect(getOriginFromReferer(null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -315,10 +315,11 @@ describe("Security Sanitization - Image Data URLs", () => {
|
|||||||
// Database integration tests
|
// Database integration tests
|
||||||
describe("Drawing API - Database Round-Trip", () => {
|
describe("Drawing API - Database Round-Trip", () => {
|
||||||
const prisma = getTestPrisma();
|
const prisma = getTestPrisma();
|
||||||
|
let testUser: { id: string };
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
setupTestDb();
|
setupTestDb();
|
||||||
await initTestDb(prisma);
|
testUser = await initTestDb(prisma);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -343,6 +344,7 @@ describe("Drawing API - Database Round-Trip", () => {
|
|||||||
elements: JSON.stringify([]),
|
elements: JSON.stringify([]),
|
||||||
appState: JSON.stringify({ viewBackgroundColor: "#ffffff" }),
|
appState: JSON.stringify({ viewBackgroundColor: "#ffffff" }),
|
||||||
files: JSON.stringify(files),
|
files: JSON.stringify(files),
|
||||||
|
userId: testUser.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -381,6 +383,7 @@ describe("Drawing API - Database Round-Trip", () => {
|
|||||||
elements: JSON.stringify([]),
|
elements: JSON.stringify([]),
|
||||||
appState: JSON.stringify({}),
|
appState: JSON.stringify({}),
|
||||||
files: JSON.stringify(files),
|
files: JSON.stringify(files),
|
||||||
|
userId: testUser.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -404,6 +407,7 @@ describe("Drawing API - Database Round-Trip", () => {
|
|||||||
elements: JSON.stringify([]),
|
elements: JSON.stringify([]),
|
||||||
appState: JSON.stringify({}),
|
appState: JSON.stringify({}),
|
||||||
files: JSON.stringify({}),
|
files: JSON.stringify({}),
|
||||||
|
userId: testUser.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,53 @@
|
|||||||
* Test utilities for backend integration tests
|
* Test utilities for backend integration tests
|
||||||
*/
|
*/
|
||||||
import { PrismaClient } from "../generated/client";
|
import { PrismaClient } from "../generated/client";
|
||||||
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { execSync } from "child_process";
|
import { execSync } from "child_process";
|
||||||
|
|
||||||
// Use a separate test database
|
// Use a unique test database per test-file import to avoid cross-file contention
|
||||||
const TEST_DB_PATH = path.resolve(__dirname, "../../prisma/test.db");
|
// when Vitest runs test files in parallel.
|
||||||
|
const TEST_DB_FILENAME = `test.${process.pid}.${Math.random().toString(16).slice(2)}.db`;
|
||||||
|
const TEST_DB_PATH = path.resolve(__dirname, "../../prisma", TEST_DB_FILENAME);
|
||||||
|
const DB_PUSH_LOCK_PATH = path.resolve(__dirname, "../../prisma/.test-db-push.lock");
|
||||||
|
|
||||||
|
const sleepSync = (ms: number) => {
|
||||||
|
const shared = new Int32Array(new SharedArrayBuffer(4));
|
||||||
|
Atomics.wait(shared, 0, 0, ms);
|
||||||
|
};
|
||||||
|
|
||||||
|
const withDbPushLock = (fn: () => void) => {
|
||||||
|
const start = Date.now();
|
||||||
|
let fd: number | null = null;
|
||||||
|
while (fd === null) {
|
||||||
|
try {
|
||||||
|
fd = fs.openSync(DB_PUSH_LOCK_PATH, "wx");
|
||||||
|
fs.writeFileSync(fd, String(process.pid));
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as NodeJS.ErrnoException;
|
||||||
|
if (err.code !== "EEXIST") throw error;
|
||||||
|
if (Date.now() - start > 30_000) {
|
||||||
|
throw new Error("Timed out waiting for Prisma db push lock");
|
||||||
|
}
|
||||||
|
sleepSync(50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
fs.closeSync(fd);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(DB_PUSH_LOCK_PATH);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a test Prisma client pointing to the test database
|
* Get a test Prisma client pointing to the test database
|
||||||
@@ -32,10 +74,19 @@ export const setupTestDb = () => {
|
|||||||
|
|
||||||
// Run Prisma migrations to create the test database
|
// Run Prisma migrations to create the test database
|
||||||
try {
|
try {
|
||||||
execSync("npx prisma db push --skip-generate", {
|
withDbPushLock(() => {
|
||||||
cwd: path.resolve(__dirname, "../../"),
|
execSync("npx prisma db push --skip-generate --force-reset", {
|
||||||
env: { ...process.env, DATABASE_URL: databaseUrl },
|
cwd: path.resolve(__dirname, "../../"),
|
||||||
stdio: "pipe",
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DATABASE_URL: databaseUrl,
|
||||||
|
// Work around Prisma schema engine failures on this repo's schema
|
||||||
|
// (seen as a blank "Schema engine error:" from `prisma db push`).
|
||||||
|
// `RUST_LOG=info` reliably avoids the failure mode.
|
||||||
|
RUST_LOG: "info",
|
||||||
|
},
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to setup test database:", error);
|
console.error("Failed to setup test database:", error);
|
||||||
@@ -54,19 +105,42 @@ export const cleanupTestDb = async (prisma: PrismaClient) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a test user for testing
|
||||||
|
*/
|
||||||
|
export const createTestUser = async (prisma: PrismaClient, email: string = "test@example.com") => {
|
||||||
|
const bcrypt = require("bcrypt");
|
||||||
|
const passwordHash = await bcrypt.hash("testpassword", 10);
|
||||||
|
|
||||||
|
return await prisma.user.upsert({
|
||||||
|
where: { email },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
name: "Test User",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize test database with required data
|
* Initialize test database with required data
|
||||||
*/
|
*/
|
||||||
export const initTestDb = async (prisma: PrismaClient) => {
|
export const initTestDb = async (prisma: PrismaClient) => {
|
||||||
|
// Create a test user first
|
||||||
|
const testUser = await createTestUser(prisma);
|
||||||
|
|
||||||
// Ensure Trash collection exists
|
// Ensure Trash collection exists
|
||||||
const trash = await prisma.collection.findUnique({
|
const trash = await prisma.collection.findUnique({
|
||||||
where: { id: "trash" },
|
where: { id: "trash" },
|
||||||
});
|
});
|
||||||
if (!trash) {
|
if (!trash) {
|
||||||
await prisma.collection.create({
|
await prisma.collection.create({
|
||||||
data: { id: "trash", name: "Trash" },
|
data: { id: "trash", name: "Trash", userId: testUser.id },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return testUser;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* Security tests for user data sandboxing
|
||||||
|
*
|
||||||
|
* Verifies that:
|
||||||
|
* 1. Drawings cache keys are scoped by userId (prevents cross-user data leakage)
|
||||||
|
* 2. Drawing CRUD operations enforce userId filtering
|
||||||
|
* 3. Collection operations enforce userId filtering
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import {
|
||||||
|
getTestPrisma,
|
||||||
|
cleanupTestDb,
|
||||||
|
setupTestDb,
|
||||||
|
createTestDrawingPayload,
|
||||||
|
} from "./testUtils";
|
||||||
|
import { PrismaClient } from "../generated/client";
|
||||||
|
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
|
||||||
|
// These tests verify the data isolation logic at the database query level
|
||||||
|
describe("User Data Sandboxing", () => {
|
||||||
|
let userA: { id: string; email: string };
|
||||||
|
let userB: { id: string; email: string };
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
setupTestDb();
|
||||||
|
prisma = getTestPrisma();
|
||||||
|
|
||||||
|
// Create two test users
|
||||||
|
const hashA = await bcrypt.hash("passwordA", 10);
|
||||||
|
const hashB = await bcrypt.hash("passwordB", 10);
|
||||||
|
|
||||||
|
userA = await prisma.user.upsert({
|
||||||
|
where: { email: "usera@test.com" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: "usera@test.com",
|
||||||
|
passwordHash: hashA,
|
||||||
|
name: "User A",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
userB = await prisma.user.upsert({
|
||||||
|
where: { email: "userb@test.com" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: "userb@test.com",
|
||||||
|
passwordHash: hashB,
|
||||||
|
name: "User B",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.drawing.deleteMany({});
|
||||||
|
await prisma.collection.deleteMany({});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Drawing isolation", () => {
|
||||||
|
it("should not return User A's drawings when querying as User B", async () => {
|
||||||
|
// Create a drawing for User A
|
||||||
|
await prisma.drawing.create({
|
||||||
|
data: {
|
||||||
|
name: "User A Drawing",
|
||||||
|
elements: "[]",
|
||||||
|
appState: "{}",
|
||||||
|
userId: userA.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query as User B - should get 0 results
|
||||||
|
const userBDrawings = await prisma.drawing.findMany({
|
||||||
|
where: { userId: userB.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(userBDrawings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should only return the owning user's drawings", async () => {
|
||||||
|
// Create drawings for both users
|
||||||
|
await prisma.drawing.create({
|
||||||
|
data: {
|
||||||
|
name: "User A Drawing",
|
||||||
|
elements: "[]",
|
||||||
|
appState: "{}",
|
||||||
|
userId: userA.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.drawing.create({
|
||||||
|
data: {
|
||||||
|
name: "User B Drawing",
|
||||||
|
elements: "[]",
|
||||||
|
appState: "{}",
|
||||||
|
userId: userB.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const userADrawings = await prisma.drawing.findMany({
|
||||||
|
where: { userId: userA.id },
|
||||||
|
});
|
||||||
|
const userBDrawings = await prisma.drawing.findMany({
|
||||||
|
where: { userId: userB.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(userADrawings).toHaveLength(1);
|
||||||
|
expect(userADrawings[0].name).toBe("User A Drawing");
|
||||||
|
|
||||||
|
expect(userBDrawings).toHaveLength(1);
|
||||||
|
expect(userBDrawings[0].name).toBe("User B Drawing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not allow User B to access User A's drawing by ID", async () => {
|
||||||
|
const drawing = await prisma.drawing.create({
|
||||||
|
data: {
|
||||||
|
name: "User A Secret Drawing",
|
||||||
|
elements: "[]",
|
||||||
|
appState: "{}",
|
||||||
|
userId: userA.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate the findFirst query used in GET /drawings/:id
|
||||||
|
const result = await prisma.drawing.findFirst({
|
||||||
|
where: {
|
||||||
|
id: drawing.id,
|
||||||
|
userId: userB.id, // User B trying to access
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Collection isolation", () => {
|
||||||
|
it("should not return User A's collections when querying as User B", async () => {
|
||||||
|
await prisma.collection.create({
|
||||||
|
data: {
|
||||||
|
name: "User A Collection",
|
||||||
|
userId: userA.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const userBCollections = await prisma.collection.findMany({
|
||||||
|
where: { userId: userB.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(userBCollections).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not allow User B to modify User A's collection", async () => {
|
||||||
|
const collection = await prisma.collection.create({
|
||||||
|
data: {
|
||||||
|
name: "User A Collection",
|
||||||
|
userId: userA.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate the findFirst query used in PUT /collections/:id
|
||||||
|
const result = await prisma.collection.findFirst({
|
||||||
|
where: {
|
||||||
|
id: collection.id,
|
||||||
|
userId: userB.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Cache key user scoping", () => {
|
||||||
|
it("should generate different cache keys for different users with same query params", () => {
|
||||||
|
// This tests the buildDrawingsCacheKey function logic inline
|
||||||
|
// The function was updated to include userId in the cache key
|
||||||
|
const buildDrawingsCacheKey = (keyParts: {
|
||||||
|
userId: string;
|
||||||
|
searchTerm: string;
|
||||||
|
collectionFilter: string;
|
||||||
|
includeData: boolean;
|
||||||
|
}) =>
|
||||||
|
JSON.stringify([
|
||||||
|
keyParts.userId,
|
||||||
|
keyParts.searchTerm,
|
||||||
|
keyParts.collectionFilter,
|
||||||
|
keyParts.includeData ? "full" : "summary",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const keyA = buildDrawingsCacheKey({
|
||||||
|
userId: "user-a-id",
|
||||||
|
searchTerm: "",
|
||||||
|
collectionFilter: "default",
|
||||||
|
includeData: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const keyB = buildDrawingsCacheKey({
|
||||||
|
userId: "user-b-id",
|
||||||
|
searchTerm: "",
|
||||||
|
collectionFilter: "default",
|
||||||
|
includeData: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(keyA).not.toBe(keyB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate same cache key for same user with same query params", () => {
|
||||||
|
const buildDrawingsCacheKey = (keyParts: {
|
||||||
|
userId: string;
|
||||||
|
searchTerm: string;
|
||||||
|
collectionFilter: string;
|
||||||
|
includeData: boolean;
|
||||||
|
}) =>
|
||||||
|
JSON.stringify([
|
||||||
|
keyParts.userId,
|
||||||
|
keyParts.searchTerm,
|
||||||
|
keyParts.collectionFilter,
|
||||||
|
keyParts.includeData ? "full" : "summary",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const key1 = buildDrawingsCacheKey({
|
||||||
|
userId: "same-user",
|
||||||
|
searchTerm: "test",
|
||||||
|
collectionFilter: "default",
|
||||||
|
includeData: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const key2 = buildDrawingsCacheKey({
|
||||||
|
userId: "same-user",
|
||||||
|
searchTerm: "test",
|
||||||
|
collectionFilter: "default",
|
||||||
|
includeData: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(key1).toBe(key2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+2319
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Configuration validation and environment variable management
|
||||||
|
*/
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
interface Config {
|
||||||
|
port: number;
|
||||||
|
nodeEnv: string;
|
||||||
|
databaseUrl?: string;
|
||||||
|
frontendUrl?: string;
|
||||||
|
jwtSecret: string;
|
||||||
|
jwtAccessExpiresIn: string;
|
||||||
|
jwtRefreshExpiresIn: string;
|
||||||
|
rateLimitMaxRequests: number;
|
||||||
|
csrfMaxRequests: number;
|
||||||
|
csrfSecret: string | null;
|
||||||
|
// Feature flags - all default to false for backward compatibility
|
||||||
|
enablePasswordReset: boolean;
|
||||||
|
enableRefreshTokenRotation: boolean;
|
||||||
|
enableAuditLogging: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRequiredEnv = (key: string): string => {
|
||||||
|
const value = process.env[key];
|
||||||
|
if (!value || value.trim().length === 0) {
|
||||||
|
throw new Error(`Missing required environment variable: ${key}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOptionalEnv = (key: string, defaultValue: string): string => {
|
||||||
|
return process.env[key] || defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveJwtSecret = (nodeEnv: string): string => {
|
||||||
|
const provided = process.env.JWT_SECRET;
|
||||||
|
if (provided && provided.trim().length > 0) {
|
||||||
|
return provided;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeEnv === "production") {
|
||||||
|
throw new Error("Missing required environment variable: JWT_SECRET");
|
||||||
|
}
|
||||||
|
|
||||||
|
const generated = crypto.randomBytes(32).toString("hex");
|
||||||
|
console.warn(
|
||||||
|
"[security] JWT_SECRET is not set (non-production). Using an ephemeral secret; tokens will be invalidated on restart."
|
||||||
|
);
|
||||||
|
return generated;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseFrontendUrl = (raw: string | undefined): string | undefined => {
|
||||||
|
if (!raw || raw.trim().length === 0) return undefined;
|
||||||
|
const normalized = raw
|
||||||
|
.split(",")
|
||||||
|
.map((origin) => origin.trim())
|
||||||
|
.filter((origin) => origin.length > 0)
|
||||||
|
.join(",");
|
||||||
|
return normalized.length > 0 ? normalized : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveDatabaseUrl = (rawUrl?: string) => {
|
||||||
|
const backendRoot = path.resolve(__dirname, "../");
|
||||||
|
const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db");
|
||||||
|
|
||||||
|
if (!rawUrl || rawUrl.trim().length === 0) {
|
||||||
|
return `file:${defaultDbPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rawUrl.startsWith("file:")) {
|
||||||
|
return rawUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = rawUrl.replace(/^file:/, "");
|
||||||
|
const prismaDir = path.resolve(backendRoot, "prisma");
|
||||||
|
const normalizedRelative = filePath.replace(/^\.\/?/, "");
|
||||||
|
const hasLeadingPrismaDir =
|
||||||
|
normalizedRelative === "prisma" || normalizedRelative.startsWith("prisma/");
|
||||||
|
|
||||||
|
const absolutePath = path.isAbsolute(filePath)
|
||||||
|
? filePath
|
||||||
|
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
|
||||||
|
|
||||||
|
return `file:${absolutePath}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure DATABASE_URL is resolved before any PrismaClient is created.
|
||||||
|
process.env.DATABASE_URL = resolveDatabaseUrl(process.env.DATABASE_URL);
|
||||||
|
|
||||||
|
const getOptionalBoolean = (key: string, defaultValue: boolean): boolean => {
|
||||||
|
const value = process.env[key];
|
||||||
|
if (!value) return defaultValue;
|
||||||
|
return value.toLowerCase() === "true" || value === "1";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRequiredEnvNumber = (key: string, defaultValue: number): number => {
|
||||||
|
const value = process.env[key];
|
||||||
|
if (!value) return defaultValue;
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
throw new Error(`Invalid value for environment variable ${key}: must be a positive number`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config: Config = {
|
||||||
|
port: getRequiredEnvNumber("PORT", 8000),
|
||||||
|
nodeEnv: getOptionalEnv("NODE_ENV", "development"),
|
||||||
|
databaseUrl: process.env.DATABASE_URL,
|
||||||
|
frontendUrl: parseFrontendUrl(process.env.FRONTEND_URL),
|
||||||
|
jwtSecret: resolveJwtSecret(getOptionalEnv("NODE_ENV", "development")),
|
||||||
|
jwtAccessExpiresIn: getOptionalEnv("JWT_ACCESS_EXPIRES_IN", "15m"),
|
||||||
|
jwtRefreshExpiresIn: getOptionalEnv("JWT_REFRESH_EXPIRES_IN", "7d"),
|
||||||
|
rateLimitMaxRequests: getRequiredEnvNumber("RATE_LIMIT_MAX_REQUESTS", 1000),
|
||||||
|
csrfMaxRequests: getRequiredEnvNumber("CSRF_MAX_REQUESTS", 60),
|
||||||
|
csrfSecret: process.env.CSRF_SECRET || null,
|
||||||
|
// Feature flags - disabled by default for backward compatibility
|
||||||
|
enablePasswordReset: getOptionalBoolean("ENABLE_PASSWORD_RESET", false),
|
||||||
|
enableRefreshTokenRotation: getOptionalBoolean("ENABLE_REFRESH_TOKEN_ROTATION", false),
|
||||||
|
enableAuditLogging: getOptionalBoolean("ENABLE_AUDIT_LOGGING", false),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate JWT_SECRET strength in production
|
||||||
|
if (config.nodeEnv === "production") {
|
||||||
|
if (config.jwtSecret.length < 32) {
|
||||||
|
throw new Error("JWT_SECRET must be at least 32 characters long in production");
|
||||||
|
}
|
||||||
|
if (config.jwtSecret === "your-secret-key-change-in-production") {
|
||||||
|
throw new Error("JWT_SECRET must be changed from default value in production");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Configuration validated successfully");
|
||||||
+1616
-566
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user