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:
2026-05-30 14:09:43 +02:00
parent f5f12f50f5
commit d98e2e8dc0
7 changed files with 164 additions and 11 deletions
+10
View File
@@ -443,6 +443,16 @@ html.scheme-dark .themed-select-option.is-selected {
gap: 16px;
}
.feedback-form__honeypot {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.feedback-form__field {
display: flex;
flex-direction: column;
+25 -2
View File
@@ -26,9 +26,11 @@ export default function FeedbackModal({
const [category, setCategory] = useState<FeedbackCategory>('general')
const [contactEmail, setContactEmail] = useState('')
const [message, setMessage] = useState('')
const [website, setWebsite] = useState('')
const [submitState, setSubmitState] = useState<SubmitState>('idle')
const [statusMessage, setStatusMessage] = useState<string | null>(null)
const closeTimerRef = useRef<number | null>(null)
const openedAtRef = useRef<number>(Date.now())
const isBusy = submitState === 'submitting' || submitState === 'success'
@@ -58,9 +60,12 @@ export default function FeedbackModal({
setCategory('general')
setContactEmail('')
setMessage('')
setWebsite('')
setSubmitState('idle')
setStatusMessage(null)
return
}
openedAtRef.current = Date.now()
}, [open])
const handleSubmit = async (event: React.FormEvent) => {
@@ -76,7 +81,9 @@ export default function FeedbackModal({
message: message.trim(),
contactEmail: contactEmail.trim() || undefined,
logbookId,
logbookTitle
logbookTitle,
openedAt: openedAtRef.current,
website
})
setSubmitState('success')
setStatusMessage(t('feedback.success'))
@@ -91,7 +98,11 @@ export default function FeedbackModal({
? t('feedback.error_not_configured')
: error instanceof FeedbackApiError && error.code === 'INVALID_EMAIL'
? t('feedback.error_invalid_email')
: t('feedback.error_send')
: error instanceof FeedbackApiError && error.code === 'RATE_LIMITED'
? t('feedback.error_rate_limited')
: error instanceof FeedbackApiError && error.code === 'SPAM_DETECTED'
? t('feedback.error_spam')
: t('feedback.error_send')
)
}
}
@@ -139,6 +150,18 @@ export default function FeedbackModal({
)}
<form className="feedback-form" onSubmit={handleSubmit}>
<label className="feedback-form__honeypot" aria-hidden="true">
<span>Website</span>
<input
type="text"
name="website"
value={website}
onChange={(event) => setWebsite(event.target.value)}
tabIndex={-1}
autoComplete="off"
/>
</label>
<label className="feedback-form__field">
<span>{t('feedback.category_label')}</span>
<select
+3 -1
View File
@@ -416,7 +416,9 @@
"success": "Vielen Dank! Ihr Feedback wurde gesendet.",
"error_send": "Feedback konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.",
"error_invalid_email": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
"error_not_configured": "Feedback ist auf diesem Server nicht verfügbar."
"error_not_configured": "Feedback ist auf diesem Server nicht verfügbar.",
"error_rate_limited": "Zu viele Feedback-Nachrichten in kurzer Zeit. Bitte warten Sie einige Minuten.",
"error_spam": "Diese Nachricht konnte nicht gesendet werden. Bitte formulieren Sie sie anders."
},
"demo": {
"logbook_title": "Demo-Logbuch Ostsee",
+3 -1
View File
@@ -416,7 +416,9 @@
"success": "Thank you! Your feedback has been sent.",
"error_send": "Could not send feedback. Please try again later.",
"error_invalid_email": "Please enter a valid email address.",
"error_not_configured": "Feedback is not available on this server."
"error_not_configured": "Feedback is not available on this server.",
"error_rate_limited": "Too many feedback messages in a short time. Please wait a few minutes.",
"error_spam": "This message could not be sent. Please rephrase it and try again."
},
"demo": {
"logbook_title": "Baltic Sea Demo Logbook",
+15 -4
View File
@@ -3,11 +3,11 @@ import { apiFetch } from './api.js'
export type FeedbackCategory = 'bug' | 'feature' | 'general'
export class FeedbackApiError extends Error {
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL'
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL' | 'RATE_LIMITED' | 'SPAM_DETECTED'
constructor(
message: string,
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL' = 'REQUEST_FAILED'
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL' | 'RATE_LIMITED' | 'SPAM_DETECTED' = 'REQUEST_FAILED'
) {
super(message)
this.name = 'FeedbackApiError'
@@ -27,6 +27,8 @@ export async function sendFeedback(payload: {
contactEmail?: string | null
logbookId?: string | null
logbookTitle?: string | null
openedAt: number
website?: string
}): Promise<void> {
const contactEmail = payload.contactEmail?.trim()
if (contactEmail && !isValidFeedbackEmail(contactEmail)) {
@@ -43,7 +45,9 @@ export async function sendFeedback(payload: {
logbookId: payload.logbookId || undefined,
logbookTitle: payload.logbookTitle || undefined,
appVersion: typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : undefined,
pageUrl: window.location.href
pageUrl: window.location.href,
openedAt: payload.openedAt,
website: payload.website || undefined
})
})
@@ -51,8 +55,15 @@ export async function sendFeedback(payload: {
throw new FeedbackApiError('Feedback is not configured on this server', 'NOT_CONFIGURED')
}
if (res.status === 429) {
throw new FeedbackApiError('Too many feedback submissions', 'RATE_LIMITED')
}
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new FeedbackApiError(data.error || 'Failed to send feedback')
throw new FeedbackApiError(
data.error || 'Failed to send feedback',
data.code === 'SPAM_DETECTED' ? 'SPAM_DETECTED' : 'REQUEST_FAILED'
)
}
}
@@ -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'
})
}
})
+29 -3
View File
@@ -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,