Compare commits

..

8 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] d7a7915f8b Add security hardening: input validation, CSP headers, backup rotation, error handling
- Add collection name validation and sanitization (POST/PUT)
- Add library items count and size limits
- Add UUID/safe ID validation for route parameters
- Add Socket.io event input validation and sanitization
- Tighten CSP with base-uri, form-action directives and HSTS header
- Add timestamped backup rotation (keep 5 most recent) for db import
- Add path traversal protection for file uploads and archive names
- Add global error handler to prevent stack trace leakage
- Add 21 new security tests

Co-authored-by: ZimengXiong <83783148+ZimengXiong@users.noreply.github.com>
2026-02-06 22:33:44 +00:00
copilot-swe-agent[bot] 2d51aa9d39 initial plan for security review improvements
Co-authored-by: ZimengXiong <83783148+ZimengXiong@users.noreply.github.com>
2026-02-06 22:30:51 +00:00
copilot-swe-agent[bot] 3f949252c1 Initial plan 2026-02-06 22:28:24 +00:00
Zimeng Xiong b6d0150d44 chore: release v0.3.2 2026-02-01 16:06:19 -08:00
Zimeng Xiong 55cd816cca fix: correct test assertions for trust proxy behavior in supertest
The demonstration tests had incorrect assumptions about how Express
trust proxy works in supertest (no real socket connection). Updated
assertions to match actual behavior while preserving the test's purpose
of showing that trust proxy: true extracts the correct client IP.
2026-02-01 16:05:58 -08:00
Zimeng Xiong d67bd1daf8 fix express proxy headers 2026-02-01 16:04:52 -08:00
Zimeng Xiong 4b56d3cfc6 repro issue 2026-02-01 16:04:52 -08:00
Zimeng Xiong 88ed4360c0 docs: document comma-separated FRONTEND_URL support
Clarifies that FRONTEND_URL accepts multiple comma-separated URLs
for accessing ExcaliDash from different addresses (e.g., localhost
and LAN IP simultaneously).
2026-02-01 16:01:02 -08:00
11 changed files with 638 additions and 78 deletions
+11
View File
@@ -511,6 +511,17 @@ release-docker: ## Build and push release Docker images
pre-release-docker: ## Build and push pre-release Docker images pre-release-docker: ## Build and push pre-release Docker images
./publish-docker-prerelease.sh ./publish-docker-prerelease.sh
dev-release: ## Build and push custom dev release (usage: make dev-release NAME=issue38)
@if [ -z "$(NAME)" ]; then \
echo "$(RED)ERROR: NAME parameter is required!$(NC)"; \
echo "$(YELLOW)Usage: make dev-release NAME=<custom-name>$(NC)"; \
echo "$(YELLOW)Example: make dev-release NAME=issue38$(NC)"; \
echo "$(YELLOW) This will create tags like: 0.3.1-dev-issue38$(NC)"; \
exit 1; \
fi
@echo "$(BLUE)Building custom dev release: $(NAME)$(NC)"
@./publish-docker-dev.sh $(NAME)
#=============================================================================== #===============================================================================
# DATABASE # DATABASE
#=============================================================================== #===============================================================================
+4 -1
View File
@@ -120,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)
+1 -1
View File
@@ -1 +1 @@
0.3.1 0.3.2
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "backend", "name": "backend",
"version": "0.1.8", "version": "0.3.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "backend", "name": "backend",
"version": "0.1.8", "version": "0.3.2",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "backend", "name": "backend",
"version": "0.3.1", "version": "0.3.2",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -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,159 @@
/**
* Security hardening tests
*
* Tests for input validation and sanitization improvements:
* - Route parameter ID validation
* - Collection name validation/sanitization
* - Library items validation
* - Socket.io input validation helpers
* - Path traversal protection in archive file names
*/
import { describe, it, expect } from "vitest";
import { sanitizeText } from "../security";
// Replicate the validation functions from index.ts to test them in isolation
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const SAFE_ID_REGEX = /^[a-zA-Z0-9_-]{1,128}$/;
const isValidResourceId = (id: string): boolean => {
return UUID_REGEX.test(id) || SAFE_ID_REGEX.test(id);
};
describe("Route Parameter ID Validation", () => {
it("should accept valid UUID v4", () => {
expect(isValidResourceId("550e8400-e29b-41d4-a716-446655440000")).toBe(true);
expect(isValidResourceId("6ba7b810-9dad-11d1-80b4-00c04fd430c8")).toBe(true);
});
it("should accept safe alphanumeric IDs", () => {
expect(isValidResourceId("trash")).toBe(true);
expect(isValidResourceId("default")).toBe(true);
expect(isValidResourceId("my-collection-123")).toBe(true);
expect(isValidResourceId("element_1")).toBe(true);
});
it("should reject IDs with path traversal", () => {
expect(isValidResourceId("../etc/passwd")).toBe(false);
expect(isValidResourceId("..\\windows\\system32")).toBe(false);
expect(isValidResourceId("foo/bar")).toBe(false);
});
it("should reject IDs with SQL injection attempts", () => {
expect(isValidResourceId("'; DROP TABLE drawings; --")).toBe(false);
expect(isValidResourceId("1 OR 1=1")).toBe(false);
});
it("should reject IDs with script injection", () => {
expect(isValidResourceId("<script>alert(1)</script>")).toBe(false);
expect(isValidResourceId('"><img src=x onerror=alert(1)>')).toBe(false);
});
it("should reject empty or excessively long IDs", () => {
expect(isValidResourceId("")).toBe(false);
expect(isValidResourceId("a".repeat(129))).toBe(false);
});
it("should accept IDs at maximum length", () => {
expect(isValidResourceId("a".repeat(128))).toBe(true);
});
});
describe("Collection Name Validation", () => {
it("should sanitize collection names with HTML", () => {
const result = sanitizeText('<script>alert("xss")</script>My Collection', 255);
expect(result).not.toContain("<script>");
expect(result).toContain("My Collection");
});
it("should preserve normal collection names", () => {
const result = sanitizeText("My Drawings Collection", 255);
expect(result).toBe("My Drawings Collection");
});
it("should truncate overly long names", () => {
const longName = "A".repeat(300);
const result = sanitizeText(longName, 255);
expect(result.length).toBeLessThanOrEqual(255);
});
it("should strip control characters", () => {
const result = sanitizeText("Name\x00With\x07Control\x1FChars", 255);
expect(result).not.toContain("\x00");
expect(result).not.toContain("\x07");
expect(result).not.toContain("\x1F");
});
});
describe("Library Items Validation", () => {
it("should accept valid item counts", () => {
const items = Array.from({ length: 100 }, (_, i) => ({ id: `item-${i}` }));
expect(items.length).toBeLessThanOrEqual(10000);
});
it("should flag excessive item counts", () => {
const items = Array.from({ length: 10001 }, (_, i) => ({ id: `item-${i}` }));
expect(items.length).toBeGreaterThan(10000);
});
});
describe("Archive Path Sanitization", () => {
const sanitizeArchiveName = (name: string): string => {
return name.replace(/[<>:"/\\|?*]/g, "_").replace(/\.\./g, "_");
};
it("should replace path traversal sequences", () => {
const result = sanitizeArchiveName("../../etc/passwd");
expect(result).not.toContain("..");
expect(result).not.toContain("/");
});
it("should replace dangerous characters", () => {
const result = sanitizeArchiveName('my<drawing>:name/"test"\\path|file?name*');
expect(result).not.toContain("<");
expect(result).not.toContain(">");
expect(result).not.toContain(":");
expect(result).not.toContain('"');
expect(result).not.toContain("\\");
expect(result).not.toContain("|");
expect(result).not.toContain("?");
expect(result).not.toContain("*");
});
it("should preserve normal names", () => {
const result = sanitizeArchiveName("My Drawing 2024");
expect(result).toBe("My Drawing 2024");
});
it("should handle double-dot paths", () => {
const result = sanitizeArchiveName("..folder../..test..");
expect(result).not.toContain("..");
});
});
describe("Socket.io Input Validation Helpers", () => {
const isValidDrawingId = (id: unknown): id is string =>
typeof id === "string" && id.length > 0 && id.length <= 128 && isValidResourceId(id);
it("should accept valid drawing IDs", () => {
expect(isValidDrawingId("550e8400-e29b-41d4-a716-446655440000")).toBe(true);
expect(isValidDrawingId("my-drawing-1")).toBe(true);
});
it("should reject non-string inputs", () => {
expect(isValidDrawingId(123)).toBe(false);
expect(isValidDrawingId(null)).toBe(false);
expect(isValidDrawingId(undefined)).toBe(false);
expect(isValidDrawingId({})).toBe(false);
expect(isValidDrawingId([])).toBe(false);
});
it("should reject empty strings", () => {
expect(isValidDrawingId("")).toBe(false);
});
it("should reject strings with injection attempts", () => {
expect(isValidDrawingId("<script>alert(1)</script>")).toBe(false);
expect(isValidDrawingId("../../../etc/passwd")).toBe(false);
});
});
+160 -58
View File
@@ -48,14 +48,12 @@ 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.startsWith("prisma/"); normalizedRelative === "prisma" ||
normalizedRelative.startsWith("prisma/");
const absolutePath = path.isAbsolute(filePath) const absolutePath = path.isAbsolute(filePath)
? filePath ? filePath
: path.resolve( : path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
hasLeadingPrismaDir ? backendRoot : prismaDir,
normalizedRelative,
);
return `file:${absolutePath}`; return `file:${absolutePath}`;
}; };
@@ -134,8 +132,21 @@ const app = express();
// Trust proxy headers (X-Forwarded-For, X-Real-IP) from nginx // Trust proxy headers (X-Forwarded-For, X-Real-IP) from nginx
// Required for correct client IP detection when running behind a reverse proxy // Required for correct client IP detection when running behind a reverse proxy
// This fixes CSRF token validation failures in Docker/K8s environments // Fix for issue #38: Use 'true' to handle multiple proxy layers (e.g., Traefik, Synology NAS)
app.set("trust proxy", 1); // This ensures Express extracts the real client IP from the leftmost X-Forwarded-For value
const trustProxyConfig = process.env.TRUST_PROXY || "true";
const trustProxyValue = trustProxyConfig === "true"
? true
: trustProxyConfig === "false"
? false
: parseInt(trustProxyConfig, 10) || 1;
app.set("trust proxy", trustProxyValue);
if (trustProxyValue === true) {
console.log("[config] trust proxy: enabled (handles multiple proxy layers)");
} else {
console.log(`[config] trust proxy: ${trustProxyValue}`);
}
const httpServer = createServer(app); const httpServer = createServer(app);
const io = new Server(httpServer, { const io = new Server(httpServer, {
@@ -148,7 +159,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 {
@@ -224,10 +235,15 @@ const upload = multer({
files: 1, files: 1,
}, },
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
// Reject filenames with path traversal characters
const safeName = path.basename(file.originalname);
if (safeName !== file.originalname || /[/\\]/.test(file.originalname)) {
return cb(new Error("Invalid filename"));
}
if (file.fieldname === "db") { if (file.fieldname === "db") {
const isSqliteDb = const isSqliteDb =
file.originalname.endsWith(".db") || safeName.endsWith(".db") ||
file.originalname.endsWith(".sqlite"); safeName.endsWith(".sqlite");
if (!isSqliteDb) { if (!isSqliteDb) {
return cb(new Error("Only .db or .sqlite files are allowed")); return cb(new Error("Only .db or .sqlite files are allowed"));
} }
@@ -242,7 +258,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" }));
@@ -254,8 +270,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`
); );
} }
} }
@@ -269,8 +285,9 @@ 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("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
res.setHeader( res.setHeader(
"Content-Security-Policy", "Content-Security-Policy",
@@ -280,7 +297,9 @@ 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'; " +
"base-uri 'self'; " +
"form-action 'self';"
); );
next(); next();
@@ -289,17 +308,14 @@ 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);
@@ -335,9 +351,24 @@ app.use((req, res, next) => {
const getClientId = (req: express.Request): string => { const getClientId = (req: express.Request): string => {
const ip = req.ip || req.connection.remoteAddress || "unknown"; const ip = req.ip || req.connection.remoteAddress || "unknown";
const userAgent = req.headers["user-agent"] || "unknown"; const userAgent = req.headers["user-agent"] || "unknown";
// Create a simple hash for client identification const clientId = `${ip}:${userAgent}`.slice(0, 256);
// In production, you might use a session ID instead
return `${ip}:${userAgent}`.slice(0, 256); // Debug logging for CSRF troubleshooting (issue #38)
if (process.env.DEBUG_CSRF === "true") {
console.log("[CSRF DEBUG] getClientId", {
method: req.method,
path: req.path,
ip,
remoteAddress: req.connection.remoteAddress,
"x-forwarded-for": req.headers["x-forwarded-for"],
"x-real-ip": req.headers["x-real-ip"],
userAgent: userAgent.slice(0, 100),
clientIdPreview: clientId.slice(0, 60) + "...",
trustProxySetting: req.app.get("trust proxy"),
});
}
return clientId;
}; };
// Rate limiter specifically for CSRF token generation to prevent store exhaustion // Rate limiter specifically for CSRF token generation to prevent store exhaustion
@@ -366,10 +397,7 @@ app.get("/csrf-token", (req, res) => {
} }
clientLimit.count++; clientLimit.count++;
} else { } else {
csrfRateLimit.set(ip, { csrfRateLimit.set(ip, { count: 1, resetTime: now + CSRF_RATE_LIMIT_WINDOW });
count: 1,
resetTime: now + CSRF_RATE_LIMIT_WINDOW,
});
} }
// Cleanup old rate limit entries occasionally // Cleanup old rate limit entries occasionally
@@ -384,7 +412,7 @@ app.get("/csrf-token", (req, res) => {
res.json({ res.json({
token, token,
header: getCsrfTokenHeader(), header: getCsrfTokenHeader()
}); });
}); });
@@ -392,7 +420,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
@@ -455,6 +483,28 @@ const csrfProtectionMiddleware = (
// Apply CSRF protection to all routes // Apply CSRF protection to all routes
app.use(csrfProtectionMiddleware); app.use(csrfProtectionMiddleware);
// Validate route parameter IDs to prevent injection and ensure expected format
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const SAFE_ID_REGEX = /^[a-zA-Z0-9_-]{1,128}$/;
const isValidResourceId = (id: string): boolean => {
return UUID_REGEX.test(id) || SAFE_ID_REGEX.test(id);
};
const validateIdParam = (
req: express.Request,
res: express.Response,
next: express.NextFunction
) => {
const { id } = req.params;
if (id && !isValidResourceId(id)) {
return res.status(400).json({ error: "Invalid resource ID format" });
}
next();
};
app.param("id", validateIdParam);
const filesFieldSchema = z const filesFieldSchema = z
.union([z.record(z.string(), z.any()), z.null()]) .union([z.record(z.string(), z.any()), z.null()])
.optional() .optional()
@@ -485,7 +535,7 @@ const drawingCreateSchema = drawingBaseSchema
}, },
{ {
message: "Invalid or malicious drawing data detected", message: "Invalid or malicious drawing data detected",
}, }
); );
const drawingUpdateSchema = drawingBaseSchema const drawingUpdateSchema = drawingBaseSchema
@@ -535,12 +585,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",
@@ -590,7 +640,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;
@@ -644,6 +694,12 @@ interface User {
const roomUsers = new Map<string, User[]>(); const roomUsers = new Map<string, User[]>();
const isValidSocketId = (id: unknown): id is string =>
typeof id === "string" && id.length > 0 && id.length <= 128 && SAFE_ID_REGEX.test(id);
const isValidDrawingId = (id: unknown): id is string =>
typeof id === "string" && id.length > 0 && id.length <= 128 && isValidResourceId(id);
io.on("connection", (socket) => { io.on("connection", (socket) => {
socket.on( socket.on(
"join-room", "join-room",
@@ -654,10 +710,16 @@ io.on("connection", (socket) => {
drawingId: string; drawingId: string;
user: Omit<User, "socketId" | "isActive">; user: Omit<User, "socketId" | "isActive">;
}) => { }) => {
if (!isValidDrawingId(drawingId)) return;
if (!user || !isValidSocketId(user.id)) return;
const safeName = sanitizeText(typeof user.name === "string" ? user.name : "", 100);
const safeInitials = sanitizeText(typeof user.initials === "string" ? user.initials : "", 5);
const safeColor = sanitizeText(typeof user.color === "string" ? user.color : "", 30);
const roomId = `drawing_${drawingId}`; const roomId = `drawing_${drawingId}`;
socket.join(roomId); socket.join(roomId);
const newUser: User = { ...user, socketId: socket.id, isActive: true }; const newUser: User = { id: user.id, name: safeName, initials: safeInitials, color: safeColor, socketId: socket.id, isActive: true };
const currentUsers = roomUsers.get(roomId) || []; const currentUsers = roomUsers.get(roomId) || [];
const filteredUsers = currentUsers.filter((u) => u.id !== user.id); const filteredUsers = currentUsers.filter((u) => u.id !== user.id);
@@ -665,15 +727,17 @@ 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) => {
if (!data || !isValidDrawingId(data.drawingId)) return;
const roomId = `drawing_${data.drawingId}`; const roomId = `drawing_${data.drawingId}`;
socket.volatile.to(roomId).emit("cursor-move", data); socket.volatile.to(roomId).emit("cursor-move", data);
}); });
socket.on("element-update", (data) => { socket.on("element-update", (data) => {
if (!data || !isValidDrawingId(data.drawingId)) return;
const roomId = `drawing_${data.drawingId}`; const roomId = `drawing_${data.drawingId}`;
socket.to(roomId).emit("element-update", data); socket.to(roomId).emit("element-update", data);
}); });
@@ -681,6 +745,8 @@ io.on("connection", (socket) => {
socket.on( socket.on(
"user-activity", "user-activity",
({ drawingId, isActive }: { drawingId: string; isActive: boolean }) => { ({ drawingId, isActive }: { drawingId: string; isActive: boolean }) => {
if (!isValidDrawingId(drawingId)) return;
if (typeof isActive !== "boolean") return;
const roomId = `drawing_${drawingId}`; const roomId = `drawing_${drawingId}`;
const users = roomUsers.get(roomId); const users = roomUsers.get(roomId);
if (users) { if (users) {
@@ -690,7 +756,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", () => {
@@ -973,8 +1039,12 @@ app.get("/collections", async (req, res) => {
app.post("/collections", async (req, res) => { app.post("/collections", async (req, res) => {
try { try {
const { name } = req.body; const { name } = req.body;
if (typeof name !== "string" || name.trim().length === 0 || name.trim().length > 255) {
return res.status(400).json({ error: "Collection name must be a non-empty string (max 255 characters)" });
}
const sanitizedName = sanitizeText(name.trim(), 255);
const newCollection = await prisma.collection.create({ const newCollection = await prisma.collection.create({
data: { name }, data: { name: sanitizedName },
}); });
res.json(newCollection); res.json(newCollection);
} catch (error) { } catch (error) {
@@ -986,9 +1056,13 @@ app.put("/collections/:id", async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
const { name } = req.body; const { name } = req.body;
if (typeof name !== "string" || name.trim().length === 0 || name.trim().length > 255) {
return res.status(400).json({ error: "Collection name must be a non-empty string (max 255 characters)" });
}
const sanitizedName = sanitizeText(name.trim(), 255);
const updatedCollection = await prisma.collection.update({ const updatedCollection = await prisma.collection.update({
where: { id }, where: { id },
data: { name }, data: { name: sanitizedName },
}); });
res.json(updatedCollection); res.json(updatedCollection);
} catch (error) { } catch (error) {
@@ -1043,14 +1117,23 @@ app.put("/library", async (req, res) => {
return res.status(400).json({ error: "Items must be an array" }); return res.status(400).json({ error: "Items must be an array" });
} }
if (items.length > 10000) {
return res.status(400).json({ error: "Library items limit exceeded (max 10,000)" });
}
const serialized = JSON.stringify(items);
if (serialized.length > 50 * 1024 * 1024) {
return res.status(400).json({ error: "Library data too large" });
}
const library = await prisma.library.upsert({ const library = await prisma.library.upsert({
where: { id: "default" }, where: { id: "default" },
update: { update: {
items: JSON.stringify(items), items: serialized,
}, },
create: { create: {
id: "default", id: "default",
items: JSON.stringify(items), items: serialized,
}, },
}); });
@@ -1081,9 +1164,8 @@ 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-${ `attachment; filename="excalidash-db-${new Date().toISOString().split("T")[0]
new Date().toISOString().split("T")[0] }.${extension}"`
}.${extension}"`,
); );
const fileStream = fs.createReadStream(dbPath); const fileStream = fs.createReadStream(dbPath);
@@ -1105,9 +1187,8 @@ 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-${ `attachment; filename="excalidraw-drawings-${new Date().toISOString().split("T")[0]
new Date().toISOString().split("T")[0] }.zip"`
}.zip"`,
); );
const archive = archiver("zip", { zlib: { level: 9 } }); const archive = archiver("zip", { zlib: { level: 9 } });
@@ -1121,8 +1202,6 @@ 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]) {
@@ -1130,9 +1209,6 @@ 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 || "{}"),
@@ -1146,19 +1222,19 @@ app.get("/export/json", async (req, res) => {
Object.entries(drawingsByCollection).forEach( Object.entries(drawingsByCollection).forEach(
([collectionName, collectionDrawings]) => { ([collectionName, collectionDrawings]) => {
const folderName = collectionName.replace(/[<>:"/\\|?*]/g, "_"); const folderName = collectionName.replace(/[<>:"/\\|?*]/g, "_").replace(/\.\./g, "_");
collectionDrawings.forEach((drawing, index) => { collectionDrawings.forEach((drawing, index) => {
const fileName = `${drawing.name.replace( const fileName = `${drawing.name.replace(
/[<>:"/\\|?*]/g, /[<>:"/\\|?*]/g,
"_", "_"
)}.excalidraw`; ).replace(/\.\./g, "_")}.excalidraw`;
const filePath = `${folderName}/${fileName}`; const filePath = `${folderName}/${fileName}`;
archive.append(JSON.stringify(drawing.data, null, 2), { archive.append(JSON.stringify(drawing.data, null, 2), {
name: filePath, name: filePath,
}); });
}); });
}, }
); );
const readmeContent = `ExcaliDash Export const readmeContent = `ExcaliDash Export
@@ -1222,7 +1298,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 {
@@ -1243,13 +1319,28 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
} }
const dbPath = getResolvedDbPath(); const dbPath = getResolvedDbPath();
const backupPath = `${dbPath}.backup`; const backupTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
const backupPath = `${dbPath}.backup-${backupTimestamp}`;
try { try {
try { try {
await fsPromises.access(dbPath); await fsPromises.access(dbPath);
await fsPromises.copyFile(dbPath, backupPath); await fsPromises.copyFile(dbPath, backupPath);
} catch {} console.log(`[import] Created backup: ${backupPath}`);
// Rotate old backups - keep only the 5 most recent
const dbDir = path.dirname(dbPath);
const dbName = path.basename(dbPath);
const files = await fsPromises.readdir(dbDir);
const backups = files
.filter((f) => f.startsWith(`${dbName}.backup-`))
.sort()
.reverse();
for (const oldBackup of backups.slice(5)) {
await removeFileIfExists(path.join(dbDir, oldBackup));
console.log(`[import] Removed old backup: ${oldBackup}`);
}
} catch { }
await moveFile(stagedPath, dbPath); await moveFile(stagedPath, dbPath);
} catch (error) { } catch (error) {
@@ -1287,6 +1378,17 @@ const ensureTrashCollection = async () => {
} }
}; };
// Global error handler - prevent stack traces from leaking to clients
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
if (err instanceof multer.MulterError) {
return res.status(400).json({ error: `Upload error: ${err.message}` });
}
if (err && err.message) {
console.error("Unhandled error:", err.message);
}
res.status(500).json({ error: "Internal server error" });
});
httpServer.listen(PORT, async () => { httpServer.listen(PORT, async () => {
await initializeUploadDir(); await initializeUploadDir();
await ensureTrashCollection(); await ensureTrashCollection();
+6 -2
View File
@@ -569,8 +569,12 @@ const getCsrfSecret = (): Buffer => {
cachedCsrfSecret = crypto.randomBytes(32); cachedCsrfSecret = crypto.randomBytes(32);
const envLabel = process.env.NODE_ENV ? ` (${process.env.NODE_ENV})` : ""; const envLabel = process.env.NODE_ENV ? ` (${process.env.NODE_ENV})` : "";
console.warn( console.warn(
`[security] CSRF_SECRET is not set${envLabel}. Using an ephemeral per-process secret. ` + `[SECURITY WARNING] CSRF_SECRET is not set${envLabel}.\n` +
"For horizontal scaling (k8s), set CSRF_SECRET to the same value on all instances." `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; return cachedCsrfSecret;
}; };
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "0.3.1", "version": "0.3.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+109
View File
@@ -0,0 +1,109 @@
#!/bin/bash
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Custom name is required
CUSTOM_NAME=$1
if [ -z "$CUSTOM_NAME" ]; then
echo -e "${RED}ERROR: Custom name is required!${NC}"
echo -e "${YELLOW}Usage: $0 <custom-name>${NC}"
echo -e "${YELLOW}Example: $0 issue38${NC}"
echo -e "${YELLOW} This will create tags like: 0.3.1-dev-issue38${NC}"
exit 1
fi
# Configuration
DOCKER_USERNAME="zimengxiong"
IMAGE_NAME="excalidash"
BASE_VERSION=$(node -e "try { console.log(require('fs').readFileSync('VERSION', 'utf8').trim()) } catch { console.log('0.0.0') }")
VERSION="${BASE_VERSION}-dev-${CUSTOM_NAME}"
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
echo -e "${BLUE}===========================================${NC}"
echo -e "${BLUE}ExcaliDash Custom Dev Release${NC}"
echo -e "${BLUE}===========================================${NC}"
echo ""
echo -e "${YELLOW}Branch: ${CURRENT_BRANCH}${NC}"
echo -e "${YELLOW}Base version: ${BASE_VERSION}${NC}"
echo -e "${YELLOW}Custom name: ${CUSTOM_NAME}${NC}"
echo -e "${YELLOW}Full tag: ${VERSION}${NC}"
echo ""
echo -e "${YELLOW}This will publish images with tag: ${VERSION}${NC}"
echo -e "${YELLOW}Dev images will NOT update 'latest' or 'dev' tags${NC}"
echo ""
# Confirm before proceeding
read -p "Continue? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo -e "${RED}Aborted.${NC}"
exit 1
fi
# Check if logged in to Docker Hub
echo -e "${YELLOW}Checking Docker Hub authentication...${NC}"
if ! docker info | grep -q "Username: $DOCKER_USERNAME"; then
echo -e "${YELLOW}Not logged in. Please login to Docker Hub:${NC}"
docker login
else
echo -e "${GREEN}✓ Already logged in as $DOCKER_USERNAME${NC}"
fi
# Create buildx builder if it doesn't exist
echo -e "${YELLOW}Setting up buildx builder...${NC}"
if ! docker buildx inspect excalidash-builder > /dev/null 2>&1; then
echo -e "${YELLOW}Creating new buildx builder...${NC}"
docker buildx create --name excalidash-builder --use --bootstrap
else
echo -e "${GREEN}✓ Using existing buildx builder${NC}"
docker buildx use excalidash-builder
fi
# Build and push backend image
echo ""
echo -e "${BLUE}Building and pushing backend image...${NC}"
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag $DOCKER_USERNAME/$IMAGE_NAME-backend:$VERSION \
--file backend/Dockerfile \
--push \
backend/
echo -e "${GREEN}✓ Backend image pushed successfully${NC}"
# Build and push frontend image
echo ""
echo -e "${BLUE}Building and pushing frontend image...${NC}"
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag $DOCKER_USERNAME/$IMAGE_NAME-frontend:$VERSION \
--build-arg VITE_APP_VERSION=$VERSION \
--file frontend/Dockerfile \
--push \
.
echo -e "${GREEN}✓ Frontend image pushed successfully${NC}"
echo ""
echo -e "${BLUE}===========================================${NC}"
echo -e "${GREEN}✓ Custom dev images published!${NC}"
echo -e "${BLUE}===========================================${NC}"
echo ""
echo -e "${YELLOW}Images published:${NC}"
echo -e "$DOCKER_USERNAME/$IMAGE_NAME-backend:$VERSION"
echo -e "$DOCKER_USERNAME/$IMAGE_NAME-frontend:$VERSION"
echo ""
echo -e "${YELLOW}To use these images in docker-compose:${NC}"
echo -e "${BLUE} services:"
echo -e " backend:"
echo -e " image: $DOCKER_USERNAME/$IMAGE_NAME-backend:$VERSION"
echo -e " frontend:"
echo -e " image: $DOCKER_USERNAME/$IMAGE_NAME-frontend:$VERSION${NC}"
echo ""