Compare commits

..

1 Commits

Author SHA1 Message Date
Zimeng Xiong 71f18a2053 Update RELEASE.md with CSRF_SECRET instructions
Added instructions for the required CSRF_SECRET environment variable for CSRF protection in Kubernetes deployments.
2026-01-14 13:11:13 -08:00
4 changed files with 43 additions and 64 deletions
+1 -1
View File
@@ -1 +1 @@
0.3.1
0.3.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "0.3.1",
"version": "0.3.0",
"description": "",
"main": "index.js",
"scripts": {
+27 -48
View File
@@ -48,14 +48,12 @@ const resolveDatabaseUrl = (rawUrl?: string) => {
const prismaDir = path.resolve(backendRoot, "prisma");
const normalizedRelative = filePath.replace(/^\.\/?/, "");
const hasLeadingPrismaDir =
normalizedRelative === "prisma" || normalizedRelative.startsWith("prisma/");
normalizedRelative === "prisma" ||
normalizedRelative.startsWith("prisma/");
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(
hasLeadingPrismaDir ? backendRoot : prismaDir,
normalizedRelative,
);
: path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative);
return `file:${absolutePath}`;
};
@@ -131,12 +129,6 @@ const initializeUploadDir = async () => {
};
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 io = new Server(httpServer, {
cors: {
@@ -148,7 +140,7 @@ const io = new Server(httpServer, {
const prisma = new PrismaClient();
const parseJsonField = <T>(
rawValue: string | null | undefined,
fallback: T,
fallback: T
): T => {
if (!rawValue) return fallback;
try {
@@ -242,7 +234,7 @@ app.use(
credentials: true,
allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token"],
exposedHeaders: ["x-csrf-token"],
}),
})
);
app.use(express.json({ limit: "50mb" }));
app.use(express.urlencoded({ extended: true, limit: "50mb" }));
@@ -254,8 +246,8 @@ app.use((req, res, next) => {
if (sizeInMB > 10) {
console.log(
`[LARGE REQUEST] ${req.method} ${req.path} - ${sizeInMB.toFixed(
2,
)}MB - Content-Length: ${contentLength} bytes`,
2
)}MB - Content-Length: ${contentLength} bytes`
);
}
}
@@ -269,7 +261,7 @@ app.use((req, res, next) => {
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
res.setHeader(
"Permissions-Policy",
"geolocation=(), microphone=(), camera=()",
"geolocation=(), microphone=(), camera=()"
);
res.setHeader(
@@ -280,7 +272,7 @@ app.use((req, res, next) => {
"font-src 'self' https://fonts.gstatic.com; " +
"img-src 'self' data: blob: https:; " +
"connect-src 'self' ws: wss:; " +
"frame-ancestors 'none';",
"frame-ancestors 'none';"
);
next();
@@ -289,17 +281,14 @@ app.use((req, res, next) => {
const requestCounts = new Map<string, { count: number; resetTime: number }>();
const RATE_LIMIT_WINDOW = 15 * 60 * 1000;
setInterval(
() => {
setInterval(() => {
const now = Date.now();
for (const [ip, data] of requestCounts.entries()) {
if (now > data.resetTime) {
requestCounts.delete(ip);
}
}
},
5 * 60 * 1000,
).unref();
}, 5 * 60 * 1000).unref();
const RATE_LIMIT_MAX_REQUESTS = (() => {
const parsed = Number(process.env.RATE_LIMIT_MAX_REQUESTS);
@@ -366,10 +355,7 @@ app.get("/csrf-token", (req, res) => {
}
clientLimit.count++;
} 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
@@ -384,7 +370,7 @@ app.get("/csrf-token", (req, res) => {
res.json({
token,
header: getCsrfTokenHeader(),
header: getCsrfTokenHeader()
});
});
@@ -392,7 +378,7 @@ app.get("/csrf-token", (req, res) => {
const csrfProtectionMiddleware = (
req: express.Request,
res: express.Response,
next: express.NextFunction,
next: express.NextFunction
) => {
// Skip CSRF validation for safe methods (GET, HEAD, OPTIONS)
// Note: /csrf-token is a GET endpoint, so it's automatically exempt
@@ -485,7 +471,7 @@ const drawingCreateSchema = drawingBaseSchema
},
{
message: "Invalid or malicious drawing data detected",
},
}
);
const drawingUpdateSchema = drawingBaseSchema
@@ -535,12 +521,12 @@ const drawingUpdateSchema = drawingBaseSchema
},
{
message: "Invalid or malicious drawing data detected",
},
}
);
const respondWithValidationErrors = (
res: express.Response,
issues: z.ZodIssue[],
issues: z.ZodIssue[]
) => {
res.status(400).json({
error: "Invalid drawing payload",
@@ -590,7 +576,7 @@ const verifyDatabaseIntegrityAsync = (filePath: string): Promise<boolean> => {
path.resolve(__dirname, "./workers/db-verify.js"),
{
workerData: { filePath },
},
}
);
let timeoutHandle: NodeJS.Timeout;
let settled = false;
@@ -665,7 +651,7 @@ io.on("connection", (socket) => {
roomUsers.set(roomId, filteredUsers);
io.to(roomId).emit("presence-update", filteredUsers);
},
}
);
socket.on("cursor-move", (data) => {
@@ -690,7 +676,7 @@ io.on("connection", (socket) => {
io.to(roomId).emit("presence-update", users);
}
}
},
}
);
socket.on("disconnect", () => {
@@ -1081,9 +1067,8 @@ app.get("/export", async (req, res) => {
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader(
"Content-Disposition",
`attachment; filename="excalidash-db-${
new Date().toISOString().split("T")[0]
}.${extension}"`,
`attachment; filename="excalidash-db-${new Date().toISOString().split("T")[0]
}.${extension}"`
);
const fileStream = fs.createReadStream(dbPath);
@@ -1105,9 +1090,8 @@ app.get("/export/json", async (req, res) => {
res.setHeader("Content-Type", "application/zip");
res.setHeader(
"Content-Disposition",
`attachment; filename="excalidraw-drawings-${
new Date().toISOString().split("T")[0]
}.zip"`,
`attachment; filename="excalidraw-drawings-${new Date().toISOString().split("T")[0]
}.zip"`
);
const archive = archiver("zip", { zlib: { level: 9 } });
@@ -1121,8 +1105,6 @@ app.get("/export/json", async (req, res) => {
const drawingsByCollection: { [key: string]: any[] } = {};
const exportSource = `${req.protocol}://${req.get("host")}`;
drawings.forEach((drawing: any) => {
const collectionName = drawing.collection?.name || "Unorganized";
if (!drawingsByCollection[collectionName]) {
@@ -1130,9 +1112,6 @@ app.get("/export/json", async (req, res) => {
}
const drawingData = {
type: "excalidraw",
version: 2,
source: exportSource,
elements: JSON.parse(drawing.elements),
appState: JSON.parse(drawing.appState),
files: JSON.parse(drawing.files || "{}"),
@@ -1150,7 +1129,7 @@ app.get("/export/json", async (req, res) => {
collectionDrawings.forEach((drawing, index) => {
const fileName = `${drawing.name.replace(
/[<>:"/\\|?*]/g,
"_",
"_"
)}.excalidraw`;
const filePath = `${folderName}/${fileName}`;
@@ -1158,7 +1137,7 @@ app.get("/export/json", async (req, res) => {
name: filePath,
});
});
},
}
);
const readmeContent = `ExcaliDash Export
@@ -1222,7 +1201,7 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
const originalPath = req.file.path;
const stagedPath = path.join(
uploadDir,
`temp-${Date.now()}-${Math.random().toString(36).slice(2)}.db`,
`temp-${Date.now()}-${Math.random().toString(36).slice(2)}.db`
);
try {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.3.1",
"version": "0.3.0",
"type": "module",
"scripts": {
"dev": "vite",