Compare commits

..

2 Commits

Author SHA1 Message Date
Zimeng Xiong e9d349bb0e fix express proxy headers 2026-01-30 14:28:38 -08:00
Zimeng Xiong 6a84cc4ab7 repro issue 2026-01-30 14:19:24 -08:00
49 changed files with 995 additions and 10213 deletions
-69
View File
@@ -1,69 +0,0 @@
# 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 -4
View File
@@ -120,17 +120,14 @@ docker compose up -d
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. **Supports multiple comma-separated URLs** for accessing from different addresses.
- `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.
- `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
# docker-compose.yml example
backend:
environment:
# Single URL
- 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:
environment:
# For standard Docker Compose (default)
+1 -1
View File
@@ -1 +1 @@
0.3.2
0.3.1
+1 -8
View File
@@ -2,11 +2,4 @@
PORT=8000
NODE_ENV=production
DATABASE_URL=file:/app/prisma/dev.db
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
FRONTEND_URL=http://localhost:6767
+11 -312
View File
File diff suppressed because it is too large Load Diff
+1 -14
View File
@@ -1,12 +1,10 @@
{
"name": "backend",
"version": "0.3.2",
"version": "0.3.1",
"description": "",
"main": "index.js",
"scripts": {
"predev": "node scripts/predev-migrate.cjs",
"dev": "nodemon src/index.ts",
"admin:recover": "node scripts/admin-recover.cjs",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
@@ -18,30 +16,19 @@
"dependencies": {
"@prisma/client": "^5.22.0",
"@types/archiver": "^7.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/socket.io": "^3.0.1",
"@types/uuid": "^10.0.0",
"archiver": "^7.0.1",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.4.6",
"cors": "^2.8.5",
"dompurify": "^3.3.0",
"dotenv": "^17.2.3",
"express": "^5.1.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",
"prisma": "^5.22.0",
"socket.io": "^4.8.1",
"uuid": "^13.0.0",
"zod": "^4.1.12"
},
"devDependencies": {
@@ -1,96 +0,0 @@
-- 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");
@@ -1,40 +0,0 @@
-- 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");
@@ -1,5 +0,0 @@
-- 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;
@@ -1,5 +0,0 @@
-- 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;
+1 -66
View File
@@ -12,40 +12,9 @@ datasource db {
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 {
id String @id @default(uuid())
name String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
drawings Drawing[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -59,8 +28,6 @@ model Drawing {
files String @default("{}") // Stored as JSON string
preview String? // SVG string for thumbnail
version Int @default(1)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
collectionId String?
collection Collection? @relation(fields: [collectionId], references: [id])
createdAt DateTime @default(now())
@@ -68,40 +35,8 @@ model Drawing {
}
model Library {
id String @id // User-specific library ID (e.g., "user_<userId>")
id String @id @default("default") // Singleton pattern - use "default" ID
items String @default("[]") // Stored as JSON string array of library items
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
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())
}
-183
View File
@@ -1,183 +0,0 @@
#!/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;
});
-118
View File
@@ -1,118 +0,0 @@
/* 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;
}
}
@@ -51,13 +51,12 @@ describe("Issue #38: CSRF with trust proxy settings", () => {
.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");
// With trust proxy: 1, Express takes second-to-last IP (the external proxy)
expect(response1.body.ip).toBe("10.0.0.5");
console.log(
"trust proxy: 1 → IP:",
response1.body.ip,
"(not the real client IP)",
"(external proxy IP - WRONG)",
);
// With trust proxy: true
@@ -161,12 +160,10 @@ describe("Issue #38: CSRF with trust proxy settings", () => {
});
// 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
expect(seenIp).toBe("192.168.1.4"); // Proxy IP, not client IP
});
});
@@ -315,11 +315,10 @@ describe("Security Sanitization - Image Data URLs", () => {
// Database integration tests
describe("Drawing API - Database Round-Trip", () => {
const prisma = getTestPrisma();
let testUser: { id: string };
beforeAll(async () => {
setupTestDb();
testUser = await initTestDb(prisma);
await initTestDb(prisma);
});
afterAll(async () => {
@@ -344,7 +343,6 @@ describe("Drawing API - Database Round-Trip", () => {
elements: JSON.stringify([]),
appState: JSON.stringify({ viewBackgroundColor: "#ffffff" }),
files: JSON.stringify(files),
userId: testUser.id,
},
});
@@ -383,7 +381,6 @@ describe("Drawing API - Database Round-Trip", () => {
elements: JSON.stringify([]),
appState: JSON.stringify({}),
files: JSON.stringify(files),
userId: testUser.id,
},
});
@@ -407,7 +404,6 @@ describe("Drawing API - Database Round-Trip", () => {
elements: JSON.stringify([]),
appState: JSON.stringify({}),
files: JSON.stringify({}),
userId: testUser.id,
},
});
+7 -81
View File
@@ -2,53 +2,11 @@
* Test utilities for backend integration tests
*/
import { PrismaClient } from "../generated/client";
import fs from "fs";
import path from "path";
import { execSync } from "child_process";
// Use a unique test database per test-file import to avoid cross-file contention
// 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
}
}
};
// Use a separate test database
const TEST_DB_PATH = path.resolve(__dirname, "../../prisma/test.db");
/**
* Get a test Prisma client pointing to the test database
@@ -74,19 +32,10 @@ export const setupTestDb = () => {
// Run Prisma migrations to create the test database
try {
withDbPushLock(() => {
execSync("npx prisma db push --skip-generate --force-reset", {
cwd: path.resolve(__dirname, "../../"),
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",
});
execSync("npx prisma db push --skip-generate", {
cwd: path.resolve(__dirname, "../../"),
env: { ...process.env, DATABASE_URL: databaseUrl },
stdio: "pipe",
});
} catch (error) {
console.error("Failed to setup test database:", error);
@@ -105,42 +54,19 @@ 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
*/
export const initTestDb = async (prisma: PrismaClient) => {
// Create a test user first
const testUser = await createTestUser(prisma);
// Ensure Trash collection exists
const trash = await prisma.collection.findUnique({
where: { id: "trash" },
});
if (!trash) {
await prisma.collection.create({
data: { id: "trash", name: "Trash", userId: testUser.id },
data: { id: "trash", name: "Trash" },
});
}
return testUser;
};
/**
@@ -1,242 +0,0 @@
/**
* 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
View File
File diff suppressed because it is too large Load Diff
-137
View File
@@ -1,137 +0,0 @@
/**
* 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");
+607 -1480
View File
File diff suppressed because it is too large Load Diff
-341
View File
@@ -1,341 +0,0 @@
/**
* Authentication middleware for protecting routes
*/
import { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import { config } from "../config";
import { PrismaClient } from "../generated/client";
const prisma = new PrismaClient();
const DEFAULT_SYSTEM_CONFIG_ID = "default";
const BOOTSTRAP_USER_ID = "bootstrap-admin";
type AuthEnabledCache = {
value: boolean;
fetchedAt: number;
};
let authEnabledCache: AuthEnabledCache | null = null;
const AUTH_ENABLED_TTL_MS = 0;
const getAuthEnabled = async (): Promise<boolean> => {
const now = Date.now();
if (authEnabledCache && now - authEnabledCache.fetchedAt < AUTH_ENABLED_TTL_MS) {
return authEnabledCache.value;
}
const systemConfig = await prisma.systemConfig.upsert({
where: { id: DEFAULT_SYSTEM_CONFIG_ID },
update: {},
create: {
id: DEFAULT_SYSTEM_CONFIG_ID,
authEnabled: false,
registrationEnabled: false,
},
select: { authEnabled: true },
});
authEnabledCache = { value: systemConfig.authEnabled, fetchedAt: now };
return systemConfig.authEnabled;
};
const getBootstrapActingUser = async () => {
const user = await prisma.user.findUnique({
where: { id: BOOTSTRAP_USER_ID },
select: {
id: true,
username: true,
email: true,
name: true,
role: true,
mustResetPassword: true,
isActive: true,
},
});
if (user) return user;
return prisma.user.create({
data: {
id: BOOTSTRAP_USER_ID,
email: "bootstrap@excalidash.local",
username: null,
passwordHash: "",
name: "Bootstrap Admin",
role: "ADMIN",
mustResetPassword: true,
isActive: false,
},
select: {
id: true,
username: true,
email: true,
name: true,
role: true,
mustResetPassword: true,
isActive: true,
},
});
};
// Extend Express Request type to include user
declare global {
namespace Express {
interface Request {
user?: {
id: string;
username?: string | null;
email: string;
name: string;
role: string;
mustResetPassword?: boolean;
impersonatorId?: string;
};
}
}
}
interface JwtPayload {
userId: string;
email: string;
type: "access" | "refresh";
impersonatorId?: string;
}
/**
* Type guard to check if decoded JWT is our expected payload structure
*/
const isJwtPayload = (decoded: unknown): decoded is JwtPayload => {
if (typeof decoded !== "object" || decoded === null) {
return false;
}
const payload = decoded as Record<string, unknown>;
const impersonatorOk =
typeof payload.impersonatorId === "undefined" || typeof payload.impersonatorId === "string";
return (
typeof payload.userId === "string" &&
typeof payload.email === "string" &&
(payload.type === "access" || payload.type === "refresh") &&
impersonatorOk
);
};
/**
* Extract JWT token from Authorization header
*/
const extractToken = (req: Request): string | null => {
const authHeader = req.headers.authorization;
if (!authHeader || typeof authHeader !== "string") return null;
const parts = authHeader.split(" ");
if (parts.length !== 2 || parts[0] !== "Bearer") {
return null;
}
return parts[1];
};
/**
* Verify and decode JWT token
*/
const verifyToken = (token: string): JwtPayload | null => {
try {
const decoded = jwt.verify(token, config.jwtSecret);
if (!isJwtPayload(decoded)) {
return null;
}
if (decoded.type !== "access") {
return null; // Only accept access tokens in middleware
}
return decoded;
} catch {
return null;
}
};
const normalizeRequestPath = (req: Request): string => {
const raw = (req.originalUrl || req.url || "").split("?")[0] || "";
// In some deployments the backend may see a /api prefix.
return raw.replace(/^\/api(?=\/)/, "");
};
const isAllowedWhileMustResetPassword = (req: Request): boolean => {
const path = normalizeRequestPath(req);
// Permit fetching current user and changing password.
if (req.method === "GET" && path === "/auth/me") return true;
if (req.method === "POST" && path === "/auth/change-password") return true;
if (req.method === "POST" && path === "/auth/must-reset-password") return true;
return false;
};
/**
* Require authentication middleware
* Protects routes that require a valid JWT token
*/
export const requireAuth = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
// Single-user mode: authentication disabled -> treat all requests as the bootstrap user.
try {
const authEnabled = await getAuthEnabled();
if (!authEnabled) {
const user = await getBootstrapActingUser();
req.user = {
id: user.id,
username: user.username,
email: user.email,
name: user.name,
role: user.role,
mustResetPassword: user.mustResetPassword,
};
return next();
}
} catch (error) {
console.error("Error reading auth mode:", error);
res.status(500).json({
error: "Internal server error",
message: "Failed to read authentication mode",
});
return;
}
const token = extractToken(req);
if (!token) {
res.status(401).json({
error: "Unauthorized",
message: "Authentication token required",
});
return;
}
const payload = verifyToken(token);
if (!payload) {
res.status(401).json({
error: "Unauthorized",
message: "Invalid or expired token",
});
return;
}
// Verify user still exists and is active
try {
const user = await prisma.user.findUnique({
where: { id: payload.userId },
select: {
id: true,
username: true,
email: true,
name: true,
role: true,
mustResetPassword: true,
isActive: true,
},
});
if (!user || !user.isActive) {
res.status(401).json({
error: "Unauthorized",
message: "User account not found or inactive",
});
return;
}
if (user.mustResetPassword && !isAllowedWhileMustResetPassword(req)) {
res.status(403).json({
error: "Forbidden",
code: "MUST_RESET_PASSWORD",
message: "You must reset your password before using the app",
});
return;
}
// Attach user to request
req.user = {
id: user.id,
username: user.username,
email: user.email,
name: user.name,
role: user.role,
mustResetPassword: user.mustResetPassword,
impersonatorId: payload.impersonatorId,
};
next();
} catch (error) {
console.error("Error verifying user:", error);
res.status(500).json({
error: "Internal server error",
message: "Failed to verify user",
});
}
};
/**
* Optional authentication middleware
* Attaches user to request if token is present, but doesn't require it
*/
export const optionalAuth = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
const authEnabled = await getAuthEnabled();
if (!authEnabled) {
return next();
}
} catch (error) {
console.error("Error reading auth mode:", error);
return next();
}
const token = extractToken(req);
if (!token) {
return next();
}
const payload = verifyToken(token);
if (!payload) {
return next();
}
try {
const user = await prisma.user.findUnique({
where: { id: payload.userId },
select: {
id: true,
username: true,
email: true,
name: true,
role: true,
mustResetPassword: true,
isActive: true,
},
});
if (user && user.isActive) {
req.user = {
id: user.id,
username: user.username,
email: user.email,
name: user.name,
role: user.role,
mustResetPassword: user.mustResetPassword,
impersonatorId: payload.impersonatorId,
};
}
} catch (error) {
// Silently fail for optional auth
console.error("Error in optional auth:", error);
}
next();
};
-86
View File
@@ -1,86 +0,0 @@
/**
* Error handling middleware
* Sanitizes error messages in production to prevent information leakage
*/
import { Request, Response, NextFunction } from "express";
import { config } from "../config";
export interface AppError extends Error {
statusCode?: number;
isOperational?: boolean;
}
/**
* Error handler middleware
* Should be added last in the middleware chain
*/
export const errorHandler = (
err: AppError,
req: Request,
res: Response,
next: NextFunction
): void => {
const statusCode = err.statusCode || 500;
const isDevelopment = config.nodeEnv === "development";
// Log full error details server-side
console.error("Error:", {
message: err.message,
stack: err.stack,
statusCode,
path: req.path,
method: req.method,
timestamp: new Date().toISOString(),
});
// In production, don't expose internal error details
if (!isDevelopment) {
// Generic error messages for clients
if (statusCode >= 500) {
res.status(statusCode).json({
error: "Internal server error",
message: "An error occurred while processing your request",
});
return;
}
// For client errors (4xx), provide generic message
res.status(statusCode).json({
error: "Request error",
message: err.isOperational ? err.message : "Invalid request",
});
return;
}
// In development, show full error details
res.status(statusCode).json({
error: err.message,
stack: err.stack,
statusCode,
});
};
/**
* Async error wrapper
* Wraps async route handlers to catch errors
*/
export const asyncHandler = <T = void>(
fn: (req: Request, res: Response, next: NextFunction) => Promise<T>
) => {
return (req: Request, res: Response, next: NextFunction): void => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
/**
* Create an operational error (known error that can be safely shown to client)
*/
export const createError = (
message: string,
statusCode: number = 400
): AppError => {
const error: AppError = new Error(message);
error.statusCode = statusCode;
error.isOperational = true;
return error;
};
+16 -30
View File
@@ -30,9 +30,7 @@ let activeConfig: SecurityConfig = { ...defaultConfig };
* Configure security settings
* @param config Partial configuration to merge with defaults
*/
export const configureSecuritySettings = (
config: Partial<SecurityConfig>
): void => {
export const configureSecuritySettings = (config: Partial<SecurityConfig>): void => {
activeConfig = { ...activeConfig, ...config };
};
@@ -320,13 +318,10 @@ export const appStateSchema = z
.optional()
.nullable(),
currentItemRoundness: z
.union([
z.enum(["sharp", "round"]),
z.object({
type: z.enum(["round", "sharp"]),
value: z.number().finite().min(0).max(1),
}),
])
.object({
type: z.enum(["round", "sharp"]),
value: z.number().finite().min(0).max(1),
})
.optional()
.nullable(),
currentItemFontSize: z
@@ -432,19 +427,10 @@ export const sanitizeDrawingData = (data: {
];
// Dangerous URL protocols to block entirely
const dangerousProtocols = [
/^javascript:/i,
/^vbscript:/i,
/^data:text\/html/i,
];
const dangerousProtocols = [/^javascript:/i, /^vbscript:/i, /^data:text\/html/i];
// Suspicious patterns for security validation within data URLs
const suspiciousPatterns = [
/<script/i,
/javascript:/i,
/on\w+\s*=/i,
/<iframe/i,
];
const suspiciousPatterns = [/<script/i, /javascript:/i, /on\w+\s*=/i, /<iframe/i];
// Maximum size for dataURL (configurable, default 10MB to prevent DoS)
const MAX_DATAURL_SIZE = activeConfig.maxDataUrlSize;
@@ -462,8 +448,8 @@ export const sanitizeDrawingData = (data: {
const normalizedValue = value.toLowerCase();
// First, check for dangerous protocols - block these entirely
const hasDangerousProtocol = dangerousProtocols.some(
(pattern) => pattern.test(value)
const hasDangerousProtocol = dangerousProtocols.some((pattern) =>
pattern.test(value)
);
if (hasDangerousProtocol) {
@@ -479,8 +465,8 @@ export const sanitizeDrawingData = (data: {
if (isSafeImageType) {
// Check for suspicious content and size limits
const hasSuspiciousContent = suspiciousPatterns.some(
(pattern) => pattern.test(value)
const hasSuspiciousContent = suspiciousPatterns.some((pattern) =>
pattern.test(value)
);
const isTooLarge = value.length > MAX_DATAURL_SIZE;
@@ -584,11 +570,11 @@ const getCsrfSecret = (): Buffer => {
const envLabel = process.env.NODE_ENV ? ` (${process.env.NODE_ENV})` : "";
console.warn(
`[SECURITY WARNING] CSRF_SECRET is not set${envLabel}.\n` +
`Using an ephemeral per-process secret.\n` +
` - Tokens will expire on container restart\n` +
` - Horizontal scaling (k8s) will NOT work\n` +
` - Generate a secret: openssl rand -base64 32\n` +
` - Set environment variable: CSRF_SECRET=<generated-secret>`
`Using an ephemeral per-process secret.\n` +
` - Tokens will expire on container restart\n` +
` - Horizontal scaling (k8s) will NOT work\n` +
` - Generate a secret: openssl rand -base64 32\n` +
` - Set environment variable: CSRF_SECRET=<generated-secret>`
);
return cachedCsrfSecret;
};
-205
View File
@@ -1,205 +0,0 @@
/**
* Tests for audit logging utility
*
* These tests verify that audit logging works correctly when enabled
* and gracefully degrades when disabled or when tables don't exist.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import { getTestPrisma, setupTestDb, initTestDb, createTestUser } from "../../__tests__/testUtils";
import { logAuditEvent, getAuditLogs, type AuditLogData } from "../audit";
describe("Audit Logging", () => {
const prisma = getTestPrisma();
let testUser: { id: string; email: string };
beforeAll(async () => {
setupTestDb();
testUser = await initTestDb(prisma);
// Enable audit logging for tests
process.env.ENABLE_AUDIT_LOGGING = "true";
});
afterAll(async () => {
await prisma.$disconnect();
delete process.env.ENABLE_AUDIT_LOGGING;
});
beforeEach(async () => {
// Clean up audit logs before each test
await prisma.auditLog.deleteMany({});
});
describe("logAuditEvent", () => {
it("should create an audit log entry when enabled", async () => {
const auditData: AuditLogData = {
userId: testUser.id,
action: "test_action",
resource: "test_resource",
ipAddress: "127.0.0.1",
userAgent: "test-agent",
details: { test: "value" },
};
await logAuditEvent(auditData);
const logs = await prisma.auditLog.findMany({
where: { userId: testUser.id, action: "test_action" },
});
expect(logs.length).toBe(1);
expect(logs[0].action).toBe("test_action");
expect(logs[0].resource).toBe("test_resource");
expect(logs[0].ipAddress).toBe("127.0.0.1");
expect(logs[0].userAgent).toBe("test-agent");
expect(logs[0].details).toBe(JSON.stringify({ test: "value" }));
});
it("should handle audit log without userId", async () => {
const auditData: AuditLogData = {
action: "anonymous_action",
ipAddress: "127.0.0.1",
};
await logAuditEvent(auditData);
const logs = await prisma.auditLog.findMany({
where: { action: "anonymous_action" },
});
expect(logs.length).toBe(1);
expect(logs[0].userId).toBeNull();
});
it("should handle audit log without optional fields", async () => {
const auditData: AuditLogData = {
action: "minimal_action",
};
await logAuditEvent(auditData);
const logs = await prisma.auditLog.findMany({
where: { action: "minimal_action" },
});
expect(logs.length).toBe(1);
expect(logs[0].resource).toBeNull();
expect(logs[0].ipAddress).toBeNull();
expect(logs[0].userAgent).toBeNull();
expect(logs[0].details).toBeNull();
});
it("should gracefully handle when feature is disabled", async () => {
// Note: Config is cached, so we test the graceful error handling instead
// by checking that errors don't propagate
const auditData: AuditLogData = {
action: "should_not_log_disabled",
};
// Should not throw even if feature is disabled or table missing
await expect(logAuditEvent(auditData)).resolves.not.toThrow();
});
it("should serialize details object to JSON", async () => {
const complexDetails = {
nested: { value: 123 },
array: [1, 2, 3],
string: "test",
};
await logAuditEvent({
userId: testUser.id,
action: "complex_details",
details: complexDetails,
});
const logs = await prisma.auditLog.findMany({
where: { action: "complex_details" },
});
expect(logs.length).toBe(1);
const parsed = JSON.parse(logs[0].details || "{}");
expect(parsed).toEqual(complexDetails);
});
});
describe("getAuditLogs", () => {
beforeEach(async () => {
// Create some test audit logs
await prisma.auditLog.createMany({
data: [
{
userId: testUser.id,
action: "action_1",
createdAt: new Date("2025-01-01T10:00:00Z"),
},
{
userId: testUser.id,
action: "action_2",
createdAt: new Date("2025-01-01T11:00:00Z"),
},
{
userId: testUser.id,
action: "action_3",
createdAt: new Date("2025-01-01T12:00:00Z"),
},
],
});
});
it("should retrieve audit logs for a specific user", async () => {
const logs = await getAuditLogs(testUser.id);
expect(logs.length).toBe(3);
expect(logs[0].action).toBe("action_3"); // Most recent first
expect(logs[1].action).toBe("action_2");
expect(logs[2].action).toBe("action_1");
});
it("should retrieve all audit logs when userId is not provided", async () => {
// Create a log for another user
const otherUser = await createTestUser(prisma, "other@example.com");
await prisma.auditLog.create({
data: {
userId: otherUser.id,
action: "other_action",
},
});
const logs = await getAuditLogs();
expect(logs.length).toBeGreaterThanOrEqual(4);
});
it("should respect limit parameter", async () => {
const logs = await getAuditLogs(testUser.id, 2);
expect(logs.length).toBe(2);
});
it("should parse details JSON in returned logs", async () => {
await prisma.auditLog.create({
data: {
userId: testUser.id,
action: "with_details",
details: JSON.stringify({ key: "value" }),
},
});
const logs = await getAuditLogs(testUser.id, 1);
expect(logs.length).toBe(1);
expect((logs[0] as { details: unknown }).details).toEqual({ key: "value" });
});
it("should include user information in logs", async () => {
const logs = await getAuditLogs(testUser.id, 1);
expect(logs.length).toBe(1);
const log = logs[0] as { user: { id: string; email: string; name: string } };
expect(log.user).toBeDefined();
expect(log.user.id).toBe(testUser.id);
expect(log.user.email).toBe(testUser.email);
});
});
});
-115
View File
@@ -1,115 +0,0 @@
/**
* Audit logging utility for security events
*/
import { PrismaClient } from "../generated/client";
let prisma: PrismaClient | null = null;
const getPrisma = () => {
if (prisma) return prisma;
prisma = new PrismaClient();
return prisma;
};
export interface AuditLogData {
userId?: string;
action: string;
resource?: string;
ipAddress?: string;
userAgent?: string;
details?: Record<string, unknown>;
}
export interface AuditLogResult {
id: string;
userId: string | null;
action: string;
resource: string | null;
ipAddress: string | null;
userAgent: string | null;
details: unknown | null;
createdAt: Date;
user: { id: string; email: string; name: string } | null;
}
/**
* Log a security event to the audit log
* This should be called for important security-related actions
* Gracefully handles missing audit log table (feature disabled)
*/
export const logAuditEvent = async (data: AuditLogData): Promise<void> => {
try {
// Check if audit logging is enabled via config
const { config } = await import("../config");
if (!config.enableAuditLogging) {
return; // Feature disabled, silently skip
}
await getPrisma().auditLog.create({
data: {
userId: data.userId || null,
action: data.action,
resource: data.resource || null,
ipAddress: data.ipAddress || null,
userAgent: data.userAgent || null,
details: data.details ? JSON.stringify(data.details) : null,
},
});
} catch (error) {
// Don't fail the request if audit logging fails
// This handles cases where the table doesn't exist (feature disabled)
// or other database errors
if (process.env.NODE_ENV === "development") {
console.debug("Audit logging skipped (feature disabled or table missing):", error);
}
}
};
/**
* Get audit logs for a user (or all users if userId is not provided)
* Returns empty array if audit logging is disabled or table doesn't exist
*/
export const getAuditLogs = async (
userId?: string,
limit: number = 100
): Promise<AuditLogResult[]> => {
try {
// Check if audit logging is enabled via config
const { config } = await import("../config");
if (!config.enableAuditLogging) {
return []; // Feature disabled, return empty array
}
const logs = await getPrisma().auditLog.findMany({
where: userId ? { userId } : undefined,
orderBy: { createdAt: "desc" },
take: limit,
include: {
user: {
select: {
id: true,
email: true,
name: true,
},
},
},
});
return logs.map((log) => ({
...log,
details: (() => {
if (!log.details) return null;
try {
return JSON.parse(log.details) as unknown;
} catch {
return null;
}
})(),
}));
} catch (error) {
// Gracefully handle missing table or other errors
if (process.env.NODE_ENV === "development") {
console.debug("Failed to retrieve audit logs (feature disabled or table missing):", error);
}
return [];
}
};

Some files were not shown because too many files have changed in this diff Show More