Add browser security headers, implement upload temp directory management, and enhance SSE handling.
This commit is contained in:
+13
@@ -220,6 +220,17 @@ function sameOriginProtection(config) {
|
||||
};
|
||||
}
|
||||
|
||||
function browserSecurityHeaders(config) {
|
||||
return (_req, res, next) => {
|
||||
res.setHeader("Content-Security-Policy", "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; font-src 'self' data:; worker-src 'self' blob:");
|
||||
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
if (process.env.NODE_ENV === "production" && config.cookieSecure) res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload");
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
function enforceSessionVersion(repositories) {
|
||||
return (req, res, next) => {
|
||||
if (!req.session.user) return next();
|
||||
@@ -249,6 +260,7 @@ export function createApp(options = {}) {
|
||||
if (config.dbPath !== ":memory:") {
|
||||
fs.mkdirSync(config.dataDir, {recursive: true});
|
||||
fs.mkdirSync(config.sessionsDir, {recursive: true});
|
||||
fs.mkdirSync(config.uploadTmpDir, {recursive: true});
|
||||
}
|
||||
|
||||
const closeHandlers = createLifecycleMonitors(app, config);
|
||||
@@ -271,6 +283,7 @@ export function createApp(options = {}) {
|
||||
);
|
||||
|
||||
app.use(observability.middleware);
|
||||
app.use(browserSecurityHeaders(config));
|
||||
app.use(createHealthRouter({modelService}));
|
||||
app.use(express.json({limit: "1mb"}));
|
||||
app.use(session({
|
||||
|
||||
@@ -33,6 +33,7 @@ export function createConfig(overrides = {}) {
|
||||
publicDir: path.resolve(rootDir, "public"),
|
||||
dataDir,
|
||||
uploadDir: process.env.UPLOAD_DIR || (process.env.NODE_ENV === "production" ? "/app/data/uploads" : path.resolve(dataDir, "uploads")),
|
||||
uploadTmpDir: process.env.UPLOAD_TMP_DIR || path.resolve(dataDir, "upload-tmp"),
|
||||
uploadMaxSizeMb: Math.max(1, int(process.env.UPLOAD_MAX_SIZE_MB, 10)),
|
||||
uploadMaxFiles: Math.max(1, int(process.env.UPLOAD_MAX_FILES, 5)),
|
||||
uploadMaxPixels: Math.max(1, int(process.env.UPLOAD_MAX_PIXELS, 30000000)),
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* @file Verarbeitet das Hochladen, Abrufen und Entfernen von Bildanhängen mit Zugriffskontrolle.
|
||||
*/
|
||||
import express from "express";
|
||||
import {mkdirSync} from "node:fs";
|
||||
import {unlink} from "node:fs/promises";
|
||||
import {asyncHandler} from "../middleware/asyncHandler.js";
|
||||
import multer from "multer";
|
||||
import {requireLogin} from "../middleware/auth.js";
|
||||
@@ -9,8 +11,9 @@ import {AttachmentError} from "../services/attachmentService.js";
|
||||
|
||||
export function createUploadsRouter(service, config) {
|
||||
const router = express.Router();
|
||||
mkdirSync(config.uploadTmpDir, {recursive: true});
|
||||
const activeUploads = new Map();
|
||||
const upload = multer({storage: multer.memoryStorage(), limits: {
|
||||
const upload = multer({storage: multer.diskStorage({destination: config.uploadTmpDir}), limits: {
|
||||
fileSize: config.uploadMaxSizeMb * 1024 * 1024, files: 1, fields: 0, parts: 1
|
||||
}}).single("image");
|
||||
const fail = (res, error) => res.status(error instanceof AttachmentError ? error.status : 500).json({error:
|
||||
@@ -26,8 +29,9 @@ export function createUploadsRouter(service, config) {
|
||||
if (remaining) activeUploads.set(user, remaining); else activeUploads.delete(user);
|
||||
});
|
||||
upload(req, res, async error => {
|
||||
if (error) return res.status(error.code === "LIMIT_FILE_SIZE" ? 413 : 400).json({error:
|
||||
if (error) { if (req.file?.path) await unlink(req.file.path).catch(() => {}); return res.status(error.code === "LIMIT_FILE_SIZE" ? 413 : 400).json({error:
|
||||
error.code === "LIMIT_FILE_SIZE" ? `Image exceeds ${config.uploadMaxSizeMb} MB` : "Upload one PNG, JPEG or WebP image per request"});
|
||||
}
|
||||
try { res.status(201).json(await service.upload(req.file, req.session.user)); }
|
||||
catch (failure) { fail(res, failure); }
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* @file Validiert und speichert Bildanhänge und bereitet zwischengespeicherte Bildanalysen für den Chat vor.
|
||||
*/
|
||||
import sharp from "sharp";
|
||||
import {readFile, unlink} from "node:fs/promises";
|
||||
import {createHash, randomUUID} from "node:crypto";
|
||||
import {attachmentMetadata} from "../db/attachments.js";
|
||||
import {LocalFileStorage} from "./localFileStorage.js";
|
||||
@@ -164,26 +165,29 @@ export function createAttachmentService(db, config, modelService, visionDebugSer
|
||||
},
|
||||
async upload(file, user, {forImport = false} = {}) {
|
||||
await cleanup();
|
||||
if (!file?.buffer?.length) throw new AttachmentError("Choose a non-empty PNG, JPEG or WebP image");
|
||||
if (file.size > config.uploadMaxSizeMb * 1024 * 1024) throw new AttachmentError("Image is too large", 413);
|
||||
let buffer;
|
||||
try { buffer = file?.buffer || (file?.path ? await readFile(file.path) : null); }
|
||||
finally { if (file?.path) await unlink(file.path).catch(() => {}); }
|
||||
if (!buffer?.length) throw new AttachmentError("Choose a non-empty PNG, JPEG or WebP image");
|
||||
if (buffer.length > config.uploadMaxSizeMb * 1024 * 1024) throw new AttachmentError("Image is too large", 413);
|
||||
let info;
|
||||
try {
|
||||
const image = sharp(file.buffer, {limitInputPixels: config.uploadMaxPixels, failOn: "warning"});
|
||||
const image = sharp(buffer, {limitInputPixels: config.uploadMaxPixels, failOn: "warning"});
|
||||
info = await image.metadata();
|
||||
if (!["png", "jpeg", "webp"].includes(info.format) || (info.pages || 1) > 1) throw new Error("Unsupported");
|
||||
await image.stats(); // Decode pixels, not just headers: reject truncated/malformed images.
|
||||
} catch { throw new AttachmentError("Invalid image. Use a non-animated PNG, JPEG or WebP within the pixel limit."); }
|
||||
const {storageKey} = await storage.save({buffer: file.buffer, extension: info.format}).catch(() => { throw new AttachmentError("Image could not be saved. Please retry.", 503); });
|
||||
const {storageKey} = await storage.save({buffer, extension: info.format}).catch(() => { throw new AttachmentError("Image could not be saved. Please retry.", 503); });
|
||||
const id = randomUUID();
|
||||
try {
|
||||
// Bound abandoned uploads per account as well as per submitted message.
|
||||
db.transaction(() => {
|
||||
if (!forImport && db.prepare("SELECT COUNT(*) AS n FROM attachments WHERE username=? AND status='temporary'").get(user).n >= config.uploadMaxFiles * 4) throw new AttachmentError("Too many pending images. Remove unused uploads first.");
|
||||
db.prepare("INSERT INTO attachments(id,username,original_name,storage_key,mime_type,size_bytes,width,height) VALUES(?,?,?,?,?,?,?,?)").run(id, user,
|
||||
[...String(file.originalname || "image").replace(/[\\/]/g, "_")].map(character => character.charCodeAt(0) < 32 ? "_" : character).join("").slice(0, 255), storageKey, `image/${info.format}`, file.size, info.width, info.height);
|
||||
[...String(file.originalname || "image").replace(/[\\/]/g, "_")].map(character => character.charCodeAt(0) < 32 ? "_" : character).join("").slice(0, 255), storageKey, `image/${info.format}`, buffer.length, info.width, info.height);
|
||||
})();
|
||||
} catch (error) { await storage.delete(storageKey); throw error; }
|
||||
log("attachmentUploadSuccess", {id, size: file.size, mime: `image/${info.format}`});
|
||||
log("attachmentUploadSuccess", {id, size: buffer.length, mime: `image/${info.format}`});
|
||||
return attachmentMetadata(get(id));
|
||||
},
|
||||
createMessage(ids, user, chatId, insert, reuseChatId = null) {
|
||||
|
||||
Reference in New Issue
Block a user