prevent comment attachment to be abused

This commit is contained in:
2026-05-29 18:38:26 +02:00
parent 86085c435a
commit f79e4d6f32
3 changed files with 143 additions and 1 deletions

View File

@@ -2548,6 +2548,19 @@ class CommentSystem {
} }
} }
} }
// Fire-and-forget: tell the server to delete the orphaned upload record.
// Only possible once upload has finished (fileId is set); silently ignored on failure —
// the server-side orphan sweep will clean up any leftovers after 1 hour.
const fileId = previewItem.dataset.fileId;
if (fileId) {
const csrf = (window.f0ckSession || {}).csrf_token || '';
fetch(`/api/v2/comments/upload/${fileId}`, {
method: 'DELETE',
headers: csrf ? { 'X-CSRF-Token': csrf } : {}
}).catch(() => {});
}
previewItem.remove(); previewItem.remove();
} }
return; return;

View File

@@ -181,6 +181,24 @@ export const handleCommentUpload = async (req, res) => {
return sendJson(res, { success: false, msg: 'Only one file allowed per comment' }, 400); return sendJson(res, { success: false, msg: 'Only one file allowed per comment' }, 400);
} }
// Per-user cap on unlinked (staged) attachments.
// A normal user only ever has a handful of files queued up in their compose box at once.
// Capping at (max attachments per comment) × 3 gives plenty of headroom for legitimate
// multi-file workflows while blocking upload-and-abandon abuse.
const maxAttachmentsPerComment = cfg.websrv.fileupload_comments_max || 5;
const MAX_PENDING_PER_USER = maxAttachmentsPerComment * 3;
const pendingCount = await db`
SELECT COUNT(*) AS cnt FROM comment_files
WHERE user_id = ${req.session.id}
AND comment_id IS NULL
`;
if (parseInt(pendingCount[0].cnt, 10) + files.length > MAX_PENDING_PER_USER) {
return sendJson(res, {
success: false,
msg: `Too many staged attachments. Please post or remove existing uploads first.`
}, 429);
}
const allowedMimes = getAllowedCommentMimes(); const allowedMimes = getAllowedCommentMimes();
const results = []; const results = [];
@@ -479,6 +497,111 @@ export const handleCommentUpload = async (req, res) => {
} }
}; };
/**
* DELETE /api/v2/comments/upload/:id
* Called by the client when the user removes a staged (not-yet-posted) attachment
* from the compose area. Only deletes if the row still has comment_id = NULL
* (i.e. it was never linked to a real comment) and belongs to the requesting user.
*/
export const handleCommentUploadCancel = async (req, res, fileId) => {
// Manual session lookup (same pattern as handleCommentUpload)
if (req.cookies?.session) {
try {
const user = await db`
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".*
from "user_sessions"
left join "user" on "user".id = "user_sessions".user_id
left join "user_options" on "user_options".user_id = "user_sessions".user_id
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
limit 1
`;
if (user.length > 0) {
req.session = user[0];
}
} catch (err) {
// Session lookup failed
}
}
if (!req.session) {
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
}
const id = parseInt(fileId, 10);
if (!id || isNaN(id)) {
return sendJson(res, { success: false, msg: 'Invalid file ID' }, 400);
}
try {
// Only allow deletion of own unlinked files
const rows = await db`
SELECT id, dest FROM comment_files
WHERE id = ${id}
AND user_id = ${req.session.id}
AND comment_id IS NULL
`;
if (!rows.length) {
// Either doesn't exist, belongs to someone else, or already linked — silently OK
return sendJson(res, { success: true });
}
const { dest } = rows[0];
await db`DELETE FROM comment_files WHERE id = ${id}`;
// Delete file and thumbnail from disk
const filePath = path.join(cfg.paths.c, dest);
const uuid = dest.split('.')[0];
const thumbPath = path.join(cfg.paths.t, `cf_${uuid}.webp`);
await fs.unlink(filePath).catch(() => {});
await fs.unlink(thumbPath).catch(() => {});
console.log(`[COMMENT_UPLOAD] Cancelled (user-removed) attachment deleted: ${dest}`);
return sendJson(res, { success: true });
} catch (err) {
console.error('[COMMENT_UPLOAD] Cancel error:', err);
return sendJson(res, { success: false, msg: 'Delete failed' }, 500);
}
};
/**
* Periodic cleanup: delete comment_files rows with comment_id IS NULL
* that are older than 90 seconds. These are attachments that were uploaded
* but the comment was never posted (e.g. user closed the tab).
*/
const ORPHAN_MAX_AGE_MS = 90 * 1000; // 90 seconds
const sweepOrphanedCommentFiles = async () => {
try {
const cutoff = new Date(Date.now() - ORPHAN_MAX_AGE_MS).toISOString();
const orphans = await db`
SELECT id, dest FROM comment_files
WHERE comment_id IS NULL
AND created_at < ${cutoff}
`;
if (!orphans.length) return;
console.log(`[COMMENT_UPLOAD] Sweeping ${orphans.length} orphaned attachment(s) older than 90 seconds`);
for (const { id, dest } of orphans) {
const filePath = path.join(cfg.paths.c, dest);
const uuid = dest.split('.')[0];
const thumbPath = path.join(cfg.paths.t, `cf_${uuid}.webp`);
await fs.unlink(filePath).catch(() => {});
await fs.unlink(thumbPath).catch(() => {});
await db`DELETE FROM comment_files WHERE id = ${id}`;
}
} catch (err) {
console.error('[COMMENT_UPLOAD] Orphan sweep error:', err);
}
};
// Run sweep every 30 seconds
setInterval(sweepOrphanedCommentFiles, 30 * 1000);
// Also run once shortly after boot to catch any pre-existing orphans
setTimeout(sweepOrphanedCommentFiles, 30 * 1000);
/** /**
* Generate thumbnail for a comment file. * Generate thumbnail for a comment file.
* Outputs to /t/cf_<uuid>.webp * Outputs to /t/cf_<uuid>.webp

View File

@@ -16,7 +16,7 @@ import { handleEmojiUpload } from "./emoji_upload_handler.mjs";
import { handleHallImageUpload, handleHallImageDelete, handleHallDelete, handleHallUpdate, handleHallCreate } from "./hall_image_handler.mjs"; import { handleHallImageUpload, handleHallImageDelete, handleHallDelete, handleHallUpdate, handleHallCreate } from "./hall_image_handler.mjs";
import { handleMetaExtract } from "./meta_extract_handler.mjs"; import { handleMetaExtract } from "./meta_extract_handler.mjs";
import { handleMetaStrip } from "./meta_strip_handler.mjs"; import { handleMetaStrip } from "./meta_strip_handler.mjs";
import { handleCommentUpload } from "./comment_upload_handler.mjs"; import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs"; import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion } from "./inc/settings.mjs"; import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion } from "./inc/settings.mjs";
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs"; import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
@@ -846,6 +846,12 @@ process.on('uncaughtException', err => {
await handleCommentUpload(req, res); await handleCommentUpload(req, res);
req.url.pathname = '/handled_comment_upload_bypass'; req.url.pathname = '/handled_comment_upload_bypass';
} }
// DELETE /api/v2/comments/upload/:id — user cancels a staged attachment
const cancelMatch = req.url.pathname.match(/^\/api\/v2\/comments\/upload\/(\d+)$/);
if (req.method === 'DELETE' && cancelMatch) {
await handleCommentUploadCancel(req, res, cancelMatch[1]);
req.url.pathname = '/handled_comment_upload_cancel_bypass';
}
}); });
// Bypass middleware for DM encrypted attachment upload/download/delete // Bypass middleware for DM encrypted attachment upload/download/delete