Add browser security headers, implement upload temp directory management, and enhance SSE handling.
CI / check (22) (push) Failing after 15m52s
CI / check (24) (push) Failing after 15m49s

This commit is contained in:
2026-10-07 00:54:58 +02:00
parent 0ead7fa30e
commit 10ec76d437
7 changed files with 101 additions and 186 deletions
+13
View File
@@ -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({
+1
View File
@@ -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)),
+6 -2
View File
@@ -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); }
});
+10 -6
View File
@@ -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) {