Compare commits

..

7 Commits

Author SHA1 Message Date
Zimeng Xiong 7dfa69de2a fix export source and verisoning 2026-01-30 14:57:27 -08:00
Zimeng Xiong 81918b00cd chore: release v0.3.1 2026-01-20 13:41:22 -08:00
Zimeng Xiong 3b384dc5fb CSRF token validation failing behind nginx proxy (#38)
Express was not configured to trust proxy headers, causing req.ip to return nginx's internal container IP instead of the actual client IP. In Docker environments, nginx can appear with different internal IPs between requests, causing the CSRF clientId to change and token validation to fail.
2026-01-20 13:39:33 -08:00
Zimeng Xiong 7c238701b7 Update RELEASE.md with CSRF_SECRET instructions (#33)
Added instructions for the required CSRF_SECRET environment variable for CSRF protection in Kubernetes deployments.
2026-01-14 13:11:25 -08:00
Zimeng Xiong c5c8b15e75 Update README header to remove version number
Removed version number from README header.
2026-01-14 13:10:43 -08:00
Zimeng Xiong 9bc3c7c8fc chore: release v0.3.0 2026-01-14 11:26:20 -08:00
Zimeng Xiong 0476315322 0.2.1 Release (#32)
* feat(security): implement CSRF protection

* chore: clean up CSRF implementation

  - Remove unused generateCsrfToken export from security.ts
  - Remove redundant /csrf-token path check (GET already exempt)
  - Restore defineConfig wrapper in vitest.config.ts for type safety

* add K8S note in README, fix broken e2e

* feat/upload-bar (#30)

* feat/upload-bar: add a upload bar when user upload file, indicate the upload process

* feat/save-loading-status: add save status when click back button from editor

* fix: address PR review issues in upload and save features

- Replace deprecated substr() with substring() in UploadContext
- Fix broken error handling that checked stale task status
- Fix missing useEffect dependency in UploadStatus
- Fix CSS class conflict in progress bar styling
- Add error recovery for save state in Editor (reset on failure)
- Use .finally() instead of .then() to ensure refresh on upload failure
- Fix inconsistent indentation in UploadContext

* fix e2e tests

---------

Co-authored-by: Zimeng Xiong <zxzimeng@gmail.com>

* chore: pre-release v0.2.1-dev

* Update backend/src/security.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix filename/math random UUID generation

---------

Co-authored-by: AdrianAcala <adrianacala017@gmail.com>
Co-authored-by: adamant368 <60790941+Yiheng-Liu@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-14 11:25:27 -08:00
10 changed files with 106 additions and 67 deletions
+1 -1
View File
@@ -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
![License](https://img.shields.io/github/license/zimengxiong/ExcaliDash) ![License](https://img.shields.io/github/license/zimengxiong/ExcaliDash)
![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg) ![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)
+14
View File
@@ -27,3 +27,17 @@ CSRF Protection (8a78b2b)
- Updated docker-compose configurations with new environment variables - Updated docker-compose configurations with new environment variables
- E2E test suite improvements and reliability fixes - E2E test suite improvements and reliability fixes
- Added Kubernetes deployment note in README - Added Kubernetes deployment note in README
### Kubernetes
A `CSRF_SECRET` environment variable is now required for CSRF protection. Generate a secure 32+ character random string:
```bash
openssl rand -base64 32
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
If not set, the backend will refuse to start.
```
+1 -1
View File
@@ -1 +1 @@
0.2.1 0.3.1
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "backend", "name": "backend",
"version": "0.2.1", "version": "0.3.1",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+49 -28
View File
@@ -48,12 +48,14 @@ const resolveDatabaseUrl = (rawUrl?: string) => {
const prismaDir = path.resolve(backendRoot, "prisma"); const prismaDir = path.resolve(backendRoot, "prisma");
const normalizedRelative = filePath.replace(/^\.\/?/, ""); const normalizedRelative = filePath.replace(/^\.\/?/, "");
const hasLeadingPrismaDir = const hasLeadingPrismaDir =
normalizedRelative === "prisma" || normalizedRelative === "prisma" || normalizedRelative.startsWith("prisma/");
normalizedRelative.startsWith("prisma/");
const absolutePath = path.isAbsolute(filePath) const absolutePath = path.isAbsolute(filePath)
? filePath ? filePath
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative); : path.resolve(
hasLeadingPrismaDir ? backendRoot : prismaDir,
normalizedRelative,
);
return `file:${absolutePath}`; return `file:${absolutePath}`;
}; };
@@ -129,6 +131,12 @@ const initializeUploadDir = async () => {
}; };
const app = express(); const app = express();
// Trust proxy headers (X-Forwarded-For, X-Real-IP) from nginx
// Required for correct client IP detection when running behind a reverse proxy
// This fixes CSRF token validation failures in Docker/K8s environments
app.set("trust proxy", 1);
const httpServer = createServer(app); const httpServer = createServer(app);
const io = new Server(httpServer, { const io = new Server(httpServer, {
cors: { cors: {
@@ -140,7 +148,7 @@ const io = new Server(httpServer, {
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const parseJsonField = <T>( const parseJsonField = <T>(
rawValue: string | null | undefined, rawValue: string | null | undefined,
fallback: T fallback: T,
): T => { ): T => {
if (!rawValue) return fallback; if (!rawValue) return fallback;
try { try {
@@ -234,7 +242,7 @@ app.use(
credentials: true, credentials: true,
allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token"], allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token"],
exposedHeaders: ["x-csrf-token"], exposedHeaders: ["x-csrf-token"],
}) }),
); );
app.use(express.json({ limit: "50mb" })); app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ extended: true, limit: "50mb" })); app.use(express.urlencoded({ extended: true, limit: "50mb" }));
@@ -246,8 +254,8 @@ app.use((req, res, next) => {
if (sizeInMB > 10) { if (sizeInMB > 10) {
console.log( console.log(
`[LARGE REQUEST] ${req.method} ${req.path} - ${sizeInMB.toFixed( `[LARGE REQUEST] ${req.method} ${req.path} - ${sizeInMB.toFixed(
2 2,
)}MB - Content-Length: ${contentLength} bytes` )}MB - Content-Length: ${contentLength} bytes`,
); );
} }
} }
@@ -261,7 +269,7 @@ app.use((req, res, next) => {
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
res.setHeader( res.setHeader(
"Permissions-Policy", "Permissions-Policy",
"geolocation=(), microphone=(), camera=()" "geolocation=(), microphone=(), camera=()",
); );
res.setHeader( res.setHeader(
@@ -272,7 +280,7 @@ app.use((req, res, next) => {
"font-src 'self' https://fonts.gstatic.com; " + "font-src 'self' https://fonts.gstatic.com; " +
"img-src 'self' data: blob: https:; " + "img-src 'self' data: blob: https:; " +
"connect-src 'self' ws: wss:; " + "connect-src 'self' ws: wss:; " +
"frame-ancestors 'none';" "frame-ancestors 'none';",
); );
next(); next();
@@ -281,14 +289,17 @@ app.use((req, res, next) => {
const requestCounts = new Map<string, { count: number; resetTime: number }>(); const requestCounts = new Map<string, { count: number; resetTime: number }>();
const RATE_LIMIT_WINDOW = 15 * 60 * 1000; const RATE_LIMIT_WINDOW = 15 * 60 * 1000;
setInterval(() => { setInterval(
() => {
const now = Date.now(); const now = Date.now();
for (const [ip, data] of requestCounts.entries()) { for (const [ip, data] of requestCounts.entries()) {
if (now > data.resetTime) { if (now > data.resetTime) {
requestCounts.delete(ip); requestCounts.delete(ip);
} }
} }
}, 5 * 60 * 1000).unref(); },
5 * 60 * 1000,
).unref();
const RATE_LIMIT_MAX_REQUESTS = (() => { const RATE_LIMIT_MAX_REQUESTS = (() => {
const parsed = Number(process.env.RATE_LIMIT_MAX_REQUESTS); const parsed = Number(process.env.RATE_LIMIT_MAX_REQUESTS);
@@ -355,7 +366,10 @@ app.get("/csrf-token", (req, res) => {
} }
clientLimit.count++; clientLimit.count++;
} else { } else {
csrfRateLimit.set(ip, { count: 1, resetTime: now + CSRF_RATE_LIMIT_WINDOW }); csrfRateLimit.set(ip, {
count: 1,
resetTime: now + CSRF_RATE_LIMIT_WINDOW,
});
} }
// Cleanup old rate limit entries occasionally // Cleanup old rate limit entries occasionally
@@ -370,7 +384,7 @@ app.get("/csrf-token", (req, res) => {
res.json({ res.json({
token, token,
header: getCsrfTokenHeader() header: getCsrfTokenHeader(),
}); });
}); });
@@ -378,7 +392,7 @@ app.get("/csrf-token", (req, res) => {
const csrfProtectionMiddleware = ( const csrfProtectionMiddleware = (
req: express.Request, req: express.Request,
res: express.Response, res: express.Response,
next: express.NextFunction next: express.NextFunction,
) => { ) => {
// Skip CSRF validation for safe methods (GET, HEAD, OPTIONS) // Skip CSRF validation for safe methods (GET, HEAD, OPTIONS)
// Note: /csrf-token is a GET endpoint, so it's automatically exempt // Note: /csrf-token is a GET endpoint, so it's automatically exempt
@@ -471,7 +485,7 @@ const drawingCreateSchema = drawingBaseSchema
}, },
{ {
message: "Invalid or malicious drawing data detected", message: "Invalid or malicious drawing data detected",
} },
); );
const drawingUpdateSchema = drawingBaseSchema const drawingUpdateSchema = drawingBaseSchema
@@ -521,12 +535,12 @@ const drawingUpdateSchema = drawingBaseSchema
}, },
{ {
message: "Invalid or malicious drawing data detected", message: "Invalid or malicious drawing data detected",
} },
); );
const respondWithValidationErrors = ( const respondWithValidationErrors = (
res: express.Response, res: express.Response,
issues: z.ZodIssue[] issues: z.ZodIssue[],
) => { ) => {
res.status(400).json({ res.status(400).json({
error: "Invalid drawing payload", error: "Invalid drawing payload",
@@ -576,7 +590,7 @@ const verifyDatabaseIntegrityAsync = (filePath: string): Promise<boolean> => {
path.resolve(__dirname, "./workers/db-verify.js"), path.resolve(__dirname, "./workers/db-verify.js"),
{ {
workerData: { filePath }, workerData: { filePath },
} },
); );
let timeoutHandle: NodeJS.Timeout; let timeoutHandle: NodeJS.Timeout;
let settled = false; let settled = false;
@@ -651,7 +665,7 @@ io.on("connection", (socket) => {
roomUsers.set(roomId, filteredUsers); roomUsers.set(roomId, filteredUsers);
io.to(roomId).emit("presence-update", filteredUsers); io.to(roomId).emit("presence-update", filteredUsers);
} },
); );
socket.on("cursor-move", (data) => { socket.on("cursor-move", (data) => {
@@ -676,7 +690,7 @@ io.on("connection", (socket) => {
io.to(roomId).emit("presence-update", users); io.to(roomId).emit("presence-update", users);
} }
} }
} },
); );
socket.on("disconnect", () => { socket.on("disconnect", () => {
@@ -1067,8 +1081,9 @@ app.get("/export", async (req, res) => {
res.setHeader("Content-Type", "application/octet-stream"); res.setHeader("Content-Type", "application/octet-stream");
res.setHeader( res.setHeader(
"Content-Disposition", "Content-Disposition",
`attachment; filename="excalidash-db-${new Date().toISOString().split("T")[0] `attachment; filename="excalidash-db-${
}.${extension}"` new Date().toISOString().split("T")[0]
}.${extension}"`,
); );
const fileStream = fs.createReadStream(dbPath); const fileStream = fs.createReadStream(dbPath);
@@ -1090,8 +1105,9 @@ app.get("/export/json", async (req, res) => {
res.setHeader("Content-Type", "application/zip"); res.setHeader("Content-Type", "application/zip");
res.setHeader( res.setHeader(
"Content-Disposition", "Content-Disposition",
`attachment; filename="excalidraw-drawings-${new Date().toISOString().split("T")[0] `attachment; filename="excalidraw-drawings-${
}.zip"` new Date().toISOString().split("T")[0]
}.zip"`,
); );
const archive = archiver("zip", { zlib: { level: 9 } }); const archive = archiver("zip", { zlib: { level: 9 } });
@@ -1105,6 +1121,8 @@ app.get("/export/json", async (req, res) => {
const drawingsByCollection: { [key: string]: any[] } = {}; const drawingsByCollection: { [key: string]: any[] } = {};
const exportSource = `${req.protocol}://${req.get("host")}`;
drawings.forEach((drawing: any) => { drawings.forEach((drawing: any) => {
const collectionName = drawing.collection?.name || "Unorganized"; const collectionName = drawing.collection?.name || "Unorganized";
if (!drawingsByCollection[collectionName]) { if (!drawingsByCollection[collectionName]) {
@@ -1112,6 +1130,9 @@ app.get("/export/json", async (req, res) => {
} }
const drawingData = { const drawingData = {
type: "excalidraw",
version: 2,
source: exportSource,
elements: JSON.parse(drawing.elements), elements: JSON.parse(drawing.elements),
appState: JSON.parse(drawing.appState), appState: JSON.parse(drawing.appState),
files: JSON.parse(drawing.files || "{}"), files: JSON.parse(drawing.files || "{}"),
@@ -1129,7 +1150,7 @@ app.get("/export/json", async (req, res) => {
collectionDrawings.forEach((drawing, index) => { collectionDrawings.forEach((drawing, index) => {
const fileName = `${drawing.name.replace( const fileName = `${drawing.name.replace(
/[<>:"/\\|?*]/g, /[<>:"/\\|?*]/g,
"_" "_",
)}.excalidraw`; )}.excalidraw`;
const filePath = `${folderName}/${fileName}`; const filePath = `${folderName}/${fileName}`;
@@ -1137,7 +1158,7 @@ app.get("/export/json", async (req, res) => {
name: filePath, name: filePath,
}); });
}); });
} },
); );
const readmeContent = `ExcaliDash Export const readmeContent = `ExcaliDash Export
@@ -1201,7 +1222,7 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
const originalPath = req.file.path; const originalPath = req.file.path;
const stagedPath = path.join( const stagedPath = path.join(
uploadDir, uploadDir,
`temp-${Date.now()}-${Math.random().toString(36).slice(2)}.db` `temp-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
); );
try { try {
@@ -1228,7 +1249,7 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
try { try {
await fsPromises.access(dbPath); await fsPromises.access(dbPath);
await fsPromises.copyFile(dbPath, backupPath); await fsPromises.copyFile(dbPath, backupPath);
} catch { } } catch {}
await moveFile(stagedPath, dbPath); await moveFile(stagedPath, dbPath);
} catch (error) { } catch (error) {
-1
View File
@@ -532,7 +532,6 @@ export const validateImportedDrawing = (data: any): boolean => {
// CSRF Protection // CSRF Protection
// ============================================================================ // ============================================================================
const CSRF_TOKEN_LENGTH = 32;
const CSRF_TOKEN_HEADER = "x-csrf-token"; const CSRF_TOKEN_HEADER = "x-csrf-token";
const CSRF_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours const CSRF_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
const CSRF_TOKEN_FUTURE_SKEW_MS = 5 * 60 * 1000; // 5 minutes clock skew tolerance const CSRF_TOKEN_FUTURE_SKEW_MS = 5 * 60 * 1000; // 5 minutes clock skew tolerance
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.2.1", "version": "0.3.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+6 -6
View File
@@ -48,7 +48,7 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children })
const uploadFiles = useCallback(async (files: File[], targetCollectionId: string | null) => { const uploadFiles = useCallback(async (files: File[], targetCollectionId: string | null) => {
const newTasks: UploadTask[] = files.map(f => ({ const newTasks: UploadTask[] = files.map(f => ({
id: Math.random().toString(36).substring(2, 11), id: crypto.randomUUID(),
fileName: f.name, fileName: f.name,
status: 'pending', status: 'pending',
progress: 0 progress: 0
@@ -56,12 +56,12 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children })
setTasks(prev => [...newTasks, ...prev]); setTasks(prev => [...newTasks, ...prev]);
// Map file names to task IDs for progress callbacks // Map file index to task ID for progress callbacks (handles duplicate filenames)
const fileTaskMap = new Map<string, string>(); const indexToTaskId = new Map<number, string>();
newTasks.forEach(t => fileTaskMap.set(t.fileName, t.id)); newTasks.forEach((t, index) => indexToTaskId.set(index, t.id));
const handleProgress = (fileName: string, status: UploadStatus, progress: number, error?: string) => { const handleProgress = (fileIndex: number, status: UploadStatus, progress: number, error?: string) => {
const taskId = fileTaskMap.get(fileName); const taskId = indexToTaskId.get(fileIndex);
if (taskId) { if (taskId) {
updateTask(taskId, { status, progress, error }); updateTask(taskId, { status, progress, error });
} }
+15 -7
View File
@@ -7,7 +7,7 @@ export const importDrawings = async (
targetCollectionId: string | null, targetCollectionId: string | null,
onSuccess?: () => void | Promise<void>, onSuccess?: () => void | Promise<void>,
onProgress?: ( onProgress?: (
fileName: string, fileIndex: number,
status: UploadStatus, status: UploadStatus,
progress: number, progress: number,
error?: string error?: string
@@ -25,12 +25,20 @@ export const importDrawings = async (
let failCount = 0; let failCount = 0;
const errors: string[] = []; const errors: string[] = [];
// Build a map from drawingFile index to original file index for progress reporting
const originalIndexMap = new Map<number, number>();
drawingFiles.forEach((df, i) => {
const originalIndex = files.indexOf(df);
originalIndexMap.set(i, originalIndex);
});
// We process files in parallel (Promise.all) but we could limit concurrency if needed. // We process files in parallel (Promise.all) but we could limit concurrency if needed.
// For now, full parallel is fine as browser limits connection count anyway. // For now, full parallel is fine as browser limits connection count anyway.
await Promise.all( await Promise.all(
drawingFiles.map(async (file) => { drawingFiles.map(async (file, drawingIndex) => {
const fileIndex = originalIndexMap.get(drawingIndex) ?? drawingIndex;
try { try {
if (onProgress) onProgress(file.name, 'processing', 0); // Parsing phase if (onProgress) onProgress(fileIndex, 'processing', 0); // Parsing phase
const text = await file.text(); const text = await file.text();
const data = JSON.parse(text); const data = JSON.parse(text);
@@ -61,7 +69,7 @@ export const importDrawings = async (
preview: svg.outerHTML, preview: svg.outerHTML,
}; };
if (onProgress) onProgress(file.name, 'uploading', 0); if (onProgress) onProgress(fileIndex, 'uploading', 0);
await api.post("/drawings", payload, { await api.post("/drawings", payload, {
headers: { headers: {
@@ -73,12 +81,12 @@ export const importDrawings = async (
const percentCompleted = Math.round( const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total (progressEvent.loaded * 100) / progressEvent.total
); );
onProgress(file.name, 'uploading', percentCompleted); onProgress(fileIndex, 'uploading', percentCompleted);
} }
}, },
}); });
if (onProgress) onProgress(file.name, 'success', 100); if (onProgress) onProgress(fileIndex, 'success', 100);
successCount++; successCount++;
} catch (err: any) { } catch (err: any) {
@@ -90,7 +98,7 @@ export const importDrawings = async (
err?.message || err?.message ||
"Upload failed"; "Upload failed";
errors.push(`${file.name}: ${errorMessage}`); errors.push(`${file.name}: ${errorMessage}`);
if (onProgress) onProgress(file.name, 'error', 0, errorMessage); if (onProgress) onProgress(fileIndex, 'error', 0, errorMessage);
} }
}) })
); );
+6 -9
View File
@@ -15,19 +15,16 @@ try {
console.warn("Unable to read VERSION file:", error); console.warn("Unable to read VERSION file:", error);
} }
if ( const appVersion = process.env.VITE_APP_VERSION?.trim() || versionFromFile;
!process.env.VITE_APP_VERSION || const buildLabel = process.env.VITE_APP_BUILD_LABEL?.trim() || "local development build";
process.env.VITE_APP_VERSION.trim().length === 0
) {
process.env.VITE_APP_VERSION = versionFromFile;
if (!process.env.VITE_APP_BUILD_LABEL) {
process.env.VITE_APP_BUILD_LABEL = "local development build";
}
}
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
define: {
'import.meta.env.VITE_APP_VERSION': JSON.stringify(appVersion),
'import.meta.env.VITE_APP_BUILD_LABEL': JSON.stringify(buildLabel),
},
server: { server: {
proxy: { proxy: {
"/api": { "/api": {