Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bea26a3abd | |||
| a8615d9087 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
+40
-61
@@ -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,18 +261,18 @@ 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(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com; " +
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
|
||||
"font-src 'self' https://fonts.gstatic.com; " +
|
||||
"img-src 'self' data: blob: https:; " +
|
||||
"connect-src 'self' ws: wss:; " +
|
||||
"frame-ancestors 'none';",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com; " +
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
|
||||
"font-src 'self' https://fonts.gstatic.com; " +
|
||||
"img-src 'self' data: blob: https:; " +
|
||||
"connect-src 'self' ws: wss:; " +
|
||||
"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(
|
||||
() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, data] of requestCounts.entries()) {
|
||||
if (now > data.resetTime) {
|
||||
requestCounts.delete(ip);
|
||||
}
|
||||
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
|
||||
@@ -1176,8 +1155,8 @@ Total Drawings: ${drawings.length}
|
||||
|
||||
Collections:
|
||||
${Object.entries(drawingsByCollection)
|
||||
.map(([name, drawings]) => `- ${name}: ${drawings.length} drawings`)
|
||||
.join("\n")}
|
||||
.map(([name, drawings]) => `- ${name}: ${drawings.length} drawings`)
|
||||
.join("\n")}
|
||||
`;
|
||||
|
||||
archive.append(readmeContent, { name: "README.txt" });
|
||||
@@ -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 {
|
||||
@@ -1249,7 +1228,7 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
|
||||
try {
|
||||
await fsPromises.access(dbPath);
|
||||
await fsPromises.copyFile(dbPath, backupPath);
|
||||
} catch {}
|
||||
} catch { }
|
||||
|
||||
await moveFile(stagedPath, dbPath);
|
||||
} catch (error) {
|
||||
|
||||
+26
-12
@@ -30,7 +30,9 @@ 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 };
|
||||
};
|
||||
|
||||
@@ -318,10 +320,13 @@ export const appStateSchema = z
|
||||
.optional()
|
||||
.nullable(),
|
||||
currentItemRoundness: z
|
||||
.object({
|
||||
type: z.enum(["round", "sharp"]),
|
||||
value: z.number().finite().min(0).max(1),
|
||||
})
|
||||
.union([
|
||||
z.enum(["sharp", "round"]),
|
||||
z.object({
|
||||
type: z.enum(["round", "sharp"]),
|
||||
value: z.number().finite().min(0).max(1),
|
||||
}),
|
||||
])
|
||||
.optional()
|
||||
.nullable(),
|
||||
currentItemFontSize: z
|
||||
@@ -427,10 +432,19 @@ 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;
|
||||
@@ -448,8 +462,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) {
|
||||
@@ -465,8 +479,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;
|
||||
|
||||
@@ -570,7 +584,7 @@ const getCsrfSecret = (): Buffer => {
|
||||
const envLabel = process.env.NODE_ENV ? ` (${process.env.NODE_ENV})` : "";
|
||||
console.warn(
|
||||
`[security] CSRF_SECRET is not set${envLabel}. Using an ephemeral per-process secret. ` +
|
||||
"For horizontal scaling (k8s), set CSRF_SECRET to the same value on all instances."
|
||||
"For horizontal scaling (k8s), set CSRF_SECRET to the same value on all instances."
|
||||
);
|
||||
return cachedCsrfSecret;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -301,6 +301,7 @@ export const Editor: React.FC = () => {
|
||||
|
||||
try {
|
||||
const persistableAppState = {
|
||||
...appState,
|
||||
viewBackgroundColor: appState?.viewBackgroundColor || '#ffffff',
|
||||
gridSize: appState?.gridSize || null,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user