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>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
+100
-11
@@ -235,10 +235,15 @@ const upload = multer({
|
||||
files: 1,
|
||||
},
|
||||
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") {
|
||||
const isSqliteDb =
|
||||
file.originalname.endsWith(".db") ||
|
||||
file.originalname.endsWith(".sqlite");
|
||||
safeName.endsWith(".db") ||
|
||||
safeName.endsWith(".sqlite");
|
||||
if (!isSqliteDb) {
|
||||
return cb(new Error("Only .db or .sqlite files are allowed"));
|
||||
}
|
||||
@@ -282,6 +287,7 @@ app.use((req, res, next) => {
|
||||
"Permissions-Policy",
|
||||
"geolocation=(), microphone=(), camera=()"
|
||||
);
|
||||
res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
||||
|
||||
res.setHeader(
|
||||
"Content-Security-Policy",
|
||||
@@ -291,7 +297,9 @@ 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'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self';"
|
||||
);
|
||||
|
||||
next();
|
||||
@@ -475,6 +483,28 @@ const csrfProtectionMiddleware = (
|
||||
// Apply CSRF protection to all routes
|
||||
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
|
||||
.union([z.record(z.string(), z.any()), z.null()])
|
||||
.optional()
|
||||
@@ -664,6 +694,12 @@ interface 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) => {
|
||||
socket.on(
|
||||
"join-room",
|
||||
@@ -674,10 +710,16 @@ io.on("connection", (socket) => {
|
||||
drawingId: string;
|
||||
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}`;
|
||||
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 filteredUsers = currentUsers.filter((u) => u.id !== user.id);
|
||||
@@ -689,11 +731,13 @@ io.on("connection", (socket) => {
|
||||
);
|
||||
|
||||
socket.on("cursor-move", (data) => {
|
||||
if (!data || !isValidDrawingId(data.drawingId)) return;
|
||||
const roomId = `drawing_${data.drawingId}`;
|
||||
socket.volatile.to(roomId).emit("cursor-move", data);
|
||||
});
|
||||
|
||||
socket.on("element-update", (data) => {
|
||||
if (!data || !isValidDrawingId(data.drawingId)) return;
|
||||
const roomId = `drawing_${data.drawingId}`;
|
||||
socket.to(roomId).emit("element-update", data);
|
||||
});
|
||||
@@ -701,6 +745,8 @@ io.on("connection", (socket) => {
|
||||
socket.on(
|
||||
"user-activity",
|
||||
({ drawingId, isActive }: { drawingId: string; isActive: boolean }) => {
|
||||
if (!isValidDrawingId(drawingId)) return;
|
||||
if (typeof isActive !== "boolean") return;
|
||||
const roomId = `drawing_${drawingId}`;
|
||||
const users = roomUsers.get(roomId);
|
||||
if (users) {
|
||||
@@ -993,8 +1039,12 @@ app.get("/collections", async (req, res) => {
|
||||
app.post("/collections", async (req, res) => {
|
||||
try {
|
||||
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({
|
||||
data: { name },
|
||||
data: { name: sanitizedName },
|
||||
});
|
||||
res.json(newCollection);
|
||||
} catch (error) {
|
||||
@@ -1006,9 +1056,13 @@ app.put("/collections/:id", async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
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({
|
||||
where: { id },
|
||||
data: { name },
|
||||
data: { name: sanitizedName },
|
||||
});
|
||||
res.json(updatedCollection);
|
||||
} catch (error) {
|
||||
@@ -1063,14 +1117,23 @@ app.put("/library", async (req, res) => {
|
||||
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({
|
||||
where: { id: "default" },
|
||||
update: {
|
||||
items: JSON.stringify(items),
|
||||
items: serialized,
|
||||
},
|
||||
create: {
|
||||
id: "default",
|
||||
items: JSON.stringify(items),
|
||||
items: serialized,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1159,12 +1222,12 @@ app.get("/export/json", async (req, res) => {
|
||||
|
||||
Object.entries(drawingsByCollection).forEach(
|
||||
([collectionName, collectionDrawings]) => {
|
||||
const folderName = collectionName.replace(/[<>:"/\\|?*]/g, "_");
|
||||
const folderName = collectionName.replace(/[<>:"/\\|?*]/g, "_").replace(/\.\./g, "_");
|
||||
collectionDrawings.forEach((drawing, index) => {
|
||||
const fileName = `${drawing.name.replace(
|
||||
/[<>:"/\\|?*]/g,
|
||||
"_"
|
||||
)}.excalidraw`;
|
||||
).replace(/\.\./g, "_")}.excalidraw`;
|
||||
const filePath = `${folderName}/${fileName}`;
|
||||
|
||||
archive.append(JSON.stringify(drawing.data, null, 2), {
|
||||
@@ -1256,12 +1319,27 @@ app.post("/import/sqlite", upload.single("db"), async (req, res) => {
|
||||
}
|
||||
|
||||
const dbPath = getResolvedDbPath();
|
||||
const backupPath = `${dbPath}.backup`;
|
||||
const backupTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backupPath = `${dbPath}.backup-${backupTimestamp}`;
|
||||
|
||||
try {
|
||||
try {
|
||||
await fsPromises.access(dbPath);
|
||||
await fsPromises.copyFile(dbPath, backupPath);
|
||||
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);
|
||||
@@ -1300,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 () => {
|
||||
await initializeUploadDir();
|
||||
await ensureTrashCollection();
|
||||
|
||||
Reference in New Issue
Block a user