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;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feedback-form__honeypot {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.feedback-form__field {
|
.feedback-form__field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ export default function FeedbackModal({
|
|||||||
const [category, setCategory] = useState<FeedbackCategory>('general')
|
const [category, setCategory] = useState<FeedbackCategory>('general')
|
||||||
const [contactEmail, setContactEmail] = useState('')
|
const [contactEmail, setContactEmail] = useState('')
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
|
const [website, setWebsite] = useState('')
|
||||||
const [submitState, setSubmitState] = useState<SubmitState>('idle')
|
const [submitState, setSubmitState] = useState<SubmitState>('idle')
|
||||||
const [statusMessage, setStatusMessage] = useState<string | null>(null)
|
const [statusMessage, setStatusMessage] = useState<string | null>(null)
|
||||||
const closeTimerRef = useRef<number | null>(null)
|
const closeTimerRef = useRef<number | null>(null)
|
||||||
|
const openedAtRef = useRef<number>(Date.now())
|
||||||
|
|
||||||
const isBusy = submitState === 'submitting' || submitState === 'success'
|
const isBusy = submitState === 'submitting' || submitState === 'success'
|
||||||
|
|
||||||
@@ -58,9 +60,12 @@ export default function FeedbackModal({
|
|||||||
setCategory('general')
|
setCategory('general')
|
||||||
setContactEmail('')
|
setContactEmail('')
|
||||||
setMessage('')
|
setMessage('')
|
||||||
|
setWebsite('')
|
||||||
setSubmitState('idle')
|
setSubmitState('idle')
|
||||||
setStatusMessage(null)
|
setStatusMessage(null)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
openedAtRef.current = Date.now()
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent) => {
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
@@ -76,7 +81,9 @@ export default function FeedbackModal({
|
|||||||
message: message.trim(),
|
message: message.trim(),
|
||||||
contactEmail: contactEmail.trim() || undefined,
|
contactEmail: contactEmail.trim() || undefined,
|
||||||
logbookId,
|
logbookId,
|
||||||
logbookTitle
|
logbookTitle,
|
||||||
|
openedAt: openedAtRef.current,
|
||||||
|
website
|
||||||
})
|
})
|
||||||
setSubmitState('success')
|
setSubmitState('success')
|
||||||
setStatusMessage(t('feedback.success'))
|
setStatusMessage(t('feedback.success'))
|
||||||
@@ -91,7 +98,11 @@ export default function FeedbackModal({
|
|||||||
? t('feedback.error_not_configured')
|
? t('feedback.error_not_configured')
|
||||||
: error instanceof FeedbackApiError && error.code === 'INVALID_EMAIL'
|
: error instanceof FeedbackApiError && error.code === 'INVALID_EMAIL'
|
||||||
? t('feedback.error_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}>
|
<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">
|
<label className="feedback-form__field">
|
||||||
<span>{t('feedback.category_label')}</span>
|
<span>{t('feedback.category_label')}</span>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -416,7 +416,9 @@
|
|||||||
"success": "Vielen Dank! Ihr Feedback wurde gesendet.",
|
"success": "Vielen Dank! Ihr Feedback wurde gesendet.",
|
||||||
"error_send": "Feedback konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.",
|
"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_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": {
|
"demo": {
|
||||||
"logbook_title": "Demo-Logbuch Ostsee",
|
"logbook_title": "Demo-Logbuch Ostsee",
|
||||||
|
|||||||
@@ -416,7 +416,9 @@
|
|||||||
"success": "Thank you! Your feedback has been sent.",
|
"success": "Thank you! Your feedback has been sent.",
|
||||||
"error_send": "Could not send feedback. Please try again later.",
|
"error_send": "Could not send feedback. Please try again later.",
|
||||||
"error_invalid_email": "Please enter a valid email address.",
|
"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": {
|
"demo": {
|
||||||
"logbook_title": "Baltic Sea Demo Logbook",
|
"logbook_title": "Baltic Sea Demo Logbook",
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { apiFetch } from './api.js'
|
|||||||
export type FeedbackCategory = 'bug' | 'feature' | 'general'
|
export type FeedbackCategory = 'bug' | 'feature' | 'general'
|
||||||
|
|
||||||
export class FeedbackApiError extends Error {
|
export class FeedbackApiError extends Error {
|
||||||
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL'
|
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL' | 'RATE_LIMITED' | 'SPAM_DETECTED'
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
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)
|
super(message)
|
||||||
this.name = 'FeedbackApiError'
|
this.name = 'FeedbackApiError'
|
||||||
@@ -27,6 +27,8 @@ export async function sendFeedback(payload: {
|
|||||||
contactEmail?: string | null
|
contactEmail?: string | null
|
||||||
logbookId?: string | null
|
logbookId?: string | null
|
||||||
logbookTitle?: string | null
|
logbookTitle?: string | null
|
||||||
|
openedAt: number
|
||||||
|
website?: string
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const contactEmail = payload.contactEmail?.trim()
|
const contactEmail = payload.contactEmail?.trim()
|
||||||
if (contactEmail && !isValidFeedbackEmail(contactEmail)) {
|
if (contactEmail && !isValidFeedbackEmail(contactEmail)) {
|
||||||
@@ -43,7 +45,9 @@ export async function sendFeedback(payload: {
|
|||||||
logbookId: payload.logbookId || undefined,
|
logbookId: payload.logbookId || undefined,
|
||||||
logbookTitle: payload.logbookTitle || undefined,
|
logbookTitle: payload.logbookTitle || undefined,
|
||||||
appVersion: typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 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')
|
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(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
if (!res.ok) {
|
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 { Router } from 'express'
|
||||||
import { isNtfyConfigured, sendFeedbackViaNtfy } from '../services/ntfyNotify.js'
|
import { isNtfyConfigured, sendFeedbackViaNtfy } from '../services/ntfyNotify.js'
|
||||||
import { requireUser } from '../middleware/auth.js'
|
import { requireUser } from '../middleware/auth.js'
|
||||||
|
import { analyzeFeedbackSpam, feedbackLimiter } from '../middleware/feedbackProtection.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -25,14 +26,24 @@ router.get('/status', requireUser, (_req, res) => {
|
|||||||
res.json({ enabled: isNtfyConfigured() })
|
res.json({ enabled: isNtfyConfigured() })
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/', requireUser, async (req: any, res) => {
|
router.post('/', requireUser, feedbackLimiter, async (req: any, res) => {
|
||||||
try {
|
try {
|
||||||
if (!isNtfyConfigured()) {
|
if (!isNtfyConfigured()) {
|
||||||
return res.status(503).json({ error: 'Feedback is not configured on this server' })
|
return res.status(503).json({ error: 'Feedback is not configured on this server' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { category, message, username, contactEmail, logbookId, logbookTitle, appVersion, pageUrl } =
|
const {
|
||||||
req.body ?? {}
|
category,
|
||||||
|
message,
|
||||||
|
username,
|
||||||
|
contactEmail,
|
||||||
|
logbookId,
|
||||||
|
logbookTitle,
|
||||||
|
appVersion,
|
||||||
|
pageUrl,
|
||||||
|
website,
|
||||||
|
openedAt
|
||||||
|
} = req.body ?? {}
|
||||||
|
|
||||||
if (typeof category !== 'string' || !VALID_CATEGORIES.has(category)) {
|
if (typeof category !== 'string' || !VALID_CATEGORIES.has(category)) {
|
||||||
return res.status(400).json({ error: 'Invalid 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({
|
await sendFeedbackViaNtfy({
|
||||||
category,
|
category,
|
||||||
message: trimmedMessage,
|
message: trimmedMessage,
|
||||||
|
|||||||
Reference in New Issue
Block a user