feat(feedback): Rate-Limit und Spam-Erkennung für Feedback-Formular
Schützt den Feedback-Endpunkt vor Missbrauch durch pro-Nutzer-Limits, Honeypot, Zeitprüfung und einfache Inhaltsheuristiken. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import type { AuthedRequest } from './auth.js'
|
||||
|
||||
const MIN_SUBMIT_MS = 2_000
|
||||
const MAX_SUBMIT_MS = 60 * 60 * 1000
|
||||
const DUPLICATE_WINDOW_MS = 10 * 60 * 1000
|
||||
const MAX_URLS = 8
|
||||
const MAX_REPEATED_CHAR = 40
|
||||
|
||||
const recentByUser = new Map<string, { hash: string; at: number }>()
|
||||
|
||||
function normalizeMessage(message: string): string {
|
||||
return message.trim().toLowerCase().replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
function countUrls(message: string): number {
|
||||
const matches = message.match(/https?:\/\/|www\./gi)
|
||||
return matches?.length ?? 0
|
||||
}
|
||||
|
||||
function hasExcessiveRepeatedChars(message: string): boolean {
|
||||
return /(.)\1{39,}/.test(message)
|
||||
}
|
||||
|
||||
function pruneRecentEntries(now: number): void {
|
||||
for (const [userId, entry] of recentByUser) {
|
||||
if (now - entry.at > DUPLICATE_WINDOW_MS) {
|
||||
recentByUser.delete(userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type FeedbackSpamVerdict = 'ok' | 'silent_reject' | 'reject'
|
||||
|
||||
export function analyzeFeedbackSpam(
|
||||
userId: string,
|
||||
payload: { message: string; website?: unknown; openedAt?: unknown }
|
||||
): FeedbackSpamVerdict {
|
||||
if (typeof payload.website === 'string' && payload.website.trim()) {
|
||||
return 'silent_reject'
|
||||
}
|
||||
|
||||
if (typeof payload.openedAt === 'number' && Number.isFinite(payload.openedAt)) {
|
||||
const elapsed = Date.now() - payload.openedAt
|
||||
if (elapsed < MIN_SUBMIT_MS || elapsed > MAX_SUBMIT_MS) {
|
||||
return 'silent_reject'
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeMessage(payload.message)
|
||||
const now = Date.now()
|
||||
pruneRecentEntries(now)
|
||||
|
||||
const previous = recentByUser.get(userId)
|
||||
if (previous && previous.hash === normalized && now - previous.at < DUPLICATE_WINDOW_MS) {
|
||||
return 'reject'
|
||||
}
|
||||
|
||||
if (countUrls(payload.message) > MAX_URLS || hasExcessiveRepeatedChars(payload.message)) {
|
||||
return 'reject'
|
||||
}
|
||||
|
||||
recentByUser.set(userId, { hash: normalized, at: now })
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
export const feedbackLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 5,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => (req as AuthedRequest).userId ?? req.ip ?? 'unknown',
|
||||
handler: (_req, res) => {
|
||||
res.status(429).json({
|
||||
error: 'Too many feedback submissions. Please try again later.',
|
||||
code: 'RATE_LIMITED'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
import { isNtfyConfigured, sendFeedbackViaNtfy } from '../services/ntfyNotify.js'
|
||||
import { requireUser } from '../middleware/auth.js'
|
||||
import { analyzeFeedbackSpam, feedbackLimiter } from '../middleware/feedbackProtection.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -25,14 +26,24 @@ router.get('/status', requireUser, (_req, res) => {
|
||||
res.json({ enabled: isNtfyConfigured() })
|
||||
})
|
||||
|
||||
router.post('/', requireUser, async (req: any, res) => {
|
||||
router.post('/', requireUser, feedbackLimiter, async (req: any, res) => {
|
||||
try {
|
||||
if (!isNtfyConfigured()) {
|
||||
return res.status(503).json({ error: 'Feedback is not configured on this server' })
|
||||
}
|
||||
|
||||
const { category, message, username, contactEmail, logbookId, logbookTitle, appVersion, pageUrl } =
|
||||
req.body ?? {}
|
||||
const {
|
||||
category,
|
||||
message,
|
||||
username,
|
||||
contactEmail,
|
||||
logbookId,
|
||||
logbookTitle,
|
||||
appVersion,
|
||||
pageUrl,
|
||||
website,
|
||||
openedAt
|
||||
} = req.body ?? {}
|
||||
|
||||
if (typeof category !== 'string' || !VALID_CATEGORIES.has(category)) {
|
||||
return res.status(400).json({ error: 'Invalid category' })
|
||||
@@ -55,6 +66,21 @@ router.post('/', requireUser, async (req: any, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const spamVerdict = analyzeFeedbackSpam(req.userId, {
|
||||
message: trimmedMessage,
|
||||
website,
|
||||
openedAt
|
||||
})
|
||||
if (spamVerdict === 'silent_reject') {
|
||||
return res.json({ ok: true })
|
||||
}
|
||||
if (spamVerdict === 'reject') {
|
||||
return res.status(400).json({
|
||||
error: 'This feedback could not be sent. Please change your message and try again.',
|
||||
code: 'SPAM_DETECTED'
|
||||
})
|
||||
}
|
||||
|
||||
await sendFeedbackViaNtfy({
|
||||
category,
|
||||
message: trimmedMessage,
|
||||
|
||||
Reference in New Issue
Block a user