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:
@@ -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;
|
||||
|
||||
@@ -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,6 +98,10 @@ export default function FeedbackModal({
|
||||
? t('feedback.error_not_configured')
|
||||
: error instanceof FeedbackApiError && error.code === 'INVALID_EMAIL'
|
||||
? t('feedback.error_invalid_email')
|
||||
: 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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